1use std::collections::{HashMap, HashSet};
2
3use crate::errors::CoreError;
4use crate::models::{
5 DiscoverSkillsInput, DiscoveredSkillRecord, InstallSkillInput, RemoveSkillInput, SkillRecord,
6 ToggleSkillEngineInput,
7};
8
9use super::{SkillRow, decode_base64_lossy, parse_skill_frontmatter};
10
11pub async fn list_source_repos(
17 db: &sqlx::SqlitePool,
18) -> crate::Result<HashMap<String, Option<String>>> {
19 let rows = sqlx::query!("SELECT id, source_repo FROM skills WHERE status = 'active'")
20 .fetch_all(db)
21 .await?;
22 let mut out = HashMap::with_capacity(rows.len());
23 for row in rows {
24 out.insert(row.id, row.source_repo);
25 }
26 Ok(out)
27}
28
29pub async fn expand_repo_scopes_with_source_aliases(
39 db: &sqlx::SqlitePool,
40 repo_full_names: &[String],
41) -> crate::Result<Vec<String>> {
42 let mut scopes = Vec::new();
43 let mut seen = HashSet::new();
44 let mut repo_names = Vec::new();
45
46 for raw in repo_full_names {
47 let Some(repo) = crate::git::normalize_github_repo_full_name(raw) else {
48 continue;
49 };
50 if seen.insert(repo.clone()) {
51 if let Some((_, name)) = repo.rsplit_once('/')
52 && !repo_names.iter().any(|existing| existing == name)
53 {
54 repo_names.push(name.to_owned());
55 }
56 scopes.push(repo);
57 }
58 }
59
60 for repo_name in repo_names {
61 let pattern = format!("%/{repo_name}");
62 let rows: Vec<String> = sqlx::query_scalar(
63 "SELECT LOWER(source_repo)
64 FROM skills
65 WHERE status = 'active'
66 AND source_repo IS NOT NULL
67 AND TRIM(source_repo) <> ''
68 AND LOWER(source_repo) LIKE ?1
69 GROUP BY LOWER(source_repo)",
70 )
71 .bind(pattern)
72 .fetch_all(db)
73 .await?;
74
75 let candidates: Vec<String> = rows
76 .into_iter()
77 .filter_map(|repo| crate::git::normalize_github_repo_full_name(&repo))
78 .filter(|repo| {
79 repo.rsplit_once('/')
80 .is_some_and(|(_, name)| name == repo_name)
81 && !seen.contains(repo)
82 })
83 .collect();
84
85 if candidates.len() == 1 {
86 let Some(alias) = candidates.into_iter().next() else {
87 continue;
88 };
89 seen.insert(alias.clone());
90 scopes.push(alias);
91 }
92 }
93
94 Ok(scopes)
95}
96
97pub async fn list(db: &sqlx::SqlitePool) -> crate::Result<Vec<SkillRecord>> {
98 let rows = sqlx::query_as!(
99 SkillRow,
100 "SELECT id, name, source, directory, version, description, type, \
101 engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
102 enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
103 installed_at, updated_at, origin FROM skills WHERE status = 'active' \
104 ORDER BY installed_at DESC",
105 )
106 .fetch_all(db)
107 .await?;
108 Ok(rows.into_iter().map(SkillRecord::from).collect())
109}
110
111pub async fn list_all(db: &sqlx::SqlitePool) -> crate::Result<Vec<SkillRecord>> {
115 let rows = sqlx::query_as!(
116 SkillRow,
117 "SELECT id, name, source, directory, version, description, type, \
118 engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
119 enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
120 installed_at, updated_at, origin FROM skills ORDER BY installed_at DESC",
121 )
122 .fetch_all(db)
123 .await?;
124 Ok(rows.into_iter().map(SkillRecord::from).collect())
125}
126
127pub async fn add(db: &sqlx::SqlitePool, input: InstallSkillInput) -> crate::Result<SkillRecord> {
128 let id = format!("skill-{}-{}", input.owner, input.directory);
129 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
130 let engines_json = serde_json::to_string(&["claude"])?;
131 let tags_json = serde_json::to_string(&["github", "imported"])?;
132 let name = input.directory.replace('-', " ");
133 let description = format!("Imported from {}/{}", input.owner, input.repo);
134
135 sqlx::query!(
136 "INSERT OR IGNORE INTO skills
137 (id, name, source, directory, version, description, type, engines, tags,
138 repo_owner, repo_name, repo_branch, enabled_for_claude, installed_at, updated_at)
139 VALUES (?1, ?2, 'github', ?3, '1.0.0', ?4, 'skill', ?5, ?6,
140 ?7, ?8, ?9, 1, ?10, ?10)",
141 id,
142 name,
143 input.directory,
144 description,
145 engines_json,
146 tags_json,
147 input.owner,
148 input.repo,
149 input.branch,
150 now
151 )
152 .execute(db)
153 .await?;
154
155 let row = sqlx::query_as!(
156 SkillRow,
157 "SELECT id, name, source, directory, version, description, type, \
158 engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
159 enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
160 installed_at, updated_at, origin FROM skills WHERE id = ?1",
161 id
162 )
163 .fetch_one(db)
164 .await?;
165 Ok(SkillRecord::from(row))
166}
167
168pub async fn remove(db: &sqlx::SqlitePool, input: RemoveSkillInput) -> crate::Result<()> {
169 let skill: Option<SkillRecord> = sqlx::query_as!(
170 SkillRow,
171 "SELECT id, name, source, directory, version, description, type, \
172 engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
173 enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
174 installed_at, updated_at, origin FROM skills WHERE id = ?1",
175 input.id
176 )
177 .fetch_optional(db)
178 .await?
179 .map(SkillRecord::from);
180
181 let Some(skill) = skill else {
184 return Err(CoreError::NotFound(format!(
185 "rule '{}' not found. Inspect local memory with `difflore status --json`.",
186 input.id
187 )));
188 };
189
190 sqlx::query!("DELETE FROM skills WHERE id = ?1", input.id)
191 .execute(db)
192 .await?;
193
194 {
195 for engine in &["codex", "claude", "gemini", "cursor"] {
196 if let Err(e) =
197 crate::skill_fs::sync_engine_link(&skill.source, &skill.directory, engine, false)
198 {
199 eprintln!("warning: sync_engine_link failed for engine {engine}: {e}");
200 }
201 }
202 let skill_dir = crate::skill_fs::skills_base_dir()
203 .map_err(CoreError::Internal)?
204 .join(&skill.source)
205 .join(&skill.directory);
206 if skill_dir.exists() {
207 let _ = std::fs::remove_dir_all(&skill_dir);
208 }
209 }
210
211 Ok(())
212}
213
214pub async fn toggle_engine(
215 db: &sqlx::SqlitePool,
216 input: ToggleSkillEngineInput,
217) -> crate::Result<()> {
218 let val = i32::from(input.enabled);
219 match input.engine.as_str() {
220 "codex" => {
221 sqlx::query!("UPDATE skills SET enabled_for_codex = ?1, updated_at = datetime('now') WHERE id = ?2",
222 val, input.id).execute(db).await?;
223 }
224 "claude" => {
225 sqlx::query!("UPDATE skills SET enabled_for_claude = ?1, updated_at = datetime('now') WHERE id = ?2",
226 val, input.id).execute(db).await?;
227 }
228 "gemini" => {
229 sqlx::query!("UPDATE skills SET enabled_for_gemini = ?1, updated_at = datetime('now') WHERE id = ?2",
230 val, input.id).execute(db).await?;
231 }
232 "cursor" => {
233 sqlx::query!("UPDATE skills SET enabled_for_cursor = ?1, updated_at = datetime('now') WHERE id = ?2",
234 val, input.id).execute(db).await?;
235 }
236 other => return Err(CoreError::Internal(format!("unknown engine: {other}"))),
237 }
238
239 if let Some(row) = sqlx::query_as!(
241 SkillRow,
242 "SELECT id, name, source, directory, version, description, type, \
243 engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
244 enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
245 installed_at, updated_at, origin FROM skills WHERE id = ?1",
246 input.id
247 )
248 .fetch_optional(db)
249 .await?
250 {
251 let skill = SkillRecord::from(row);
252 if let Err(e) = crate::skill_fs::sync_engine_link(
253 &skill.source,
254 &skill.directory,
255 &input.engine,
256 input.enabled,
257 ) {
258 eprintln!(
259 "warning: sync_engine_link failed for engine {}: {e}",
260 input.engine
261 );
262 }
263 }
264
265 Ok(())
266}
267
268pub async fn discover(
269 db: &sqlx::SqlitePool,
270 input: DiscoverSkillsInput,
271) -> crate::Result<Vec<DiscoveredSkillRecord>> {
272 let branch = input.branch.unwrap_or_else(|| "main".into());
273 let repo_slug = format!("{}/{}", input.owner, input.repo);
274
275 let dirs: Vec<String> = if which::which("gh").is_ok() {
276 let output = std::process::Command::new("gh")
277 .args([
278 "api",
279 &format!("repos/{repo_slug}/contents"),
280 "--jq",
281 ".[].name",
282 ])
283 .output();
284
285 match output {
286 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
287 .lines()
288 .map(|l| l.trim().to_owned())
289 .filter(|l| !l.is_empty() && !l.starts_with('.'))
290 .collect(),
291 _ => vec![],
292 }
293 } else {
294 vec![]
295 };
296
297 let installed_ids: Vec<String> =
298 sqlx::query_scalar!("SELECT id FROM skills WHERE source = 'github'")
299 .fetch_all(db)
300 .await?;
301
302 if dirs.is_empty() {
303 return Ok(vec![DiscoveredSkillRecord {
304 name: format!("{} skills", input.repo),
305 description: format!("Skills from {repo_slug}"),
306 r#type: "skill".into(),
307 engines: vec!["claude".into()],
308 tags: vec!["remote".into()],
309 version: "1.0.0".into(),
310 directory: input.repo.clone(),
311 repo_owner: input.owner.clone(),
312 repo_name: input.repo,
313 repo_branch: branch,
314 installed: installed_ids.iter().any(|id| id.contains(&input.owner)),
315 }]);
316 }
317
318 let mut results = Vec::new();
319 for dir in dirs {
320 let skill_id = format!("skill-{}-{}", input.owner, dir);
321 let installed = installed_ids.contains(&skill_id);
322
323 let (name, description, fm) = if which::which("gh").is_ok() {
324 let md_output = std::process::Command::new("gh")
325 .args([
326 "api",
327 &format!("repos/{repo_slug}/contents/{dir}/SKILL.md"),
328 "--jq",
329 ".content",
330 ])
331 .output();
332
333 match md_output {
334 Ok(o) if o.status.success() => {
335 let raw = String::from_utf8_lossy(&o.stdout).trim().to_owned();
336 let decoded = decode_base64_lossy(&raw);
337 let fm = parse_skill_frontmatter(&decoded);
338 let first_line = fm
339 .body
340 .lines()
341 .find(|l| !l.trim().is_empty())
342 .unwrap_or(&dir)
343 .trim_start_matches('#')
344 .trim()
345 .to_owned();
346 let desc = fm
347 .body
348 .lines()
349 .skip(1)
350 .find(|l| !l.trim().is_empty())
351 .unwrap_or("Remote skill")
352 .trim()
353 .to_owned();
354 (first_line, desc, fm)
355 }
356 _ => (
357 dir.replace('-', " "),
358 format!("Skill from {repo_slug}/{dir}"),
359 parse_skill_frontmatter(""),
360 ),
361 }
362 } else {
363 (
364 dir.replace('-', " "),
365 format!("Skill from {repo_slug}/{dir}"),
366 parse_skill_frontmatter(""),
367 )
368 };
369
370 results.push(DiscoveredSkillRecord {
371 name,
372 description,
373 r#type: fm.r#type.unwrap_or_else(|| "skill".into()),
374 engines: fm.engines.unwrap_or_else(|| vec!["claude".into()]),
375 tags: fm
376 .tags
377 .unwrap_or_else(|| vec!["remote".into(), "github".into()]),
378 version: fm.version.unwrap_or_else(|| "1.0.0".into()),
379 directory: dir,
380 repo_owner: input.owner.clone(),
381 repo_name: input.repo.clone(),
382 repo_branch: branch.clone(),
383 installed,
384 });
385 }
386
387 Ok(results)
388}
389
390pub async fn sync_links(db: &sqlx::SqlitePool) -> crate::Result<()> {
391 let rows = sqlx::query_as!(
392 SkillRow,
393 "SELECT id, name, source, directory, version, description, type, \
394 engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
395 enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
396 installed_at, updated_at, origin FROM skills WHERE status = 'active'",
397 )
398 .fetch_all(db)
399 .await?;
400 let skills: Vec<SkillRecord> = rows.into_iter().map(SkillRecord::from).collect();
401
402 for skill in &skills {
403 let engines = [
404 ("codex", skill.enabled_for_codex),
405 ("claude", skill.enabled_for_claude),
406 ("gemini", skill.enabled_for_gemini),
407 ("cursor", skill.enabled_for_cursor),
408 ];
409 for (engine, enabled) in engines {
410 if let Err(e) =
411 crate::skill_fs::sync_engine_link(&skill.source, &skill.directory, engine, enabled)
412 {
413 eprintln!("warning: sync_engine_link failed for engine {engine}: {e}");
414 }
415 }
416 }
417
418 Ok(())
419}