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}