Skip to main content

agentstatedeveloper_cli/commands/
skill.rs

1//! Agent Skill installation (suite-onboarding t-002).
2//!
3//! Renders ASD's onboarding [`SkillSpec`] via the shared `agent-skillgen`
4//! engine and places a `SKILL.md` into each skill-capable host's skills
5//! directory. Complements `asd mcp install` (MCP config) and `asd mcp
6//! instructions` (always-on block) — this adds the actual Agent Skill file.
7//!
8//! `mcp.rs::instruction_body` renders the always-on block from this same
9//! `asd_skill_spec()` via the engine (t-007) — one source for both surfaces.
10
11use std::path::{Path, PathBuf};
12
13use crate::skillgen::{
14    Action, SkillScope, SkillSpec, SkillState, already_nudged, binary_on_path, install_suite,
15    place_skills, record_nudge, should_nudge, skill_status,
16};
17use anyhow::{Context, Result};
18use clap::Args;
19
20/// ASD's onboarding content — the single source the shared engine renders into
21/// per-agent skill files (and, later, the always-on block).
22pub fn asd_skill_spec() -> SkillSpec {
23    SkillSpec::new(
24        "AgentStateDeveloper",
25        "asd",
26        "Code-level context, impact analysis, and change scoping for coding agents.",
27        env!("CARGO_PKG_VERSION"),
28    )
29    .rule("Before a non-trivial edit, run `asd prepare-change <area>` for the scoped files, blast radius, and invariants.")
30    .rule("Before assuming a symbol's behavior, get `asd context-for <symbol>` instead of guessing from grep.")
31    .rule("To see what a change breaks, run `asd impact <symbol>` before editing.")
32    .rule("To orient in an unfamiliar area, run `asd architecture` for languages, packages, layers, routes, and hotspots.")
33    .rule("Prefer `asd search` over raw grep for symbol-level lookups.")
34    .rule("After editing code, run `asd reindex` so the index stays current.")
35    .rule("For exact syntax, params, and examples of any command, run `asd help <feature>` (`asd help` lists the whole catalog) instead of guessing.")
36    .command("asd help", "on-demand syntax, examples, and gotchas for any feature")
37    .command("asd prepare-change", "scope a change: files, impact, invariants")
38    .command("asd context-for", "focused context for one symbol")
39    .command("asd impact", "downstream blast radius of a change")
40    .command("asd architecture", "orient: languages, packages, layers, hotspots")
41    .command("asd search", "structural symbol search")
42    .sibling(
43        "CTXone",
44        "ctx",
45        "use it to share decisions, plans, and memory across your team (the `ctx` CLI).",
46    )
47    .bootstrap_step(
48        "brew install asd",
49        "install ASD (macOS/Linux; or run the install.sh script)",
50    )
51    .bootstrap_step("asd index .", "index this repository")
52    .bootstrap_step(
53        "asd mcp install && asd skill",
54        "register the MCP server + agent skill",
55    )
56    .bootstrap_step("asd status", "verify the index is built")
57}
58
59#[derive(Debug, Args)]
60pub struct SkillArgs {
61    /// Install project-scoped (into the repo) instead of the default
62    /// home-scoped (user-wide). Not every host has a project location.
63    #[arg(long)]
64    pub project: bool,
65
66    /// Only install for one host key (e.g. `claude-code`). All skill-capable
67    /// hosts when omitted.
68    #[arg(long)]
69    pub tool: Option<String>,
70
71    /// Remove installed skill files instead of writing them.
72    #[arg(long)]
73    pub remove: bool,
74
75    /// Report the install state of each host's skill without changing anything.
76    #[arg(long)]
77    pub status: bool,
78
79    /// Suppress the one-time suggestion to add the sibling product (CTXone).
80    /// Also suppressed by the ASD_NO_SUGGEST env var.
81    #[arg(long)]
82    pub no_nudge: bool,
83
84    /// Print ASD's onboarding SkillSpec as JSON and exit (for the sibling CLI
85    /// to render the combined suite skill). Internal cross-CLI contract.
86    #[arg(long)]
87    pub emit_spec: bool,
88
89    /// Print what would happen without touching the filesystem.
90    #[arg(long)]
91    pub dry_run: bool,
92}
93
94pub fn run(args: SkillArgs) -> Result<()> {
95    let home = std::env::var_os("HOME")
96        .map(PathBuf::from)
97        .context("cannot resolve $HOME")?;
98    let root = std::env::current_dir().context("cannot resolve current directory")?;
99    let scope = if args.project {
100        SkillScope::Project
101    } else {
102        SkillScope::Home
103    };
104    let spec = asd_skill_spec();
105
106    if args.emit_spec {
107        println!("{}", spec.to_json());
108        return Ok(());
109    }
110
111    if args.status {
112        let states = skill_status(&spec, &home, &root, scope, args.tool.as_deref());
113        if states.is_empty() {
114            println!("No skill-capable hosts matched.");
115        }
116        for (tool, state) in &states {
117            println!("  {:<12}  {}", tool, describe_state(state));
118        }
119        return Ok(());
120    }
121
122    let placed = place_skills(
123        &spec,
124        &home,
125        &root,
126        scope,
127        args.tool.as_deref(),
128        args.remove,
129        args.dry_run,
130    )?;
131
132    if placed.is_empty() {
133        println!("No skill-capable hosts matched.");
134        return Ok(());
135    }
136    for p in &placed {
137        let verb = match p.action {
138            Action::Wrote => "installed",
139            Action::Removed => "removed",
140            Action::WouldWrite => "would install",
141            Action::WouldRemove => "would remove",
142            Action::Skipped => "skipped",
143            Action::SkippedNewer => "skipped (newer on disk)",
144        };
145        println!("  {verb:>22}  {:<12}  {}", p.tool, p.path.display());
146    }
147
148    // When the sibling is installed, also install the canonical combined suite
149    // skill (idempotent — either product produces byte-identical content).
150    if !args.remove {
151        maybe_install_combined(&spec, &home, &root, scope, args.dry_run);
152    }
153
154    // Self-verify + one-time nudge — only after a real install.
155    if !args.dry_run && !args.remove {
156        print_verify_summary(&spec, &home, &root, scope, args.tool.as_deref());
157        let suppress = args.no_nudge || std::env::var_os("ASD_NO_SUGGEST").is_some();
158        if let Some(msg) = maybe_nudge_sibling(&spec, &asd_state_dir(&home), suppress) {
159            println!("{msg}");
160        }
161    }
162    Ok(())
163}
164
165/// Re-read each host's skill state after an install and print a green/red
166/// verdict, so the user knows the install actually took (t-008). For the full
167/// index health check, `asd health` remains the deeper probe.
168fn print_verify_summary(
169    spec: &SkillSpec,
170    home: &Path,
171    root: &Path,
172    scope: SkillScope,
173    filter: Option<&str>,
174) {
175    let states = skill_status(spec, home, root, scope, filter);
176    if states.is_empty() {
177        return;
178    }
179    let total = states.len();
180    let current = states
181        .iter()
182        .filter(|(_, s)| matches!(s, SkillState::Current { .. }))
183        .count();
184    if current == total {
185        println!("\n✓ verified {current}/{total} skills installed and current");
186    } else {
187        println!("\n⚠ verified {current}/{total} current — issues:");
188        for (t, s) in &states {
189            if !matches!(s, SkillState::Current { .. }) {
190                println!("    {t:<12}  {}", describe_state(s));
191            }
192        }
193    }
194}
195
196/// ASD's cross-run state directory (also home to `repos.toml`).
197fn asd_state_dir(home: &Path) -> PathBuf {
198    home.join(".config").join("asd")
199}
200
201/// If the sibling CLI is on PATH, fetch its spec (`<bin> skill --emit-spec`) and
202/// install the canonical combined suite skill. Best-effort — any failure is
203/// silent (the per-product skills already installed fine).
204fn maybe_install_combined(
205    spec: &SkillSpec,
206    home: &Path,
207    root: &Path,
208    scope: SkillScope,
209    dry_run: bool,
210) {
211    let Some(sib) = spec.sibling.as_ref() else {
212        return;
213    };
214    if !binary_on_path(&sib.bin) {
215        return;
216    }
217    let Ok(output) = std::process::Command::new(&sib.bin)
218        .args(["skill", "--emit-spec"])
219        .output()
220    else {
221        return;
222    };
223    if !output.status.success() {
224        return;
225    }
226    let Some(sib_spec) = SkillSpec::from_json(&String::from_utf8_lossy(&output.stdout)) else {
227        return;
228    };
229    if let Ok((name, placed)) = install_suite(spec, &sib_spec, home, root, scope, dry_run) {
230        if !placed.is_empty() {
231            let verb = if dry_run {
232                "would install"
233            } else {
234                "installed"
235            };
236            println!(
237                "\n✓ {verb} combined suite skill `{name}` to {} host(s)",
238                placed.len()
239            );
240        }
241    }
242}
243
244/// The one-time sibling suggestion, or `None` when it shouldn't show (sibling
245/// already installed, already shown, or suppressed). Records the nudge when it
246/// returns `Some`, so it never repeats. Never blocks — it only gates a printed
247/// line.
248pub fn maybe_nudge_sibling(spec: &SkillSpec, state_dir: &Path, suppressed: bool) -> Option<String> {
249    let sib = spec.sibling.as_ref()?;
250    let present = binary_on_path(&sib.bin);
251    if !should_nudge(present, already_nudged(state_dir, &sib.bin), suppressed) {
252        return None;
253    }
254    // Best-effort — a failed marker write shouldn't error the install.
255    let _ = record_nudge(state_dir, &sib.bin);
256    Some(format!(
257        "\nTip: pair ASD with {} — {}\n(Shown once; suppress with --no-nudge or ASD_NO_SUGGEST=1.)",
258        sib.product, sib.pitch
259    ))
260}
261
262fn describe_state(state: &SkillState) -> String {
263    match state {
264        SkillState::NotInstalled => "not installed".to_string(),
265        SkillState::Missing => "SKILL.md missing — run `asd skill` to repair".to_string(),
266        SkillState::Unstamped => "installed, no version stamp".to_string(),
267        SkillState::Current { version } => format!("current ({version})"),
268        SkillState::Stale { installed, package } => {
269            format!("stale ({installed} < {package}) — run `asd skill` to update")
270        }
271        SkillState::Newer { installed, package } => {
272            format!("newer on disk ({installed} > {package}) — not overwriting")
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::skillgen::{
281        Action, STAMP_FILE, SkillScope, SkillSpec, SkillState, place_skills, platform,
282        render_skill, skill_status, write_stamp,
283    };
284
285    #[test]
286    fn asd_spec_renders_real_content() {
287        let spec = asd_skill_spec();
288        let md = render_skill(&spec, platform("claude-code").unwrap()).unwrap();
289        assert!(md.contains("AgentStateDeveloper"));
290        assert!(md.contains("asd prepare-change"));
291        assert!(md.contains("CTXone"), "sibling cross-promo must be present");
292        assert!(md.starts_with("---\nname: asd\n"));
293    }
294
295    #[test]
296    fn places_home_scoped_skill_files() {
297        let tmp = tempfile::tempdir().unwrap();
298        let placed = place_skills(
299            &asd_skill_spec(),
300            tmp.path(),
301            tmp.path(),
302            SkillScope::Home,
303            None,
304            false,
305            false,
306        )
307        .unwrap();
308        assert!(!placed.is_empty());
309        let claude = tmp.path().join(".claude/skills/asd/SKILL.md");
310        assert!(claude.exists(), "expected {claude:?}");
311        assert!(
312            std::fs::read_to_string(&claude)
313                .unwrap()
314                .contains("Loaded by Claude Code")
315        );
316        for pl in &placed {
317            assert_eq!(pl.action, Action::Wrote);
318            assert!(pl.path.exists(), "{:?} not written", pl.path);
319        }
320    }
321
322    #[test]
323    fn dry_run_writes_nothing() {
324        let tmp = tempfile::tempdir().unwrap();
325        let placed = place_skills(
326            &asd_skill_spec(),
327            tmp.path(),
328            tmp.path(),
329            SkillScope::Home,
330            None,
331            false,
332            true,
333        )
334        .unwrap();
335        assert!(!placed.is_empty());
336        for pl in &placed {
337            assert_eq!(pl.action, Action::WouldWrite);
338            assert!(!pl.path.exists());
339        }
340    }
341
342    #[test]
343    fn remove_deletes_the_file() {
344        let tmp = tempfile::tempdir().unwrap();
345        let spec = asd_skill_spec();
346        place_skills(
347            &spec,
348            tmp.path(),
349            tmp.path(),
350            SkillScope::Home,
351            Some("claude-code"),
352            false,
353            false,
354        )
355        .unwrap();
356        let path = tmp.path().join(".claude/skills/asd/SKILL.md");
357        assert!(path.exists());
358        let placed = place_skills(
359            &spec,
360            tmp.path(),
361            tmp.path(),
362            SkillScope::Home,
363            Some("claude-code"),
364            true,
365            false,
366        )
367        .unwrap();
368        assert_eq!(placed[0].action, Action::Removed);
369        assert!(!path.exists());
370    }
371
372    #[test]
373    fn install_writes_version_stamp() {
374        let tmp = tempfile::tempdir().unwrap();
375        let spec = asd_skill_spec();
376        place_skills(
377            &spec,
378            tmp.path(),
379            tmp.path(),
380            SkillScope::Home,
381            Some("claude-code"),
382            false,
383            false,
384        )
385        .unwrap();
386        let stamp = tmp.path().join(".claude/skills/asd").join(STAMP_FILE);
387        assert!(stamp.exists(), "version stamp not written");
388        assert_eq!(
389            std::fs::read_to_string(&stamp).unwrap().trim(),
390            spec.version
391        );
392    }
393
394    #[test]
395    fn refuses_to_downgrade_a_newer_skill() {
396        let tmp = tempfile::tempdir().unwrap();
397        let spec = asd_skill_spec();
398        let dir = tmp.path().join(".claude/skills/asd");
399        std::fs::create_dir_all(&dir).unwrap();
400        std::fs::write(dir.join("SKILL.md"), "NEWER CONTENT").unwrap();
401        write_stamp(&dir, "999.0.0").unwrap(); // pretend a much newer install exists
402
403        let placed = place_skills(
404            &spec,
405            tmp.path(),
406            tmp.path(),
407            SkillScope::Home,
408            Some("claude-code"),
409            false,
410            false,
411        )
412        .unwrap();
413        assert_eq!(placed[0].action, Action::SkippedNewer);
414        // content must NOT be overwritten
415        assert_eq!(
416            std::fs::read_to_string(dir.join("SKILL.md")).unwrap(),
417            "NEWER CONTENT"
418        );
419    }
420
421    #[test]
422    fn status_transitions_not_installed_to_current() {
423        let tmp = tempfile::tempdir().unwrap();
424        let spec = asd_skill_spec();
425        let before = skill_status(
426            &spec,
427            tmp.path(),
428            tmp.path(),
429            SkillScope::Home,
430            Some("claude-code"),
431        );
432        assert_eq!(before[0].1, SkillState::NotInstalled);
433        place_skills(
434            &spec,
435            tmp.path(),
436            tmp.path(),
437            SkillScope::Home,
438            Some("claude-code"),
439            false,
440            false,
441        )
442        .unwrap();
443        let after = skill_status(
444            &spec,
445            tmp.path(),
446            tmp.path(),
447            SkillScope::Home,
448            Some("claude-code"),
449        );
450        assert!(
451            matches!(after[0].1, SkillState::Current { .. }),
452            "got {:?}",
453            after[0].1
454        );
455    }
456
457    #[test]
458    fn nudge_shows_once_then_records() {
459        let tmp = tempfile::tempdir().unwrap();
460        let spec = SkillSpec::new("X", "x", "t", "1.0.0").sibling(
461            "Sibling",
462            "nonexistent-binary-xyz-404",
463            "get the sibling",
464        );
465        // sibling absent → shows once and records the marker
466        assert!(maybe_nudge_sibling(&spec, tmp.path(), false).is_some());
467        // second call → already nudged → silent
468        assert!(maybe_nudge_sibling(&spec, tmp.path(), false).is_none());
469    }
470
471    #[test]
472    fn nudge_suppressed_records_nothing() {
473        let tmp = tempfile::tempdir().unwrap();
474        let spec = SkillSpec::new("X", "x", "t", "1.0.0").sibling(
475            "Sibling",
476            "nonexistent-binary-xyz-404",
477            "get the sibling",
478        );
479        // suppressed → no message, and no marker written…
480        assert!(maybe_nudge_sibling(&spec, tmp.path(), true).is_none());
481        // …so a later un-suppressed call still shows.
482        assert!(maybe_nudge_sibling(&spec, tmp.path(), false).is_some());
483    }
484
485    #[test]
486    fn verify_all_current_after_full_install() {
487        let tmp = tempfile::tempdir().unwrap();
488        let spec = asd_skill_spec();
489        place_skills(
490            &spec,
491            tmp.path(),
492            tmp.path(),
493            SkillScope::Home,
494            None,
495            false,
496            false,
497        )
498        .unwrap();
499        let states = skill_status(&spec, tmp.path(), tmp.path(), SkillScope::Home, None);
500        assert!(!states.is_empty());
501        assert!(
502            states
503                .iter()
504                .all(|(_, s)| matches!(s, SkillState::Current { .. })),
505            "post-install verify should show all current: {states:?}"
506        );
507    }
508}