1use std::{
10 fmt::Write as _,
11 fs,
12 io::{self, Write},
13 path::{Path, PathBuf},
14};
15
16use serde_json::{Value, json};
18
19use crate::agent::{AgentSubcommand, DescribeFormat, EmitScope, EmitTarget, ListFormat};
20use crate::{AgentCapability, ProcessEnv, ToolSpec};
21
22#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
24pub enum EmitDirError {
25 #[error("$HOME is not set")]
27 HomeUnset,
28}
29
30#[must_use]
37pub fn run_agent_subcommand(spec: &ToolSpec, env: &ProcessEnv, command: &AgentSubcommand) -> i32 {
38 if env.agent.active {
39 eprintln!("agent skill generation is not available under agent supervision");
40 return 1;
41 }
42 match command {
43 AgentSubcommand::List { format } => {
44 println!("{}", render_list(spec, *format));
45 0
46 }
47 AgentSubcommand::Describe { name, format } => find_capability(spec, name).map_or_else(
48 || {
49 eprintln!("unknown agent capability: {name}");
50 1
51 },
52 |capability| {
53 println!("{}", render_describe(spec, capability, *format));
54 0
55 },
56 ),
57 AgentSubcommand::EmitSkills {
58 target,
59 scope,
60 out,
61 install,
62 } => run_emit_skills(
63 spec,
64 env.home.as_deref(),
65 *target,
66 *scope,
67 out.as_deref(),
68 *install,
69 ),
70 }
71}
72
73fn run_emit_skills(
74 spec: &ToolSpec,
75 home: Option<&Path>,
76 target: EmitTarget,
77 scope: EmitScope,
78 out: Option<&Path>,
79 install: bool,
80) -> i32 {
81 if !install && out.is_none() {
82 eprintln!(
83 "agent emit-skills requires --install or --out=DIR (refusing to dump multiple artifacts to stdout)"
84 );
85 return 2;
86 }
87
88 let capabilities = capabilities_for(spec);
89 if capabilities.is_empty() {
90 eprintln!("tool declares no agent capabilities");
91 return 1;
92 }
93
94 let base = match resolve_emit_dir(home, target, scope, out) {
95 Ok(path) => path,
96 Err(err) => {
97 eprintln!("could not resolve skill destination: {err}");
98 return 1;
99 }
100 };
101
102 for capability in capabilities {
103 let skill_name = skill_name(spec, capability);
104 let dir = base.join(&skill_name);
105 if let Err(err) = fs::create_dir_all(&dir) {
106 eprintln!("failed to create {}: {err}", dir.display());
107 return 1;
108 }
109 let body = render_skill_md(spec, capability);
110 let path = dir.join("SKILL.md");
111 if let Err(err) = write_file(&path, &body) {
112 eprintln!("failed to write {}: {err}", path.display());
113 return 1;
114 }
115 println!("wrote {}", path.display());
116 }
117
118 0
119}
120
121fn write_file(path: &Path, body: &str) -> io::Result<()> {
122 let mut file = fs::File::create(path)?;
123 file.write_all(body.as_bytes())?;
124 if !body.ends_with('\n') {
125 file.write_all(b"\n")?;
126 }
127 Ok(())
128}
129
130pub fn resolve_emit_dir(
139 home: Option<&Path>,
140 target: EmitTarget,
141 scope: EmitScope,
142 out: Option<&Path>,
143) -> Result<PathBuf, EmitDirError> {
144 if let Some(path) = out {
145 return Ok(path.to_path_buf());
146 }
147 let segment = match target {
148 EmitTarget::Claude => ".claude",
149 EmitTarget::Codex => ".codex",
150 };
151 match scope {
152 EmitScope::Project => Ok(PathBuf::from(segment).join("skills")),
153 EmitScope::User => {
154 let home = home.ok_or(EmitDirError::HomeUnset)?;
155 Ok(home.join(segment).join("skills"))
156 }
157 }
158}
159
160fn capabilities_for(spec: &ToolSpec) -> &'static [AgentCapability] {
161 spec.agent_surface
162 .map_or(&[][..], |surface| surface.capabilities)
163}
164
165fn find_capability(spec: &ToolSpec, name: &str) -> Option<&'static AgentCapability> {
166 capabilities_for(spec)
167 .iter()
168 .find(|capability| capability.name == name)
169}
170
171#[must_use]
173pub fn skill_name(spec: &ToolSpec, capability: &AgentCapability) -> String {
174 format!("{}-{}", spec.bin_name, capability.name)
175}
176
177#[must_use]
179pub fn render_list(spec: &ToolSpec, format: ListFormat) -> String {
180 let capabilities = capabilities_for(spec);
181 match format {
182 ListFormat::Text => render_list_text(spec, capabilities),
183 ListFormat::Json => render_list_json(spec, capabilities).to_string(),
184 }
185}
186
187fn render_list_text(spec: &ToolSpec, capabilities: &[AgentCapability]) -> String {
188 let mut lines = vec![format!("tool: {}", spec.bin_name)];
189 if capabilities.is_empty() {
190 lines.push(String::from("capabilities: none declared"));
191 } else {
192 lines.push(String::from("capabilities:"));
193 for capability in capabilities {
194 lines.push(format!(
195 "- {}: {}",
196 capability.name,
197 summary_or_default(capability),
198 ));
199 }
200 }
201 lines.join("\n")
202}
203
204fn render_list_json(spec: &ToolSpec, capabilities: &[AgentCapability]) -> Value {
205 let entries = capabilities
206 .iter()
207 .map(|capability| {
208 json!({
209 "name": capability.name,
210 "summary": summary_or_default(capability),
211 "skill_name": skill_name(spec, capability),
212 })
213 })
214 .collect::<Vec<_>>();
215 json!({
216 "tool": spec.bin_name,
217 "version": spec.version,
218 "capabilities": entries,
219 })
220}
221
222#[must_use]
224pub fn render_describe(
225 spec: &ToolSpec,
226 capability: &AgentCapability,
227 format: DescribeFormat,
228) -> String {
229 match format {
230 DescribeFormat::Text => render_describe_text(spec, capability),
231 DescribeFormat::Json => render_describe_json(spec, capability).to_string(),
232 DescribeFormat::SkillMd => render_skill_md(spec, capability),
233 }
234}
235
236fn render_describe_text(spec: &ToolSpec, capability: &AgentCapability) -> String {
237 let mut sections = vec![
238 format!("tool: {}", spec.bin_name),
239 format!("capability: {}", capability.name),
240 format!("summary: {}", summary_or_default(capability)),
241 ];
242 if let Some(text) = capability.when_to_use {
243 sections.push(format!("when-to-use: {text}"));
244 }
245 if let Some(text) = capability.when_not_to_use {
246 sections.push(format!("when-not-to-use: {text}"));
247 }
248 sections.push(format!("commands:\n{}", render_command_lines(capability)));
249 sections.push(format!("flags:\n{}", render_flag_lines(capability)));
250 sections.push(format!("examples:\n{}", render_example_lines(capability)));
251 sections.push(format!("output: {}", output_or_default(capability)));
252 sections.push(format!(
253 "constraints: {}",
254 constraints_or_default(capability)
255 ));
256 sections.join("\n")
257}
258
259fn render_describe_json(spec: &ToolSpec, capability: &AgentCapability) -> Value {
260 json!({
261 "tool": spec.bin_name,
262 "version": spec.version,
263 "capability": capability.name,
264 "skill_name": skill_name(spec, capability),
265 "summary": summary_or_default(capability),
266 "when_to_use": capability.when_to_use,
267 "when_not_to_use": capability.when_not_to_use,
268 "commands": capability
269 .commands
270 .iter()
271 .map(|selector| selector.path.join(" "))
272 .collect::<Vec<_>>(),
273 "flags": capability
274 .flags
275 .iter()
276 .map(|flag| {
277 if flag.command_path.is_empty() {
278 format!("--{}", flag.long)
279 } else {
280 format!("{} --{}", flag.command_path.join(" "), flag.long)
281 }
282 })
283 .collect::<Vec<_>>(),
284 "examples": capability.examples.unwrap_or(&[]),
285 "output": output_or_default(capability),
286 "constraints": constraints_or_default(capability),
287 })
288}
289
290#[must_use]
295pub fn render_skill_md(spec: &ToolSpec, capability: &AgentCapability) -> String {
296 let name = skill_name(spec, capability);
297 let description = synthesize_description(capability);
298 let mut body = String::new();
299 body.push_str("---\n");
300 let _ = writeln!(body, "name: {name}");
301 let _ = writeln!(body, "description: {}", yaml_escape(&description));
302 body.push_str("---\n\n");
303 let _ = writeln!(body, "# {} — {}\n", spec.bin_name, capability.name);
304
305 let _ = writeln!(body, "{}\n", summary_or_default(capability));
306
307 if let Some(text) = capability.when_to_use {
308 body.push_str("## When to use\n\n");
309 body.push_str(text);
310 body.push_str("\n\n");
311 }
312 if let Some(text) = capability.when_not_to_use {
313 body.push_str("## When not to use\n\n");
314 body.push_str(text);
315 body.push_str("\n\n");
316 }
317
318 body.push_str("## Commands\n\n");
319 if capability.commands.is_empty() {
320 body.push_str("- none declared\n");
321 } else {
322 for selector in capability.commands {
323 let _ = writeln!(body, "- `{} {}`", spec.bin_name, selector.path.join(" "));
324 }
325 }
326 body.push('\n');
327
328 body.push_str("## Flags\n\n");
329 if capability.flags.is_empty() {
330 body.push_str("- none declared\n");
331 } else {
332 for flag in capability.flags {
333 if flag.command_path.is_empty() {
334 let _ = writeln!(body, "- `--{}`", flag.long);
335 } else {
336 let _ = writeln!(body, "- `{} --{}`", flag.command_path.join(" "), flag.long);
337 }
338 }
339 }
340 body.push('\n');
341
342 body.push_str("## Examples\n\n");
343 match capability.examples {
344 Some(examples) if !examples.is_empty() => {
345 for example in examples {
346 let _ = writeln!(body, "- `{example}`");
347 }
348 }
349 _ => body.push_str("- none declared\n"),
350 }
351 body.push('\n');
352
353 body.push_str("## Output\n\n");
354 body.push_str(&output_or_default(capability));
355 body.push_str("\n\n");
356
357 body.push_str("## Constraints\n\n");
358 body.push_str(&constraints_or_default(capability));
359 body.push('\n');
360
361 body
362}
363
364fn synthesize_description(capability: &AgentCapability) -> String {
365 let mut parts = vec![summary_or_default(capability)];
366 if let Some(text) = capability.when_to_use {
367 parts.push(format!("Use when {}", lower_first(text)));
368 }
369 if let Some(text) = capability.when_not_to_use {
370 parts.push(format!("Do not use when {}", lower_first(text)));
371 }
372 parts.join(". ")
373}
374
375fn lower_first(text: &str) -> String {
376 let mut chars = text.chars();
377 chars.next().map_or_else(String::new, |first| {
378 first.to_lowercase().collect::<String>() + chars.as_str()
379 })
380}
381
382fn yaml_escape(value: &str) -> String {
383 let needs_quote = value.contains(':')
384 || value.contains('#')
385 || value.contains('\n')
386 || value.starts_with(['-', '?', '!', '&', '*', '|', '>', '%', '@', '`']);
387 if needs_quote {
388 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
389 let single_line = escaped.replace('\n', " ");
390 format!("\"{single_line}\"")
391 } else {
392 value.to_string()
393 }
394}
395
396fn summary_or_default(capability: &AgentCapability) -> String {
397 if let Some(summary) = capability.summary {
398 return String::from(summary);
399 }
400 if let Some(primary) = capability.commands.first() {
401 return format!(
402 "Use {} via {}",
403 capability.name.replace('-', " "),
404 primary.path.join(" ")
405 );
406 }
407 format!("Use {}", capability.name.replace('-', " "))
408}
409
410fn output_or_default(capability: &AgentCapability) -> String {
411 capability.output.map_or_else(
412 || {
413 capability.commands.first().map_or_else(
414 || String::from("output follows the existing CLI contract"),
415 |primary| {
416 format!(
417 "output follows the existing CLI contract for {}",
418 primary.path.join(" ")
419 )
420 },
421 )
422 },
423 String::from,
424 )
425}
426
427fn constraints_or_default(capability: &AgentCapability) -> String {
428 capability.constraints.map_or_else(
429 || String::from("existing command validation and auth rules still apply"),
430 String::from,
431 )
432}
433
434fn render_command_lines(capability: &AgentCapability) -> String {
435 if capability.commands.is_empty() {
436 String::from("- none declared")
437 } else {
438 capability
439 .commands
440 .iter()
441 .map(|selector| format!("- {}", selector.path.join(" ")))
442 .collect::<Vec<_>>()
443 .join("\n")
444 }
445}
446
447fn render_flag_lines(capability: &AgentCapability) -> String {
448 if capability.flags.is_empty() {
449 String::from("- none declared")
450 } else {
451 capability
452 .flags
453 .iter()
454 .map(|flag| {
455 if flag.command_path.is_empty() {
456 format!("- --{}", flag.long)
457 } else {
458 format!("- {} --{}", flag.command_path.join(" "), flag.long)
459 }
460 })
461 .collect::<Vec<_>>()
462 .join("\n")
463 }
464}
465
466fn render_example_lines(capability: &AgentCapability) -> String {
467 capability.examples.map_or_else(
468 || String::from("- none declared"),
469 |examples| {
470 if examples.is_empty() {
471 String::from("- none declared")
472 } else {
473 examples
474 .iter()
475 .map(|example| format!("- {example}"))
476 .collect::<Vec<_>>()
477 .join("\n")
478 }
479 },
480 )
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use crate::{
487 AgentCapability, AgentModeContext, AgentSurfaceSpec, CommandSelector, FlagSelector,
488 LicenseType, RepoInfo, ToolSpec,
489 };
490
491 fn inactive_env() -> ProcessEnv {
492 ProcessEnv {
493 agent: AgentModeContext { active: false },
494 home: None,
495 }
496 }
497
498 fn active_env() -> ProcessEnv {
499 ProcessEnv {
500 agent: AgentModeContext { active: true },
501 home: None,
502 }
503 }
504
505 const SCAN_COMMAND: CommandSelector = CommandSelector::new(&["scan"]);
506 const SCAN_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["scan"], "limit");
507
508 const SCAN_CAPABILITY: AgentCapability = AgentCapability::new(
509 "scan-tree",
510 "Scan a directory tree",
511 &[SCAN_COMMAND],
512 &[SCAN_LIMIT_FLAG],
513 )
514 .with_examples(&["tool scan --limit 5"])
515 .with_output("plain text on stdout, exit 1 on findings")
516 .with_constraints("reads only the working tree")
517 .with_when_to_use("the user wants to enumerate matching files in the current tree")
518 .with_when_not_to_use("the user is asking about remote or non-filesystem state");
519
520 const AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[SCAN_CAPABILITY]);
521
522 fn spec() -> ToolSpec {
523 ToolSpec::new(
524 "tool",
525 "Tool",
526 "1.2.3",
527 LicenseType::MIT,
528 RepoInfo::new("owner", "repo"),
529 true,
530 true,
531 )
532 .with_agent_surface(&AGENT_SURFACE)
533 }
534
535 #[test]
536 fn list_text_includes_capability() {
537 let rendered = render_list(&spec(), ListFormat::Text);
538 assert!(rendered.contains("tool: tool"));
539 assert!(rendered.contains("- scan-tree: Scan a directory tree"));
540 }
541
542 #[test]
543 fn list_json_includes_skill_name() {
544 let rendered = render_list(&spec(), ListFormat::Json);
545 assert!(rendered.contains("\"skill_name\":\"tool-scan-tree\""));
546 assert!(rendered.contains("\"version\":\"1.2.3\""));
547 }
548
549 #[test]
550 fn describe_text_emits_when_sections() {
551 let rendered = render_describe(&spec(), &SCAN_CAPABILITY, DescribeFormat::Text);
552 assert!(rendered.contains("when-to-use: the user wants"));
553 assert!(rendered.contains("when-not-to-use: the user is asking"));
554 assert!(rendered.contains("- scan --limit"));
555 }
556
557 #[test]
558 fn describe_json_round_trips_optional_fields() {
559 let rendered = render_describe(&spec(), &SCAN_CAPABILITY, DescribeFormat::Json);
560 let value: Value = serde_json::from_str(&rendered).expect("valid json");
561 assert_eq!(value["capability"], "scan-tree");
562 assert_eq!(value["skill_name"], "tool-scan-tree");
563 assert_eq!(
564 value["when_to_use"],
565 "the user wants to enumerate matching files in the current tree"
566 );
567 assert_eq!(value["commands"][0], "scan");
568 assert_eq!(value["flags"][0], "scan --limit");
569 }
570
571 #[test]
572 fn skill_md_has_frontmatter_and_sections() {
573 let rendered = render_skill_md(&spec(), &SCAN_CAPABILITY);
574 assert!(rendered.starts_with("---\n"));
575 assert!(rendered.contains("name: tool-scan-tree"));
576 assert!(rendered.contains("description:"));
577 assert!(rendered.contains("## When to use"));
578 assert!(rendered.contains("## When not to use"));
579 assert!(rendered.contains("## Commands"));
580 assert!(rendered.contains("- `tool scan`"));
581 assert!(rendered.contains("## Flags"));
582 assert!(rendered.contains("- `scan --limit`"));
583 assert!(rendered.contains("## Examples"));
584 assert!(rendered.contains("- `tool scan --limit 5`"));
585 assert!(rendered.contains("## Output"));
586 assert!(rendered.contains("## Constraints"));
587 }
588
589 #[test]
590 fn skill_md_quotes_description_when_colon_present() {
591 const COLON_CAPABILITY: AgentCapability =
592 AgentCapability::new("with-colon", "Summary: contains a colon", &[], &[]);
593 const COLON_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[COLON_CAPABILITY]);
594 let spec = ToolSpec::new(
595 "tool",
596 "Tool",
597 "1.2.3",
598 LicenseType::MIT,
599 RepoInfo::new("owner", "repo"),
600 true,
601 true,
602 )
603 .with_agent_surface(&COLON_SURFACE);
604 let rendered = render_skill_md(&spec, &COLON_CAPABILITY);
605 assert!(rendered.contains("description: \"Summary: contains a colon\""));
606 }
607
608 #[test]
609 fn run_agent_describe_unknown_returns_one() {
610 let exit = run_agent_subcommand(
611 &spec(),
612 &inactive_env(),
613 &AgentSubcommand::Describe {
614 name: "nope".into(),
615 format: DescribeFormat::Text,
616 },
617 );
618 assert_eq!(exit, 1);
619 }
620
621 #[test]
622 fn run_agent_subcommand_blocks_under_active_agent_mode() {
623 let env = active_env();
624
625 let exit_list = run_agent_subcommand(
626 &spec(),
627 &env,
628 &AgentSubcommand::List {
629 format: ListFormat::Text,
630 },
631 );
632 let exit_describe = run_agent_subcommand(
633 &spec(),
634 &env,
635 &AgentSubcommand::Describe {
636 name: "scan-tree".into(),
637 format: DescribeFormat::Text,
638 },
639 );
640 let dir = tempdir();
641 let exit_emit = run_agent_subcommand(
642 &spec(),
643 &env,
644 &AgentSubcommand::EmitSkills {
645 target: EmitTarget::Claude,
646 scope: EmitScope::User,
647 out: Some(dir.clone()),
648 install: false,
649 },
650 );
651 let wrote_file = dir.join("tool-scan-tree").join("SKILL.md").exists();
652 std::fs::remove_dir_all(&dir).ok();
653
654 assert_eq!(exit_list, 1);
655 assert_eq!(exit_describe, 1);
656 assert_eq!(exit_emit, 1);
657 assert!(
658 !wrote_file,
659 "emit-skills must not write artifacts under agent mode"
660 );
661 }
662
663 #[test]
664 fn run_emit_skills_without_target_or_install_errors() {
665 let exit = run_agent_subcommand(
666 &spec(),
667 &inactive_env(),
668 &AgentSubcommand::EmitSkills {
669 target: EmitTarget::Claude,
670 scope: EmitScope::User,
671 out: None,
672 install: false,
673 },
674 );
675 assert_eq!(exit, 2);
676 }
677
678 #[test]
679 fn run_emit_skills_writes_skill_files_under_out_dir() {
680 let dir = tempdir();
681 let exit = run_agent_subcommand(
682 &spec(),
683 &inactive_env(),
684 &AgentSubcommand::EmitSkills {
685 target: EmitTarget::Claude,
686 scope: EmitScope::User,
687 out: Some(dir.clone()),
688 install: false,
689 },
690 );
691 assert_eq!(exit, 0);
692 let path = dir.join("tool-scan-tree").join("SKILL.md");
693 let contents = std::fs::read_to_string(&path).expect("skill file should exist");
694 assert!(contents.contains("name: tool-scan-tree"));
695 std::fs::remove_dir_all(&dir).ok();
696 }
697
698 #[test]
699 fn resolve_emit_dir_user_scope_uses_home_for_claude() {
700 let path = resolve_emit_dir(
701 Some(Path::new("/tmp/fake-home")),
702 EmitTarget::Claude,
703 EmitScope::User,
704 None,
705 )
706 .expect("resolves with $HOME");
707 assert_eq!(path, PathBuf::from("/tmp/fake-home/.claude/skills"));
708 }
709
710 #[test]
711 fn resolve_emit_dir_user_scope_uses_home_for_codex() {
712 let path = resolve_emit_dir(
713 Some(Path::new("/tmp/fake-home")),
714 EmitTarget::Codex,
715 EmitScope::User,
716 None,
717 )
718 .expect("resolves with $HOME");
719 assert_eq!(path, PathBuf::from("/tmp/fake-home/.codex/skills"));
720 }
721
722 #[test]
723 fn resolve_emit_dir_user_scope_errors_without_home() {
724 let err = resolve_emit_dir(None, EmitTarget::Claude, EmitScope::User, None)
725 .expect_err("user scope requires $HOME");
726 assert_eq!(err, EmitDirError::HomeUnset);
727 assert_eq!(err.to_string(), "$HOME is not set");
728 }
729
730 #[test]
731 fn resolve_emit_dir_project_scope_is_relative() {
732 let path = resolve_emit_dir(None, EmitTarget::Codex, EmitScope::Project, None)
733 .expect("project scope resolves without home");
734 assert_eq!(path, PathBuf::from(".codex/skills"));
735 }
736
737 #[test]
738 fn resolve_emit_dir_explicit_out_overrides_scope() {
739 let path = resolve_emit_dir(
740 None,
741 EmitTarget::Claude,
742 EmitScope::User,
743 Some(Path::new("/tmp/explicit")),
744 )
745 .expect("explicit path overrides resolution");
746 assert_eq!(path, PathBuf::from("/tmp/explicit"));
747 }
748
749 fn tempdir() -> PathBuf {
750 use std::sync::atomic::{AtomicU64, Ordering};
751 static COUNTER: AtomicU64 = AtomicU64::new(0);
752 let base = std::env::temp_dir().join(format!(
753 "tftio-cli-common-agent-skill-{}-{}",
754 std::process::id(),
755 COUNTER.fetch_add(1, Ordering::Relaxed),
756 ));
757 if let Err(e) = std::fs::remove_dir_all(&base) {
758 eprintln!("failed to clean up tempdir {}: {e}", base.display());
759 }
760 std::fs::create_dir_all(&base).expect("create tempdir");
761 base
762 }
763}