1use 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
20pub 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 #[arg(long)]
64 pub project: bool,
65
66 #[arg(long)]
69 pub tool: Option<String>,
70
71 #[arg(long)]
73 pub remove: bool,
74
75 #[arg(long)]
77 pub status: bool,
78
79 #[arg(long)]
82 pub no_nudge: bool,
83
84 #[arg(long)]
87 pub emit_spec: bool,
88
89 #[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 if !args.remove {
151 maybe_install_combined(&spec, &home, &root, scope, args.dry_run);
152 }
153
154 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
165fn 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
196fn asd_state_dir(home: &Path) -> PathBuf {
198 home.join(".config").join("asd")
199}
200
201fn 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
244pub 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 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(); 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 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 assert!(maybe_nudge_sibling(&spec, tmp.path(), false).is_some());
467 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 assert!(maybe_nudge_sibling(&spec, tmp.path(), true).is_none());
481 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}