Skip to main content

tftio_cli_common/
agent_skill.rs

1//! Ungated agent-skill artifact generation.
2//!
3//! This surface emits descriptors for the tool's declared agent capabilities
4//! in formats consumable by LLM coding agents (`Claude Code`, `OpenAI Codex CLI`).
5//! Unlike the redaction-oriented surface in [`crate::agent`], this surface is
6//! always available — installing skill artifacts is a human-initiated action,
7//! not a supervised agent inspection path.
8
9use std::{
10    fmt::Write as _,
11    fs,
12    io::{self, Write},
13    path::{Path, PathBuf},
14};
15
16// clap::Subcommand used via crate::agent types
17use serde_json::{Value, json};
18
19use crate::agent::{AgentSubcommand, DescribeFormat, EmitScope, EmitTarget, ListFormat};
20use crate::{AgentCapability, ProcessEnv, ToolSpec};
21
22/// Errors that can occur when resolving the skill artifact destination directory.
23#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
24pub enum EmitDirError {
25    /// User-scope resolution requires `$HOME`, which was not set at the edge.
26    #[error("$HOME is not set")]
27    HomeUnset,
28}
29
30/// Dispatch an [`AgentSubcommand`] for the given tool.
31///
32/// Refuses to run when agent mode is active (see [`AgentModeContext`]). Skill
33/// generation is a human-initiated, dev-time action; under supervision the
34/// surface is blocked to avoid leaking declared capabilities to a worker
35/// process or letting a worker write artifacts into the host's skill registry.
36#[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
130/// Resolve the directory under which `<skill>/SKILL.md` artifacts will be written.
131///
132/// `home` is the process `HOME` directory read at the binary edge (`REPO_INVARIANTS.md` #5).
133///
134/// # Errors
135///
136/// Returns [`EmitDirError::HomeUnset`] when the user-scope path requires `$HOME`
137/// and it is unset.
138pub 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/// Construct the canonical skill artifact name for a capability.
172#[must_use]
173pub fn skill_name(spec: &ToolSpec, capability: &AgentCapability) -> String {
174    format!("{}-{}", spec.bin_name, capability.name)
175}
176
177/// Render the `agent list` output.
178#[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/// Render `agent describe` for a single capability.
223#[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/// Render a Claude/Codex-compatible `SKILL.md` body for one capability.
291///
292/// The artifact has YAML frontmatter (`name`, `description`) and a Markdown body
293/// covering when-to-use, commands, flags, examples, output, and constraints.
294#[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}