Skip to main content

agents_skills/
manager.rs

1//! High-level `Manager` facade: one-stop add/list/remove/update over an injectable [`Env`].
2//!
3//! The manager is pure data: it returns structured outcomes and never prints or exits;
4//! the CLI layer (src/commands) is responsible for rendering.
5
6use std::collections::{BTreeMap, HashMap, HashSet};
7use std::path::PathBuf;
8
9use serde::Serialize;
10
11use crate::core::agents::{
12    AGENTS, Agent, Env, agent_display, config_home, detect_installed_agents, disabled_skills_dir,
13    ensure_universal_agents, get_agent, home, is_installed,
14};
15use crate::core::discover::{Skill, discover_skills, filter_skills};
16use crate::core::fetch::fetch_source;
17use crate::core::github::fetch_skill_via_api;
18use crate::core::install::{
19    get_canonical_path, install_skill, list_disabled_skills, list_installed_skills, move_skill,
20    sanitize_name, scan_disabled, scan_installed,
21};
22use crate::core::link::{
23    LinkOutcome, is_agent_linked, link_agent, pending_backup, private_content, unlink_agent,
24};
25use crate::core::lock::{
26    LockEntry, compute_folder_hash, find_lock_entry, global_lock_path, local_lock_path,
27    lock_fields, read_local_lock, write_local_lock,
28};
29use crate::core::source::{Source, SourceType, parse_source};
30use crate::error::{Result, SkillsError};
31
32/// Skill manager: carries injectable context and runs add/list/remove/update.
33///
34/// This is the high-level entry point for library consumers. It resolves an [`Env`]
35/// (home / config / cwd) once at construction, then every operation is a plain method
36/// taking a request struct and returning a structured outcome.
37///
38/// # Examples
39///
40/// ```
41/// use agents_skills::{AddRequest, Manager};
42///
43/// // Real environment:
44/// let real = Manager::new();
45///
46/// // Or a sandboxed environment (no side effects outside the given paths):
47/// let sandboxed = Manager::builder()
48///     .home("/tmp/home")
49///     .config("/tmp/config")
50///     .cwd("/tmp/project")
51///     .build();
52///
53/// let req = AddRequest::new("anthropics/skills");
54/// let _ = (real, sandboxed, req);
55/// ```
56pub struct Manager {
57    env: Env,
58}
59
60impl Default for Manager {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl Manager {
67    /// Build a manager from the real environment (home / config / cwd).
68    ///
69    /// Equivalent to [`Manager::builder`]`().build()`.
70    pub fn new() -> Self {
71        Self::builder().build()
72    }
73
74    /// Start customizing a manager (inject home/config/cwd/env vars).
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use agents_skills::Manager;
80    ///
81    /// let manager = Manager::builder()
82    ///     .home("/tmp/home")
83    ///     .env_var("CLAUDE_CONFIG_DIR", "/tmp/claude")
84    ///     .build();
85    /// ```
86    pub fn builder() -> ManagerBuilder {
87        ManagerBuilder::default()
88    }
89
90    /// Access the resolved environment context.
91    pub fn env(&self) -> &Env {
92        &self.env
93    }
94
95    /// Add (install) skills from a source.
96    ///
97    /// Parses the source, discovers its skills, and installs each selected skill
98    /// into the canonical dir (the only place real files live), recording
99    /// successful installs in the lockfile. Returns a structured [`AddOutcome`]
100    /// with discovered, selected, installed and failed skills.
101    ///
102    /// `add` never links any agent: use [`Manager::agent`] to expose the canonical
103    /// dir to an agent afterwards.
104    ///
105    /// # Selection defaults
106    ///
107    /// - `skills` empty → all discovered skills; a `"*"` entry → all as well.
108    /// - `list_only` → discover and report, without installing anything.
109    ///
110    /// # Examples
111    ///
112    /// Install a local skill into a scratch environment (hermetic — no network, no
113    /// real home access):
114    ///
115    /// ```
116    /// use agents_skills::{AddRequest, Manager};
117    ///
118    /// let tmp = tempfile::TempDir::new().unwrap();
119    /// let src = tmp.path().join("hello");
120    /// std::fs::create_dir_all(&src).unwrap();
121    /// std::fs::write(
122    ///     src.join("SKILL.md"),
123    ///     "---\nname: hello\ndescription: says hello\n---\n\n# hello\n",
124    /// )
125    /// .unwrap();
126    ///
127    /// let manager = Manager::builder()
128    ///     .home(tmp.path().join("home"))
129    ///     .config(tmp.path().join("config"))
130    ///     .cwd(tmp.path().join("project"))
131    ///     .build();
132    ///
133    /// let outcome = manager.add(&AddRequest::new(src.display().to_string()))?;
134    /// assert!(!outcome.installed.is_empty());
135    /// # Ok::<(), agents_skills::Error>(())
136    /// ```
137    ///
138    /// # Errors
139    ///
140    /// - [`SkillsError::Message`] when the source is invalid, unreadable, or contains
141    ///   no valid skill (a `SKILL.md` with `name` and `description`).
142    /// - [`SkillsError::Git`], [`SkillsError::Http`], [`SkillsError::Io`],
143    ///   [`SkillsError::Zip`], etc. for transport and filesystem failures.
144    pub fn add(&self, req: &AddRequest) -> Result<AddOutcome> {
145        let parsed = parse_source(&req.source)?;
146        // `@skill` in the source is an explicit selection, like `--skill`.
147        let include_internal = !req.skills.is_empty() || parsed.skill_filter.is_some();
148
149        // Fetch skills (the temp dir is held until install finishes).
150        let skills: Vec<Skill>;
151        let _temp: Option<tempfile::TempDir>;
152        if parsed.ty == SourceType::Local {
153            let path = parsed
154                .local_path
155                .as_ref()
156                .ok_or_else(|| SkillsError::msg("local source missing path"))?;
157            if !path.exists() {
158                return Err(SkillsError::msg(format!(
159                    "Local path does not exist: {}",
160                    path.display()
161                )));
162            }
163            skills = discover_skills(path, parsed.subpath.as_deref(), include_internal)?;
164            _temp = None;
165        } else {
166            // `@skill` install: fetch only the matching subdir via the GitHub API
167            // when possible; fall back to the whole-repo archive on any failure.
168            let fast: Option<(tempfile::TempDir, PathBuf)> =
169                if let (Some(name), false) = (parsed.skill_filter.as_deref(), req.list_only) {
170                    fetch_skill_via_api(&parsed, name, include_internal)
171                        .ok()
172                        .flatten()
173                } else {
174                    None
175                };
176            let (tmp, root) = match fast {
177                Some(v) => v,
178                None => fetch_source(&parsed)?,
179            };
180            skills = discover_skills(&root, parsed.subpath.as_deref(), include_internal)?;
181            _temp = Some(tmp);
182        }
183
184        if skills.is_empty() {
185            return Err(SkillsError::msg(
186                "No valid skills found. Skills require a SKILL.md with name and description.",
187            ));
188        }
189
190        // --list: report discovered skills without installing.
191        if req.list_only {
192            return Ok(AddOutcome {
193                source: parsed,
194                skills,
195                selected: Vec::new(),
196                installed: Vec::new(),
197                failed: Vec::new(),
198                list_only: true,
199            });
200        }
201
202        // Select skills. `--skill` args and the source's `@skill` filter both count.
203        let filters = skill_filters(&req.skills, parsed.skill_filter.as_deref());
204        let selected: Vec<Skill> = if filters.iter().any(|s| s == "*") {
205            skills.clone()
206        } else if !filters.is_empty() {
207            filter_skills(&skills, &filters)
208        } else {
209            skills.clone()
210        };
211
212        // Install into the canonical dir (the only place real files live).
213        let mut installed: Vec<InstallSuccess> = Vec::new();
214        let mut failed: Vec<InstallFailure> = Vec::new();
215        for skill in &selected {
216            let r = install_skill(skill, req.global, &self.env);
217            if r.success && !r.skipped {
218                installed.push(InstallSuccess {
219                    name: skill.name.clone(),
220                    canonical_path: r.canonical_path,
221                });
222            } else if !r.success {
223                failed.push(InstallFailure {
224                    skill: skill.name.clone(),
225                    error: r.error.unwrap_or_default(),
226                });
227            }
228        }
229
230        // Write the lock (only for successfully installed skills).
231        if !installed.is_empty() {
232            write_lock(&parsed, &selected, &installed, req.global, &self.env)?;
233        }
234
235        Ok(AddOutcome {
236            source: parsed,
237            skills,
238            selected,
239            installed,
240            failed,
241            list_only: false,
242        })
243    }
244
245    /// Link or unlink agents' skills dirs relative to the canonical dir.
246    ///
247    /// Connects each agent's own skills dir to the canonical dir with a
248    /// directory-level symlink, so every install/update/remove is immediately
249    /// visible to all linked agents. With `req.unlink`, disconnects those dirs
250    /// instead — removes the symlink (only when it points at the canonical dir)
251    /// and restores any parked backup content into a real dir; the canonical dir
252    /// and its skills are left untouched.
253    ///
254    /// Pre-existing content is never destroyed. When linking, every entry of the
255    /// agent dir that does not go into the canonical dir is parked in a backup
256    /// slot (`<base>/.agents/backup-skills/<agent>`); unlink restores it. With
257    /// `req.migrate`, skill subdirs are moved into the canonical dir instead —
258    /// name clashes keep the canonical copy, and names disabled in the
259    /// `disabled-skills` dir stay disabled (the agent-side copy is parked,
260    /// reported via [`LinkOutcome::Migrated`] `skipped`) — and only non-skill
261    /// entries are parked. Rerunning with `migrate` on an already linked agent
262    /// pulls parked skills out of the backup slot. Legacy per-skill symlinks
263    /// pointing into the canonical dir are taken over automatically. Linking is
264    /// refused only when the agent dir is a foreign symlink or a stale non-empty
265    /// backup slot exists.
266    ///
267    /// Universal agents (whose skills dir already is the canonical dir) report
268    /// [`LinkOutcome::AlreadyLinked`]. Agents whose root dir does not exist in
269    /// this scope are reported as [`LinkOutcome::Skipped`] (except
270    /// `claude-code`, the historical exception).
271    ///
272    /// # Selection defaults
273    ///
274    /// - `agents` empty → auto-detect installed agents (plus the universal agents);
275    ///   a `"*"` entry → every known agent.
276    ///
277    /// # Errors
278    ///
279    /// [`SkillsError::InvalidAgents`] when `agents` names an unknown agent.
280    pub fn agent(&self, req: &AgentRequest) -> Result<AgentOutcome> {
281        let target_agents = resolve_target_agents(&req.agents, &self.env)?;
282        let results = target_agents
283            .iter()
284            .map(|agent| AgentLinkResult {
285                agent: agent.name.to_string(),
286                display: agent.display.to_string(),
287                outcome: if req.unlink {
288                    unlink_agent(agent, req.global, &self.env)
289                } else {
290                    link_agent(agent, req.global, &self.env, req.migrate)
291                },
292            })
293            .collect();
294        Ok(AgentOutcome {
295            global: req.global,
296            results,
297        })
298    }
299
300    /// Link status of every installed agent in this scope.
301    ///
302    /// Only agents detected as installed locally (or already linked) are reported.
303    /// Agents that natively read the canonical dir (universal) report `canonical`;
304    /// agents connected via a directory-level symlink report `linked`.
305    ///
306    /// For unlinked, non-canonical agents the status classifies the agent dir's
307    /// private content (`internal_skills` / `internal_others`, the same rules
308    /// link and migrate use) and reports a pending backup slot (`pending_backup`)
309    /// when one is waiting to be restored by unlink.
310    ///
311    /// Ordering: agents that natively use the canonical dir (`canonical: true`)
312    /// come first, then the remaining agents — both groups keep the static agent
313    /// table order. This is the exact order `agent --status` renders; callers do
314    /// not need to sort again.
315    pub fn agent_status(&self, global: bool) -> Vec<AgentStatus> {
316        let mut statuses: Vec<AgentStatus> = AGENTS
317            .iter()
318            .filter(|a| {
319                is_installed(a, &self.env)
320                    || (!a.is_universal() && is_agent_linked(a, global, &self.env))
321            })
322            .map(|a| {
323                let linked = is_agent_linked(a, global, &self.env);
324                let canonical = a.is_universal();
325                // For unlinked, non-canonical agents, classify the private content
326                // of the agent's own skills dir (canonical/linked agents share the
327                // canonical dir, whose contents are shown by `list` instead).
328                let (internal_skills, internal_others, pending_backup) = if linked || canonical {
329                    (Vec::new(), Vec::new(), None)
330                } else {
331                    let (skills, others) = private_content(a, global, &self.env);
332                    let backup = pending_backup(a, global, &self.env)
333                        .map(|(path, items)| BackupStatus { path, items });
334                    (skills, others, backup)
335                };
336                AgentStatus {
337                    name: a.name.to_string(),
338                    display: a.display.to_string(),
339                    linked,
340                    canonical,
341                    internal_skills,
342                    internal_others,
343                    pending_backup,
344                }
345            })
346            .collect();
347        // Stable sort: canonical agents first, others keep table order.
348        statuses.sort_by_key(|s| !s.canonical);
349        statuses
350    }
351
352    /// List installed skills (project or global), enriched with lock metadata.
353    ///
354    /// Scans the canonical skills directory and joins each entry with its lockfile
355    /// record, producing serde-serializable [`ListedSkill`] values — the same shape
356    /// emitted by `list --json`.
357    ///
358    /// # Examples
359    ///
360    /// ```
361    /// use agents_skills::{ListRequest, Manager};
362    ///
363    /// let manager = Manager::new();
364    /// let skills = manager.list(&ListRequest::default())?;
365    /// for skill in skills {
366    ///     println!("{} -> {}", skill.name, skill.path.display());
367    /// }
368    /// # Ok::<(), agents_skills::Error>(())
369    /// ```
370    ///
371    /// # Errors
372    ///
373    /// [`SkillsError::InvalidAgents`] when `agents` names an unknown agent.
374    pub fn list(&self, req: &ListRequest) -> Result<Vec<ListedSkill>> {
375        let invalid: Vec<String> = req
376            .agents
377            .iter()
378            .filter(|a| get_agent(a).is_none())
379            .cloned()
380            .collect();
381        if !invalid.is_empty() {
382            return Err(SkillsError::InvalidAgents(invalid.join(", ")));
383        }
384
385        let lock = read_local_lock(&lock_path(&self.env, req.global));
386        let installed = list_installed_skills(&self.env, req.global, &req.agents);
387        let disabled = list_disabled_skills(&self.env, req.global);
388
389        let mut out = Vec::new();
390        for s in &installed {
391            let entry = find_lock_entry(&lock, &s.name);
392            out.push(ListedSkill {
393                name: s.name.clone(),
394                path: s.canonical_path.clone(),
395                scope: s.scope.clone(),
396                agents: s.agents.iter().map(|a| agent_display(a)).collect(),
397                source: entry.map(|e| e.source.clone()),
398                source_url: entry.and_then(|e| e.source_url.clone()),
399                source_type: entry.map(|e| e.source_type.clone()),
400                enabled: true,
401            });
402        }
403        for s in &disabled {
404            let entry = find_lock_entry(&lock, &s.name);
405            out.push(ListedSkill {
406                name: s.name.clone(),
407                path: s.canonical_path.clone(),
408                scope: s.scope.clone(),
409                agents: Vec::new(),
410                source: entry.map(|e| e.source.clone()),
411                source_url: entry.and_then(|e| e.source_url.clone()),
412                source_type: entry.map(|e| e.source_type.clone()),
413                enabled: false,
414            });
415        }
416        out.sort_by(|a, b| a.name.cmp(&b.name));
417        Ok(out)
418    }
419
420    /// Disable installed skills.
421    ///
422    /// Moves each selected skill's directory from the canonical dir into the sibling
423    /// `disabled-skills` dir, hiding it from every linked or universal agent at once.
424    /// Files are preserved, so [`Manager::enable`] restores them losslessly; the
425    /// lockfile entry is kept, so `list` still shows the skill's source metadata.
426    ///
427    /// # Selection semantics
428    ///
429    /// - `skills` empty and `all` false → nothing is disabled; the outcome reports the
430    ///   currently enabled names (used by the CLI to print a hint).
431    /// - `all` true → every currently enabled skill.
432    ///
433    /// # Examples
434    ///
435    /// Disable an installed skill in a scratch environment (hermetic — no real
436    /// home access):
437    ///
438    /// ```
439    /// use agents_skills::{DisableRequest, Manager};
440    ///
441    /// let tmp = tempfile::TempDir::new().unwrap();
442    /// // Simulate an installed skill in the canonical dir.
443    /// let skill_dir = tmp.path().join("project/.agents/skills/pdf");
444    /// std::fs::create_dir_all(&skill_dir).unwrap();
445    /// std::fs::write(
446    ///     skill_dir.join("SKILL.md"),
447    ///     "---\nname: pdf\ndescription: pdf tools\n---\n\n# pdf\n",
448    /// )
449    /// .unwrap();
450    ///
451    /// let manager = Manager::builder()
452    ///     .home(tmp.path().join("home"))
453    ///     .config(tmp.path().join("config"))
454    ///     .cwd(tmp.path().join("project"))
455    ///     .build();
456    ///
457    /// let outcome = manager.disable(&DisableRequest {
458    ///     skills: vec!["pdf".into()],
459    ///     ..Default::default()
460    /// })?;
461    /// assert_eq!(outcome.disabled, vec!["pdf".to_string()]);
462    /// # Ok::<(), agents_skills::Error>(())
463    /// ```
464    ///
465    /// # Errors
466    ///
467    /// [`SkillsError::Io`] if a directory move fails.
468    pub fn disable(&self, req: &DisableRequest) -> Result<DisableOutcome> {
469        let global = req.global;
470        let installed = scan_installed(&self.env, global);
471        let disabled = scan_disabled(&self.env, global);
472
473        if req.skills.is_empty() && !req.all {
474            return Ok(DisableOutcome {
475                installed,
476                requested: Vec::new(),
477                disabled: Vec::new(),
478                already: Vec::new(),
479                missing: Vec::new(),
480            });
481        }
482
483        let requested: Vec<String> = if req.all {
484            installed.clone()
485        } else {
486            req.skills.clone()
487        };
488        let (disabled_out, already, missing) =
489            set_enabled_state(&requested, &installed, &disabled, global, false, &self.env)?;
490
491        Ok(DisableOutcome {
492            installed,
493            requested,
494            disabled: disabled_out,
495            already,
496            missing,
497        })
498    }
499
500    /// Enable previously disabled skills.
501    ///
502    /// Moves each selected skill's directory from the `disabled-skills` dir back into
503    /// the canonical dir, restoring its visibility to every linked or universal agent.
504    /// This is the exact inverse of [`Manager::disable`].
505    ///
506    /// # Selection semantics
507    ///
508    /// - `skills` empty and `all` false → nothing is enabled; the outcome reports the
509    ///   currently disabled names (used by the CLI to print a hint).
510    /// - `all` true → every currently disabled skill.
511    ///
512    /// # Examples
513    ///
514    /// Re-enable a disabled skill in a scratch environment (hermetic — no real
515    /// home access):
516    ///
517    /// ```
518    /// use agents_skills::{EnableRequest, Manager};
519    ///
520    /// let tmp = tempfile::TempDir::new().unwrap();
521    /// // Simulate a disabled skill parked in the disabled-skills dir.
522    /// let skill_dir = tmp.path().join("project/.agents/disabled-skills/pdf");
523    /// std::fs::create_dir_all(&skill_dir).unwrap();
524    /// std::fs::write(
525    ///     skill_dir.join("SKILL.md"),
526    ///     "---\nname: pdf\ndescription: pdf tools\n---\n\n# pdf\n",
527    /// )
528    /// .unwrap();
529    ///
530    /// let manager = Manager::builder()
531    ///     .home(tmp.path().join("home"))
532    ///     .config(tmp.path().join("config"))
533    ///     .cwd(tmp.path().join("project"))
534    ///     .build();
535    ///
536    /// let outcome = manager.enable(&EnableRequest {
537    ///     skills: vec!["pdf".into()],
538    ///     ..Default::default()
539    /// })?;
540    /// assert_eq!(outcome.enabled, vec!["pdf".to_string()]);
541    /// # Ok::<(), agents_skills::Error>(())
542    /// ```
543    ///
544    /// # Errors
545    ///
546    /// [`SkillsError::Io`] if a directory move fails.
547    pub fn enable(&self, req: &EnableRequest) -> Result<EnableOutcome> {
548        let global = req.global;
549        let disabled = scan_disabled(&self.env, global);
550        let installed = scan_installed(&self.env, global);
551
552        if req.skills.is_empty() && !req.all {
553            return Ok(EnableOutcome {
554                disabled,
555                requested: Vec::new(),
556                enabled: Vec::new(),
557                already: Vec::new(),
558                missing: Vec::new(),
559            });
560        }
561
562        let requested: Vec<String> = if req.all {
563            disabled.clone()
564        } else {
565            req.skills.clone()
566        };
567        let (enabled_out, already, missing) =
568            set_enabled_state(&requested, &disabled, &installed, global, true, &self.env)?;
569
570        Ok(EnableOutcome {
571            disabled,
572            requested,
573            enabled: enabled_out,
574            already,
575            missing,
576        })
577    }
578
579    /// Remove installed skills.
580    ///
581    /// Deletes each skill's directory from the canonical dir and drops its lockfile
582    /// entry. Removal applies to every linked agent at once (they all share the
583    /// canonical dir); agent links themselves are untouched — call [`Manager::agent`]
584    /// with `unlink: true` to disconnect an agent instead.
585    ///
586    /// # Selection semantics
587    ///
588    /// - `skills` empty and `all` false → nothing is removed; the outcome reports the
589    ///   currently enabled names (used by the CLI to print a hint).
590    /// - `all` true → every installed skill (enabled or disabled) plus every lockfile key.
591    ///
592    /// # Examples
593    ///
594    /// ```
595    /// use agents_skills::{Manager, RemoveRequest};
596    ///
597    /// let tmp = tempfile::TempDir::new().unwrap();
598    /// let manager = Manager::builder()
599    ///     .home(tmp.path().join("home"))
600    ///     .cwd(tmp.path().join("project"))
601    ///     .build();
602    ///
603    /// let req = RemoveRequest {
604    ///     skills: vec!["pdf".to_string()],
605    ///     ..Default::default()
606    /// };
607    /// // Nothing installed in the scratch dir, so this is a harmless no-op.
608    /// let outcome = manager.remove(&req)?;
609    /// assert!(outcome.removed.is_empty());
610    /// # Ok::<(), agents_skills::Error>(())
611    /// ```
612    pub fn remove(&self, req: &RemoveRequest) -> Result<RemoveOutcome> {
613        let global = req.global;
614
615        // Disabled skills are still installed (parked in `disabled-skills`): scan them
616        // too so `remove <name>` and `remove --all` can find and delete them, even when
617        // they have no lockfile entry (e.g. symlinked by a third-party tool).
618        let installed = scan_installed(&self.env, global);
619        let disabled = scan_disabled(&self.env, global);
620
621        // List-only mode (no skills and not --all).
622        if req.skills.is_empty() && !req.all {
623            return Ok(RemoveOutcome {
624                installed,
625                requested: Vec::new(),
626                removed: Vec::new(),
627            });
628        }
629
630        // Resolve the skill names to remove (lock keys take priority, then on-disk dir names).
631        let lock = read_local_lock(&lock_path(&self.env, global));
632        let lock_keys: Vec<String> = lock.skills.keys().cloned().collect();
633        let requested: Vec<String> = if req.all {
634            installed
635                .iter()
636                .chain(disabled.iter())
637                .chain(lock_keys.iter())
638                .cloned()
639                .collect()
640        } else {
641            req.skills.clone()
642        };
643        if requested.is_empty() {
644            return Ok(RemoveOutcome {
645                installed,
646                requested: Vec::new(),
647                removed: Vec::new(),
648            });
649        }
650
651        let selected = resolve_to_remove(&requested, &installed, &disabled, &lock_keys);
652        if selected.is_empty() {
653            return Ok(RemoveOutcome {
654                installed,
655                requested,
656                removed: Vec::new(),
657            });
658        }
659
660        // Remove from the canonical dir (visible to every linked agent at once).
661        let lock_path = lock_path(&self.env, global);
662        let mut lock = read_local_lock(&lock_path);
663        let mut removed: Vec<String> = Vec::new();
664        for name in &selected {
665            let canonical = get_canonical_path(name, global, &self.env);
666            let sanitized = sanitize_name(name);
667            let _ = std::fs::remove_dir_all(&canonical);
668            // Also remove any parked copy in the disabled dir.
669            let parked = disabled_skills_dir(global, &self.env).join(&sanitized);
670            let _ = std::fs::remove_dir_all(&parked);
671
672            // Clean the lock.
673            lock.version = 1;
674            lock.skills.remove(name);
675            lock.skills.remove(&sanitized);
676
677            removed.push(name.clone());
678        }
679
680        // Flush the lock once for all removals.
681        if !removed.is_empty() {
682            if let Some(parent) = lock_path.parent() {
683                let _ = std::fs::create_dir_all(parent);
684            }
685            let _ = write_local_lock(&lock, &lock_path);
686        }
687
688        Ok(RemoveOutcome {
689            installed,
690            requested,
691            removed,
692        })
693    }
694
695    /// Update installed skills from their recorded (non-local) sources.
696    ///
697    /// Reads the lockfile, re-clones each recorded source once (skills sharing a source
698    /// are grouped), re-installs the latest version into the canonical dir (all linked
699    /// agents see the update immediately), and reports per-skill success/failure
700    /// counts. Locally-sourced skills are skipped.
701    ///
702    /// # Scope resolution
703    ///
704    /// [`UpdateRequest::scope`] is [`Scope::Auto`] by default: project scope if the
705    /// project has skills or a lockfile, otherwise global.
706    ///
707    /// # Examples
708    ///
709    /// ```
710    /// use agents_skills::{Manager, UpdateRequest};
711    ///
712    /// let tmp = tempfile::TempDir::new().unwrap();
713    /// let manager = Manager::builder()
714    ///     .home(tmp.path().join("home"))
715    ///     .cwd(tmp.path().join("project"))
716    ///     .build();
717    ///
718    /// // No lockfile in the scratch dir, so nothing to update.
719    /// let outcome = manager.update(&UpdateRequest::default())?;
720    /// assert_eq!(outcome.updated, 0);
721    /// # Ok::<(), agents_skills::Error>(())
722    /// ```
723    ///
724    /// # Errors
725    ///
726    /// [`SkillsError::Message`] when a recorded source fails to re-parse. Per-skill
727    /// clone/install failures are captured in [`UpdateOutcome::failures`] rather than
728    /// returned as errors.
729    pub fn update(&self, req: &UpdateRequest) -> Result<UpdateOutcome> {
730        let global = resolve_scope(req, &self.env);
731        let lock_path = lock_path(&self.env, global);
732        let lock = read_local_lock(&lock_path);
733        // Disabled skills are parked outside the canonical dir; skip them so they stay disabled.
734        let disabled_names: HashSet<String> = scan_disabled(&self.env, global)
735            .into_iter()
736            .map(|n| sanitize_name(&n))
737            .collect();
738        let skills: Vec<(String, LockEntry)> = lock
739            .skills
740            .iter()
741            .filter(|(name, entry)| {
742                matches_skill(name, &req.skills)
743                    && entry.source_type != "local"
744                    && !disabled_names.contains(&sanitize_name(name))
745            })
746            .map(|(n, e)| (n.clone(), e.clone()))
747            .collect();
748
749        if skills.is_empty() {
750            return Ok(UpdateOutcome {
751                global,
752                ..Default::default()
753            });
754        }
755
756        // Group by source (same source is cloned only once).
757        let mut by_source: BTreeMap<String, Vec<(String, LockEntry)>> = BTreeMap::new();
758        for (name, entry) in skills {
759            by_source
760                .entry(entry.source.clone())
761                .or_default()
762                .push((name, entry));
763        }
764
765        let mut outcome = UpdateOutcome {
766            global,
767            ..Default::default()
768        };
769        for (source, items) in &by_source {
770            let first = &items[0].1;
771            let clone_url = first.source_url.clone().unwrap_or_else(|| source.clone());
772            let parsed = parse_source(&clone_url)?;
773
774            // Fetch per source type (archive for github/gitlab/download, clone for git).
775            let fetched = match fetch_source(&parsed) {
776                Ok(v) => v,
777                Err(e) => {
778                    for (name, _) in items {
779                        outcome.failures.push(format!("{name}: {e}"));
780                        outcome.failed += 1;
781                    }
782                    continue;
783                }
784            };
785            let discovered =
786                discover_skills(&fetched.1, parsed.subpath.as_deref(), true).unwrap_or_default();
787
788            for (name, entry) in items {
789                let target = find_skill(&discovered, name, entry.skill_path.as_deref());
790                let Some(skill) = target else {
791                    outcome
792                        .failures
793                        .push(format!("Skill '{name}' not found in {source}"));
794                    outcome.failed += 1;
795                    continue;
796                };
797                let r = install_skill(skill, global, &self.env);
798                if r.success {
799                    outcome.updated += 1;
800                    outcome.updated_names.push(name.clone());
801                } else {
802                    outcome.failed += 1;
803                    outcome.failures.push(format!(
804                        "{name}: {}",
805                        r.error.unwrap_or_else(|| "install failed".to_string())
806                    ));
807                }
808            }
809        }
810
811        Ok(outcome)
812    }
813}
814
815/// Chained builder for [`Manager`], injecting home/config/cwd/env vars.
816///
817/// Every field is optional: unset fields fall back to the real environment at
818/// [`build`](Self::build) time, so tests and sandboxes can override just the pieces
819/// they care about.
820#[derive(Default)]
821pub struct ManagerBuilder {
822    home: Option<PathBuf>,
823    config: Option<PathBuf>,
824    cwd: Option<PathBuf>,
825    vars: std::collections::HashMap<String, String>,
826    probe_system_dirs: Option<bool>,
827}
828
829impl ManagerBuilder {
830    /// Override the home directory.
831    ///
832    /// Affects global skills (`~/.agents/skills`), the global lockfile, and per-agent
833    /// user-level skills directories.
834    pub fn home(mut self, p: impl Into<PathBuf>) -> Self {
835        self.home = Some(p.into());
836        self
837    }
838
839    /// Override the config directory.
840    ///
841    /// Affects agent config lookup (e.g. `CLAUDE_CONFIG_DIR` resolution).
842    pub fn config(mut self, p: impl Into<PathBuf>) -> Self {
843        self.config = Some(p.into());
844        self
845    }
846
847    /// Override the current working directory.
848    ///
849    /// Affects project-scope installs (`.agents/skills`), the project lockfile, and
850    /// scope auto-detection.
851    pub fn cwd(mut self, p: impl Into<PathBuf>) -> Self {
852        self.cwd = Some(p.into());
853        self
854    }
855
856    /// Inject an environment variable override.
857    ///
858    /// Useful for redirecting agent-specific env vars (e.g. `CLAUDE_CONFIG_DIR`) that
859    /// the agent directory mapping consults. Does not touch the real process env.
860    pub fn env_var(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
861        self.vars.insert(k.into(), v.into());
862        self
863    }
864
865    /// Toggle probing of well-known system locations during agent detection.
866    ///
867    /// Some agents are detected via system locations outside home/config/cwd
868    /// (e.g. `/Applications/ZCode.app`). Pass `false` in tests and sandboxes so
869    /// detection never consults the real machine. Default: probe.
870    pub fn probe_system_dirs(mut self, probe: bool) -> Self {
871        self.probe_system_dirs = Some(probe);
872        self
873    }
874
875    /// Build the [`Manager`], resolving defaults from the real environment.
876    ///
877    /// Unset fields fall back to the actual home/config/cwd of the process.
878    pub fn build(self) -> Manager {
879        let cwd = self
880            .cwd
881            .or_else(|| std::env::current_dir().ok())
882            .unwrap_or_default();
883        let mut env = Env::new(
884            self.home.unwrap_or_else(home),
885            self.config.unwrap_or_else(config_home),
886            cwd,
887        );
888        if !self.vars.is_empty() {
889            env.set_vars(self.vars);
890        }
891        if let Some(probe) = self.probe_system_dirs {
892            env.set_probe_system_dirs(probe);
893        }
894        Manager { env }
895    }
896}
897
898// ============================ Request types (clap-free) ============================
899
900/// Request for [`Manager::add`].
901///
902/// The struct is `Default + Clone`; use [`AddRequest::new`] for the common
903/// "install everything from a source" case and struct-update syntax
904/// (`..Default::default()`) to override just the fields you need.
905///
906/// See the crate-level [source formats](crate#source-formats) table for the accepted
907/// `source` strings.
908#[derive(Debug, Clone, Default)]
909pub struct AddRequest {
910    /// Source string (local path, GitHub `owner/repo`, git URL, or download URL).
911    pub source: String,
912    /// Install globally (user-level, `~/.agents/skills`) instead of project-level.
913    pub global: bool,
914    /// `"*"` or specific skill names; empty = all discovered skills.
915    pub skills: Vec<String>,
916    /// List available skills without installing anything.
917    pub list_only: bool,
918}
919
920impl AddRequest {
921    /// Create a request that installs all skills from `source` with default options.
922    ///
923    /// All other fields default: project scope, all skills.
924    ///
925    /// # Examples
926    ///
927    /// ```
928    /// use agents_skills::{AddRequest, Manager};
929    ///
930    /// let req = AddRequest::new("anthropics/skills");
931    /// assert_eq!(req.source, "anthropics/skills");
932    /// assert!(req.skills.is_empty()); // all discovered skills
933    /// # let _ = Manager::new();
934    /// ```
935    pub fn new(source: impl Into<String>) -> Self {
936        AddRequest {
937            source: source.into(),
938            ..Default::default()
939        }
940    }
941}
942
943/// Request for [`Manager::list`].
944///
945/// `Default` lists project-scope skills across all agents.
946#[derive(Debug, Clone, Default)]
947pub struct ListRequest {
948    /// List global skills instead of project skills.
949    pub global: bool,
950    /// Filter by agent names; empty = all agents.
951    pub agents: Vec<String>,
952}
953
954/// Request for [`Manager::remove`].
955///
956/// `Default` is a no-op that only reports installed names — set `skills` or `all` to
957/// actually remove anything.
958#[derive(Debug, Clone, Default)]
959pub struct RemoveRequest {
960    /// Skill names to remove (the CLI merges positional args and `--skill` here).
961    pub skills: Vec<String>,
962    /// Remove global skills instead of project skills.
963    pub global: bool,
964    /// Remove all installed skills.
965    pub all: bool,
966}
967
968/// Request for [`Manager::agent`] — one entry point mirroring the `agent` CLI command.
969///
970/// `Default` links the auto-detected installed agents at project scope.
971#[derive(Debug, Clone, Default)]
972pub struct AgentRequest {
973    /// `"*"` or specific agent names; empty = auto-detect installed agents.
974    pub agents: Vec<String>,
975    /// Link global skills dirs instead of project ones.
976    pub global: bool,
977    /// Unlink (disconnect) the agents' skills dirs instead of linking them.
978    pub unlink: bool,
979    /// Move existing skills into the canonical dir when linking (also pulls
980    /// skills parked in the backup slot of an already linked agent).
981    pub migrate: bool,
982}
983
984/// Installation scope for [`Manager::update`].
985#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
986pub enum Scope {
987    /// Auto-detect: project scope if the project has skills or a lockfile, otherwise
988    /// global.
989    #[default]
990    Auto,
991    /// Force global scope.
992    Global,
993    /// Force project scope.
994    Project,
995}
996
997/// Request for [`Manager::update`].
998///
999/// `Default` updates all skills with auto-detected scope.
1000#[derive(Debug, Clone, Default)]
1001pub struct UpdateRequest {
1002    /// Filter by skill names; empty = all.
1003    pub skills: Vec<String>,
1004    /// Installation scope ([`Scope::Auto`] by default).
1005    pub scope: Scope,
1006}
1007
1008/// Request for [`Manager::disable`].
1009///
1010/// `Default` is a no-op that only reports enabled names — set `skills` or `all` to
1011/// actually disable anything.
1012#[derive(Debug, Clone, Default)]
1013pub struct DisableRequest {
1014    /// Skill names to disable (the CLI merges positional args and `--skill` here).
1015    pub skills: Vec<String>,
1016    /// Disable global skills instead of project skills.
1017    pub global: bool,
1018    /// Disable all currently enabled skills.
1019    pub all: bool,
1020}
1021
1022/// Request for [`Manager::enable`].
1023///
1024/// `Default` is a no-op that only reports disabled names — set `skills` or `all` to
1025/// actually enable anything.
1026#[derive(Debug, Clone, Default)]
1027pub struct EnableRequest {
1028    /// Skill names to enable (the CLI merges positional args and `--skill` here).
1029    pub skills: Vec<String>,
1030    /// Enable global skills instead of project skills.
1031    pub global: bool,
1032    /// Enable all currently disabled skills.
1033    pub all: bool,
1034}
1035
1036// ============================ Outcome types ============================
1037
1038/// Result of [`Manager::add`].
1039///
1040/// Carries the full picture of an add operation: what was discovered, what was
1041/// selected, and which skills were installed into the canonical dir.
1042#[derive(Debug)]
1043pub struct AddOutcome {
1044    /// The parsed source.
1045    pub source: Source,
1046    /// All discovered skills.
1047    pub skills: Vec<Skill>,
1048    /// Selected skills (empty when `list_only`).
1049    pub selected: Vec<Skill>,
1050    /// Successfully installed skills.
1051    pub installed: Vec<InstallSuccess>,
1052    /// Failed installations.
1053    pub failed: Vec<InstallFailure>,
1054    /// Whether this was a `--list` request.
1055    pub list_only: bool,
1056}
1057
1058/// A single successful install (one skill, into the canonical dir).
1059#[derive(Debug)]
1060pub struct InstallSuccess {
1061    /// Skill name.
1062    pub name: String,
1063    /// Canonical directory.
1064    pub canonical_path: PathBuf,
1065}
1066
1067/// A single failed install.
1068#[derive(Debug)]
1069pub struct InstallFailure {
1070    /// Skill name.
1071    pub skill: String,
1072    /// Error message.
1073    pub error: String,
1074}
1075
1076/// Result of linking (or unlinking) one agent's skills dir relative to the
1077/// canonical dir.
1078#[derive(Debug)]
1079pub struct AgentLinkResult {
1080    /// Agent identifier (as used on the CLI).
1081    pub agent: String,
1082    /// Agent display name.
1083    pub display: String,
1084    /// Link/unlink outcome details.
1085    pub outcome: LinkOutcome,
1086}
1087
1088/// Link status of one agent (used by `agent --status`).
1089#[derive(Debug)]
1090pub struct AgentStatus {
1091    /// Agent identifier (as used on the CLI).
1092    pub name: String,
1093    /// Agent display name.
1094    pub display: String,
1095    /// Whether the agent's skills dir is linked to the canonical dir.
1096    pub linked: bool,
1097    /// Whether the agent natively uses the canonical dir (no link involved).
1098    pub canonical: bool,
1099    /// Skills inside the agent's own skills dir: real subdirs and dir-targeting
1100    /// symlinks — the same classification link and migrate use. Only populated
1101    /// for unlinked, non-canonical agents; empty for linked/canonical agents
1102    /// (they share the canonical dir, shown by `list`).
1103    pub internal_skills: Vec<String>,
1104    /// Non-skill entries (files, symlinks to non-directories) inside the agent's
1105    /// own skills dir. Same population rules as [`AgentStatus::internal_skills`].
1106    pub internal_others: Vec<String>,
1107    /// Backup slot with parked content waiting for unlink to restore, if any.
1108    pub pending_backup: Option<BackupStatus>,
1109}
1110
1111/// A pending backup slot (used by `agent --status`).
1112#[derive(Debug)]
1113pub struct BackupStatus {
1114    /// Backup slot directory (`.agents/backup-skills/<agent>`).
1115    pub path: PathBuf,
1116    /// Names of the entries parked in the slot.
1117    pub items: Vec<String>,
1118}
1119
1120/// Result of [`Manager::agent`].
1121#[derive(Debug)]
1122pub struct AgentOutcome {
1123    /// Whether the links used global scope.
1124    pub global: bool,
1125    /// Per-agent link results.
1126    pub results: Vec<AgentLinkResult>,
1127}
1128
1129/// A listed skill enriched with lock metadata (serialized by `list --json`).
1130///
1131/// Fields are serialized in camelCase, so `source_url` becomes `"sourceUrl"` — the
1132/// exact JSON shape emitted by the CLI's `list --json`.
1133#[derive(Debug, Serialize)]
1134#[serde(rename_all = "camelCase")]
1135pub struct ListedSkill {
1136    /// Skill name.
1137    pub name: String,
1138    /// Canonical directory path.
1139    pub path: PathBuf,
1140    /// `"project"` or `"global"`.
1141    pub scope: String,
1142    /// Agent display names this skill is linked to.
1143    pub agents: Vec<String>,
1144    /// Source identifier from the lock.
1145    pub source: Option<String>,
1146    /// Resolved source URL.
1147    pub source_url: Option<String>,
1148    /// Source type (e.g. `"github"`, `"local"`).
1149    pub source_type: Option<String>,
1150    /// Whether the skill is enabled (`true`) or parked in `disabled-skills` (`false`).
1151    pub enabled: bool,
1152}
1153
1154/// Result of [`Manager::remove`].
1155#[derive(Debug)]
1156pub struct RemoveOutcome {
1157    /// Installed names scanned (used by the no-args hint).
1158    pub installed: Vec<String>,
1159    /// Requested names (used by the no-match hint).
1160    pub requested: Vec<String>,
1161    /// Names actually removed.
1162    pub removed: Vec<String>,
1163}
1164
1165/// Result of [`Manager::update`].
1166#[derive(Debug, Default)]
1167pub struct UpdateOutcome {
1168    /// Whether the update used global scope.
1169    pub global: bool,
1170    /// Number of successful updates (one count per skill).
1171    pub updated: usize,
1172    /// Number of failed updates.
1173    pub failed: usize,
1174    /// Names of skills that were updated.
1175    pub updated_names: Vec<String>,
1176    /// Human-readable failure messages.
1177    pub failures: Vec<String>,
1178}
1179
1180/// Result of [`Manager::disable`].
1181#[derive(Debug)]
1182pub struct DisableOutcome {
1183    /// Currently enabled names (used by the no-args hint).
1184    pub installed: Vec<String>,
1185    /// Requested names (used by the no-match hint).
1186    pub requested: Vec<String>,
1187    /// Names actually disabled.
1188    pub disabled: Vec<String>,
1189    /// Names that were already disabled (idempotent no-op).
1190    pub already: Vec<String>,
1191    /// Requested names that matched neither enabled nor disabled skills.
1192    pub missing: Vec<String>,
1193}
1194
1195/// Result of [`Manager::enable`].
1196#[derive(Debug)]
1197pub struct EnableOutcome {
1198    /// Currently disabled names (used by the no-args hint).
1199    pub disabled: Vec<String>,
1200    /// Requested names (used by the no-match hint).
1201    pub requested: Vec<String>,
1202    /// Names actually enabled.
1203    pub enabled: Vec<String>,
1204    /// Names that were already enabled (idempotent no-op).
1205    pub already: Vec<String>,
1206    /// Requested names that matched neither enabled nor disabled skills.
1207    pub missing: Vec<String>,
1208}
1209
1210// ============================ Private helpers ============================
1211
1212/// Resolve agent selection: `"*"` → all; names → validated; empty → auto-detect + universal.
1213fn resolve_target_agents(names: &[String], env: &Env) -> Result<Vec<&'static Agent>> {
1214    if names.iter().any(|a| a == "*") {
1215        return Ok(AGENTS.iter().collect());
1216    }
1217    if !names.is_empty() {
1218        let mut agents = Vec::new();
1219        let mut invalid = Vec::new();
1220        for name in names {
1221            match get_agent(name) {
1222                Some(a) => agents.push(a),
1223                None => invalid.push(name.clone()),
1224            }
1225        }
1226        if !invalid.is_empty() {
1227            return Err(SkillsError::InvalidAgents(invalid.join(", ")));
1228        }
1229        return Ok(agents);
1230    }
1231    let installed = detect_installed_agents(env);
1232    Ok(ensure_universal_agents(installed))
1233}
1234
1235/// Resolve requested names against available name sets, matching case-insensitively on
1236/// sanitized names. `sources` are consulted in order and the first available original
1237/// name wins — put higher-priority sources (e.g. lock keys) first.
1238fn resolve_names(requested: &[String], sources: &[&[String]]) -> Vec<String> {
1239    let mut identity: HashMap<String, String> = HashMap::new();
1240    for source in sources {
1241        for folder in *source {
1242            identity
1243                .entry(sanitize_name(folder))
1244                .or_insert_with(|| folder.clone());
1245        }
1246    }
1247    let mut matched = HashSet::new();
1248    for name in requested {
1249        if let Some(hit) = identity.get(&sanitize_name(name)) {
1250            matched.insert(hit.clone());
1251        }
1252    }
1253    let mut v: Vec<String> = matched.into_iter().collect();
1254    v.sort();
1255    v
1256}
1257
1258/// Core enable/disable: resolve requested names, classify idempotent/missing, and move
1259/// dirs. Returns `(selected, already, missing)` where `selected` were actually moved.
1260///
1261/// `from_set` holds names in the current state (source of the move); `target_set` holds
1262/// names in the target state (to detect idempotent no-ops).
1263fn set_enabled_state(
1264    requested: &[String],
1265    from_set: &[String],
1266    target_set: &[String],
1267    global: bool,
1268    to_enabled: bool,
1269    env: &Env,
1270) -> Result<(Vec<String>, Vec<String>, Vec<String>)> {
1271    let selected = resolve_names(requested, &[from_set]);
1272
1273    let mut already: Vec<String> = Vec::new();
1274    let mut missing: Vec<String> = Vec::new();
1275    for name in requested {
1276        if selected
1277            .iter()
1278            .any(|s| sanitize_name(s) == sanitize_name(name))
1279        {
1280            continue;
1281        }
1282        if target_set
1283            .iter()
1284            .any(|d| sanitize_name(d) == sanitize_name(name))
1285        {
1286            already.push(name.clone());
1287        } else {
1288            missing.push(name.clone());
1289        }
1290    }
1291    already.sort();
1292    already.dedup();
1293    missing.sort();
1294    missing.dedup();
1295
1296    for name in &selected {
1297        move_skill(name, global, to_enabled, env)?;
1298    }
1299
1300    Ok((selected, already, missing))
1301}
1302
1303/// Combine `--skill` args with the source's `@skill` filter into one selection list.
1304fn skill_filters(skills: &[String], skill_filter: Option<&str>) -> Vec<String> {
1305    let mut filters = skills.to_vec();
1306    if let Some(sf) = skill_filter {
1307        filters.push(sf.to_string());
1308    }
1309    filters
1310}
1311
1312/// Match a discovered skill by sanitized name or skillPath directory name.
1313fn find_skill<'a>(
1314    discovered: &'a [Skill],
1315    name: &str,
1316    skill_path: Option<&str>,
1317) -> Option<&'a Skill> {
1318    let sanitized = sanitize_name(name);
1319    // Prefer matching by (sanitized) name.
1320    if let Some(s) = discovered
1321        .iter()
1322        .find(|s| sanitize_name(&s.name) == sanitized)
1323    {
1324        return Some(s);
1325    }
1326    // Match by skillPath (directory name).
1327    if let Some(sp) = skill_path
1328        && let Some(dn) = sp.split('/').rfind(|p| !p.is_empty())
1329        && let Some(s) = discovered.iter().find(|s| {
1330            s.dir
1331                .file_name()
1332                .map(|f| f.to_string_lossy() == dn)
1333                .unwrap_or(false)
1334        })
1335    {
1336        return Some(s);
1337    }
1338    discovered.first().filter(|_| discovered.len() == 1)
1339}
1340
1341/// Whether a skill name matches a case-insensitive filter (empty filter matches all).
1342fn matches_skill(name: &str, filter: &[String]) -> bool {
1343    if filter.is_empty() {
1344        return true;
1345    }
1346    let lower = name.to_lowercase();
1347    filter.iter().any(|f| f.to_lowercase() == lower)
1348}
1349
1350/// Resolve skill names to remove: match by sanitized name, lock keys take priority.
1351fn resolve_to_remove(
1352    requested: &[String],
1353    installed: &[String],
1354    disabled: &[String],
1355    lock_keys: &[String],
1356) -> Vec<String> {
1357    resolve_names(requested, &[lock_keys, installed, disabled])
1358}
1359
1360fn lock_path(env: &Env, global: bool) -> PathBuf {
1361    if global {
1362        global_lock_path(&env.home)
1363    } else {
1364        local_lock_path(&env.cwd)
1365    }
1366}
1367
1368fn write_lock(
1369    parsed: &Source,
1370    selected: &[Skill],
1371    successful: &[InstallSuccess],
1372    global: bool,
1373    env: &Env,
1374) -> Result<()> {
1375    let lock_path = lock_path(env, global);
1376    let mut lock = read_local_lock(&lock_path);
1377    lock.version = 1;
1378
1379    let successful_names: HashSet<&str> = successful.iter().map(|s| s.name.as_str()).collect();
1380    for skill in selected {
1381        if !successful_names.contains(skill.name.as_str()) {
1382            continue;
1383        }
1384        let hash = compute_folder_hash(&skill.dir).unwrap_or_default();
1385        let (source, source_type, source_url, ref_, skill_path) = lock_fields(parsed);
1386        let mut entry = LockEntry::new(&source, &source_type, hash);
1387        entry.source_url = source_url;
1388        entry.r#ref = ref_;
1389        entry.skill_path = skill_path;
1390        lock.skills.insert(sanitize_name(&skill.name), entry);
1391    }
1392    if let Some(parent) = lock_path.parent() {
1393        std::fs::create_dir_all(parent)?;
1394    }
1395    write_local_lock(&lock, &lock_path)
1396}
1397
1398fn resolve_scope(req: &UpdateRequest, env: &Env) -> bool {
1399    match req.scope {
1400        Scope::Global => true,
1401        Scope::Project => false,
1402        Scope::Auto => !has_project_skills(env),
1403    }
1404}
1405
1406fn has_project_skills(env: &Env) -> bool {
1407    if local_lock_path(&env.cwd).exists() {
1408        return true;
1409    }
1410    env.cwd.join(".agents/skills").exists()
1411}
1412
1413#[cfg(test)]
1414mod tests {
1415    use super::*;
1416    use crate::core::test_utils::{env_at, write_and_parse_skill};
1417
1418    fn skills_with_dirs(pairs: &[(&str, &str)]) -> Vec<Skill> {
1419        pairs
1420            .iter()
1421            .map(|(dir, name)| {
1422                let mut s = write_and_parse_skill(std::path::Path::new(dir), name);
1423                // write_and_parse_skill derives dir from the SKILL.md path; use the skill dir.
1424                s.dir = std::path::PathBuf::from(dir);
1425                s
1426            })
1427            .collect()
1428    }
1429
1430    #[test]
1431    fn find_skill_prefers_name_then_skill_path() {
1432        let tmp = tempfile::TempDir::new().unwrap();
1433        let a = tmp.path().join("dir-a");
1434        let b = tmp.path().join("dir-b");
1435        std::fs::create_dir_all(&a).unwrap();
1436        std::fs::create_dir_all(&b).unwrap();
1437        let skills = skills_with_dirs(&[
1438            (a.to_str().unwrap(), "alpha"),
1439            (b.to_str().unwrap(), "beta"),
1440        ]);
1441
1442        // By (sanitized) name.
1443        assert_eq!(find_skill(&skills, "Alpha", None).unwrap().name, "alpha");
1444        // By skillPath directory name.
1445        assert_eq!(
1446            find_skill(&skills, "missing", Some("x/dir-b"))
1447                .unwrap()
1448                .name,
1449            "beta"
1450        );
1451        // Ambiguous without a match.
1452        assert!(find_skill(&skills, "missing", None).is_none());
1453    }
1454
1455    #[test]
1456    fn matches_skill_filters_case_insensitively() {
1457        let filter = vec!["PDF".to_string()];
1458        assert!(matches_skill("pdf", &filter));
1459        assert!(!matches_skill("git", &filter));
1460        assert!(matches_skill("anything", &[]));
1461    }
1462
1463    #[test]
1464    fn skill_filters_merges_args_and_at_filter() {
1465        assert_eq!(skill_filters(&[], Some("pdf")), vec!["pdf"]);
1466        assert_eq!(skill_filters(&["x".to_string()], None), vec!["x"]);
1467        assert_eq!(
1468            skill_filters(&["x".to_string()], Some("pdf")),
1469            vec!["x", "pdf"]
1470        );
1471        assert!(skill_filters(&[], None).is_empty());
1472    }
1473
1474    #[test]
1475    fn resolve_to_remove_prefers_lock_keys() {
1476        let installed = vec!["PDF".to_string()];
1477        let lock_keys = vec!["pdf".to_string()];
1478        let requested = vec!["pdf".to_string(), "unknown".to_string()];
1479
1480        // Lock keys take priority: "pdf" (not the on-disk "PDF" casing).
1481        assert_eq!(
1482            resolve_to_remove(&requested, &installed, &[], &lock_keys),
1483            vec!["pdf"]
1484        );
1485    }
1486
1487    #[test]
1488    fn resolve_to_remove_matches_disabled_without_lock() {
1489        let installed = Vec::new();
1490        let disabled = vec!["legacy".to_string()];
1491        let lock_keys = Vec::new();
1492        let requested = vec!["legacy".to_string()];
1493
1494        // A disabled skill with no lockfile entry is still resolvable for removal.
1495        assert_eq!(
1496            resolve_to_remove(&requested, &installed, &disabled, &lock_keys),
1497            vec!["legacy"]
1498        );
1499    }
1500
1501    #[test]
1502    fn resolve_target_agents_validates_names() {
1503        let tmp = tempfile::TempDir::new().unwrap();
1504        let env = env_at(&tmp);
1505        assert!(resolve_target_agents(&["claude-code".to_string()], &env).is_ok());
1506        assert!(matches!(
1507            resolve_target_agents(&["nope".to_string()], &env),
1508            Err(SkillsError::InvalidAgents(_))
1509        ));
1510    }
1511}