1use crate::errors::CoreError;
2use crate::models::{CreateLocalSkillInput, SkillRecord};
3use uuid::Uuid;
4
5use super::SkillRow;
6
7async fn record_engine_link_failure(
8 db: &sqlx::SqlitePool,
9 skill_id: &str,
10 engine: &str,
11 error: &std::io::Error,
12) {
13 let event_id = format!("rule-event-{}", Uuid::new_v4());
14 let reason = format!("sync_engine_link failed for engine {engine}: {error}");
15 let metadata = serde_json::json!({
16 "engine": engine,
17 "enabled": true,
18 "error": error.to_string(),
19 })
20 .to_string();
21 if let Err(insert_err) = sqlx::query(
22 "INSERT INTO rule_events
23 (id, skill_id, kind, source, reason, metadata)
24 VALUES (?1, ?2, 'engine_link_failed', 'local_rule_create', ?3, ?4)",
25 )
26 .bind(event_id)
27 .bind(skill_id)
28 .bind(reason)
29 .bind(metadata)
30 .execute(db)
31 .await
32 {
33 eprintln!("warning: failed to audit sync_engine_link failure: {insert_err}");
34 }
35}
36
37pub async fn create_local(
38 db: &sqlx::SqlitePool,
39 input: CreateLocalSkillInput,
40) -> crate::Result<SkillRecord> {
41 let slug: String = input
42 .name
43 .to_lowercase()
44 .chars()
45 .map(|c| {
46 if c.is_ascii_alphanumeric() || c == '_' {
47 c
48 } else {
49 '-'
50 }
51 })
52 .collect::<String>()
53 .split('-')
54 .filter(|s| !s.is_empty())
55 .collect::<Vec<_>>()
56 .join("-");
57 if slug.is_empty() {
58 return Err(CoreError::Internal(
59 "skill name produces an empty slug after sanitization".into(),
60 ));
61 }
62 let id = format!("local-{slug}");
63 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
64 let engines = input.engines.unwrap_or_default();
65 let tags = input.tags.unwrap_or_default();
66 let engines_json = serde_json::to_string(&engines)?;
67 let tags_json = serde_json::to_string(&tags)?;
68 let skill_type = input.r#type.unwrap_or_else(|| "skill".into());
69 let description = input.description.unwrap_or_default();
70
71 let base_dir = crate::skill_fs::skills_base_dir()
72 .map_err(CoreError::Internal)?
73 .join("local");
74 let skill_dir = base_dir.join(&slug);
75
76 std::fs::create_dir_all(&base_dir)
81 .map_err(|e| CoreError::Internal(format!("failed to create skills base directory: {e}")))?;
82
83 let canonical_base = base_dir
84 .canonicalize()
85 .map_err(|e| CoreError::Internal(format!("failed to resolve skills base dir: {e}")))?;
86 let skill_dir_for_check = canonical_base.join(&slug);
87 if !skill_dir_for_check.starts_with(&canonical_base) {
88 return Err(CoreError::Internal("invalid skill name".into()));
89 }
90
91 let mut skill_md = String::new();
92 skill_md.push_str("---\n");
93 skill_md.push_str(&format!("type: {}\n", &skill_type));
94 if !engines.is_empty() {
95 skill_md.push_str(&format!("engines: [{}]\n", engines.join(", ")));
96 }
97 if !tags.is_empty() {
98 skill_md.push_str(&format!("tags: [{}]\n", tags.join(", ")));
99 }
100 if let Some(ref trigger) = input.trigger
101 && !trigger.is_empty()
102 {
103 skill_md.push_str(&format!("trigger: {trigger}\n"));
104 }
105 skill_md.push_str("---\n\n");
106 skill_md.push_str(&format!("# {}\n\n", &input.name));
107 if !description.is_empty() {
108 skill_md.push_str(&format!("{}\n", &description));
109 }
110 if let Some(ref content) = input.content
111 && !content.is_empty()
112 {
113 skill_md.push_str(&format!("\n{content}\n"));
114 }
115
116 let existing_id = sqlx::query_scalar!("SELECT id FROM skills WHERE id = ?1", id)
122 .fetch_optional(db)
123 .await?;
124 if existing_id.is_some() {
125 return Err(CoreError::Validation(format!(
126 "a rule with id '{id}' already exists. Remove it first with \
127 the memory management UI or pick a different name."
128 )));
129 }
130
131 std::fs::create_dir_all(&skill_dir)
132 .map_err(|e| CoreError::Internal(format!("failed to create skill directory: {e}")))?;
133 let canonical_skill = skill_dir
134 .canonicalize()
135 .map_err(|e| CoreError::Internal(format!("failed to resolve skill directory: {e}")))?;
136 if !canonical_skill.starts_with(&canonical_base) {
137 return Err(CoreError::Internal("invalid skill name".into()));
138 }
139
140 std::fs::write(skill_dir.join("SKILL.md"), &skill_md)
141 .map_err(|e| CoreError::Internal(format!("failed to write SKILL.md: {e}")))?;
142
143 let insert_result = sqlx::query!(
144 "INSERT INTO skills
145 (id, name, source, directory, version, description, type, engines, tags,
146 trigger, check_prompt, enabled_for_claude, installed_at, updated_at)
147 VALUES (?1, ?2, 'local', ?3, '1.0.0', ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10, ?10)",
148 id,
149 input.name,
150 slug,
151 description,
152 skill_type,
153 engines_json,
154 tags_json,
155 input.trigger,
156 input.check_prompt,
157 now
158 )
159 .execute(db)
160 .await;
161 if let Err(e) = insert_result {
162 let _ = std::fs::remove_dir_all(&skill_dir);
163 return Err(e.into());
164 }
165
166 for engine_name in &engines {
167 if let Err(e) = crate::skill_fs::sync_engine_link("local", &slug, engine_name, true) {
168 eprintln!("warning: sync_engine_link failed for engine {engine_name}: {e}");
169 record_engine_link_failure(db, &id, engine_name, &e).await;
170 }
171 }
172
173 let row = sqlx::query_as!(
174 SkillRow,
175 "SELECT id, name, source, directory, version, description, type, \
176 engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
177 enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
178 installed_at, updated_at, origin FROM skills WHERE id = ?1",
179 id
180 )
181 .fetch_one(db)
182 .await?;
183 Ok(SkillRecord::from(row))
184}