Skip to main content

tftio_cli_common/
agent.rs

1//! Shared agent-mode capability declarations and filtering helpers.
2
3use std::{collections::BTreeSet, ffi::OsString};
4
5use clap::{Arg, ArgAction, Command, CommandFactory, FromArgMatches, error::ErrorKind};
6
7use crate::ToolSpec;
8
9/// Environment variable containing the presented agent token.
10pub const AGENT_TOKEN_ENV: &str = "TFTIO_AGENT_TOKEN";
11
12/// Environment variable containing the expected agent token.
13pub const AGENT_TOKEN_EXPECTED_ENV: &str = "TFTIO_AGENT_TOKEN_EXPECTED";
14
15/// Shared process-level agent-mode context.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub struct AgentModeContext {
18    /// Whether agent mode is active for the current process.
19    pub active: bool,
20}
21
22impl AgentModeContext {
23    /// Construct an agent-mode context from edge-read token values.
24    ///
25    /// Agent mode is active only when both the presented and expected tokens
26    /// are present and equal. Reading the tokens from the environment is the
27    /// binary edge's responsibility (`REPO_INVARIANTS.md` #5).
28    #[must_use]
29    pub fn from_tokens(presented: Option<String>, expected: Option<String>) -> Self {
30        Self {
31            active: matches!((presented, expected), (Some(presented), Some(expected)) if presented == expected),
32        }
33    }
34}
35
36/// Process-edge environment values threaded inward from each binary's `main`.
37///
38/// Per `REPO_INVARIANTS.md` #5 the environment is read once at the binary edge
39/// and passed inward as typed values. This bundle carries those reads so the
40/// shared library code never touches `std::env`.
41#[derive(Debug, Clone, PartialEq, Eq, Default)]
42pub struct ProcessEnv {
43    /// Agent-mode context derived from the agent token environment variables.
44    pub agent: AgentModeContext,
45    /// The process `HOME` directory, if set.
46    pub home: Option<std::path::PathBuf>,
47}
48
49/// Declarative capability surface for a tool.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct AgentSurfaceSpec {
52    /// Named capabilities visible to the agent surface.
53    pub(crate) capabilities: &'static [AgentCapability],
54}
55
56impl AgentSurfaceSpec {
57    /// Create a new [`AgentSurfaceSpec`].
58    #[must_use]
59    pub const fn new(capabilities: &'static [AgentCapability]) -> Self {
60        Self { capabilities }
61    }
62
63    /// Return the capabilities in this surface.
64    #[must_use]
65    pub const fn capabilities(&self) -> &'static [AgentCapability] {
66        self.capabilities
67    }
68}
69
70/// Declarative capability group for agent-mode visibility.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct AgentCapability {
73    /// Stable capability name.
74    pub(crate) name: &'static str,
75    /// Optional human-readable summary for the capability.
76    pub(crate) summary: Option<&'static str>,
77    /// Command paths exposed by this capability.
78    pub(crate) commands: &'static [CommandSelector],
79    /// Long flags exposed by this capability.
80    pub(crate) flags: &'static [FlagSelector],
81    /// Optional example invocations.
82    pub(crate) examples: Option<&'static [&'static str]>,
83    /// Optional output contract prose.
84    pub(crate) output: Option<&'static str>,
85    /// Optional constraints prose.
86    pub(crate) constraints: Option<&'static str>,
87    /// Optional prose describing when an agent should reach for this capability.
88    pub(crate) when_to_use: Option<&'static str>,
89    /// Optional prose describing when an agent should NOT reach for this capability.
90    pub(crate) when_not_to_use: Option<&'static str>,
91}
92
93impl AgentCapability {
94    /// Return the capability name.
95    #[must_use]
96    pub const fn name(&self) -> &'static str {
97        self.name
98    }
99
100    /// Return the capability summary, if any.
101    #[must_use]
102    pub const fn summary(&self) -> Option<&'static str> {
103        self.summary
104    }
105
106    /// Return the command selectors for this capability.
107    #[must_use]
108    pub const fn commands(&self) -> &'static [CommandSelector] {
109        self.commands
110    }
111
112    /// Return the flag selectors for this capability.
113    #[must_use]
114    pub const fn flags(&self) -> &'static [FlagSelector] {
115        self.flags
116    }
117
118    /// Return example invocations, if any.
119    #[must_use]
120    pub const fn examples(&self) -> Option<&'static [&'static str]> {
121        self.examples
122    }
123
124    /// Return the output contract prose, if any.
125    #[must_use]
126    pub const fn output(&self) -> Option<&'static str> {
127        self.output
128    }
129
130    /// Return the constraints prose, if any.
131    #[must_use]
132    pub const fn constraints(&self) -> Option<&'static str> {
133        self.constraints
134    }
135
136    /// Return the when-to-use prose, if any.
137    #[must_use]
138    pub const fn when_to_use(&self) -> Option<&'static str> {
139        self.when_to_use
140    }
141
142    /// Return the when-not-to-use prose, if any.
143    #[must_use]
144    pub const fn when_not_to_use(&self) -> Option<&'static str> {
145        self.when_not_to_use
146    }
147
148    /// Create a new [`AgentCapability`].
149    #[must_use]
150    pub const fn new(
151        name: &'static str,
152        summary: &'static str,
153        commands: &'static [CommandSelector],
154        flags: &'static [FlagSelector],
155    ) -> Self {
156        Self {
157            name,
158            summary: Some(summary),
159            commands,
160            flags,
161            examples: None,
162            output: None,
163            constraints: None,
164            when_to_use: None,
165            when_not_to_use: None,
166        }
167    }
168
169    /// Create a new [`AgentCapability`] without optional prose metadata.
170    #[must_use]
171    pub const fn minimal(
172        name: &'static str,
173        commands: &'static [CommandSelector],
174        flags: &'static [FlagSelector],
175    ) -> Self {
176        Self {
177            name,
178            summary: None,
179            commands,
180            flags,
181            examples: None,
182            output: None,
183            constraints: None,
184            when_to_use: None,
185            when_not_to_use: None,
186        }
187    }
188
189    /// Attach example invocations.
190    #[must_use]
191    pub const fn with_examples(self, examples: &'static [&'static str]) -> Self {
192        Self {
193            examples: Some(examples),
194            ..self
195        }
196    }
197
198    /// Attach output contract prose.
199    #[must_use]
200    pub const fn with_output(self, output: &'static str) -> Self {
201        Self {
202            output: Some(output),
203            ..self
204        }
205    }
206
207    /// Attach constraints prose.
208    #[must_use]
209    pub const fn with_constraints(self, constraints: &'static str) -> Self {
210        Self {
211            constraints: Some(constraints),
212            ..self
213        }
214    }
215
216    /// Attach when-to-use prose.
217    #[must_use]
218    pub const fn with_when_to_use(self, when_to_use: &'static str) -> Self {
219        Self {
220            when_to_use: Some(when_to_use),
221            ..self
222        }
223    }
224
225    /// Attach when-not-to-use prose.
226    #[must_use]
227    pub const fn with_when_not_to_use(self, when_not_to_use: &'static str) -> Self {
228        Self {
229            when_not_to_use: Some(when_not_to_use),
230            ..self
231        }
232    }
233}
234
235/// Declarative selector for a command path.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct CommandSelector {
238    /// Path segments for the selected command.
239    pub(crate) path: &'static [&'static str],
240}
241
242impl CommandSelector {
243    /// Create a new [`CommandSelector`].
244    #[must_use]
245    pub const fn new(path: &'static [&'static str]) -> Self {
246        Self { path }
247    }
248
249    /// Return the path segments.
250    #[must_use]
251    pub const fn path(&self) -> &'static [&'static str] {
252        self.path
253    }
254}
255
256/// Declarative selector for a long flag on a command path.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct FlagSelector {
259    /// Command path that owns the flag.
260    pub(crate) command_path: &'static [&'static str],
261    /// Long flag name without the leading `--`.
262    pub(crate) long: &'static str,
263}
264
265impl FlagSelector {
266    /// Create a new [`FlagSelector`].
267    #[must_use]
268    pub const fn new(command_path: &'static [&'static str], long: &'static str) -> Self {
269        Self { command_path, long }
270    }
271
272    /// Return the command path that owns this flag.
273    #[must_use]
274    pub const fn command_path(&self) -> &'static [&'static str] {
275        self.command_path
276    }
277
278    /// Return the long flag name without the leading `--`.
279    #[must_use]
280    pub const fn long(&self) -> &'static str {
281        self.long
282    }
283}
284
285/// Shared parse result for agent-aware entrypoints.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum AgentDispatch<T> {
288    /// Continue with the parsed CLI value.
289    Cli(T),
290    /// A shared agent inspection path printed output and chose an exit code.
291    Printed(i32),
292}
293
294/// Error returned when rendering a single agent capability view fails.
295#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
296pub enum AgentSkillError {
297    /// The requested capability name is not visible in the current context.
298    #[error("unknown agent capability: {0}")]
299    UnknownCapability(String),
300}
301
302/// Return the capabilities visible in the current agent-mode context.
303#[must_use]
304pub fn visible_capabilities<'a>(
305    spec: &'a ToolSpec,
306    ctx: &AgentModeContext,
307) -> &'a [AgentCapability] {
308    if ctx.active {
309        spec.agent_surface
310            .map_or(&[], |surface| surface.capabilities)
311    } else {
312        &[]
313    }
314}
315
316/// Apply the visible agent surface to a `clap` command tree.
317pub fn apply_agent_surface(command: &mut Command, spec: &ToolSpec, ctx: &AgentModeContext) {
318    if !ctx.active {
319        return;
320    }
321
322    ensure_agent_inspection_args(command);
323
324    let filtered = filter_command(command, spec.version, visible_capabilities(spec, ctx), &[]);
325    *command = filtered;
326}
327
328fn filter_command(
329    command: &Command,
330    version: &'static str,
331    capabilities: &[AgentCapability],
332    current_path: &[&str],
333) -> Command {
334    let keep_full_subtree =
335        is_within_explicit_command_subtree(capabilities, current_path, command.has_subcommands());
336    let allowed_flags = allowed_flags(capabilities, current_path);
337    let mut filtered = clone_command_metadata(command, version, current_path.is_empty());
338
339    for arg in command
340        .get_arguments()
341        .filter(|arg| {
342            should_keep_arg(
343                arg,
344                capabilities,
345                current_path,
346                &allowed_flags,
347                keep_full_subtree,
348            )
349        })
350        .cloned()
351    {
352        filtered = filtered.arg(arg);
353    }
354
355    if keep_full_subtree {
356        for subcommand in command.get_subcommands() {
357            let subcommand_name = subcommand.get_name();
358            let next_path = extend_path_owned(current_path, subcommand_name);
359            let next_path_refs = next_path.iter().map(String::as_str).collect::<Vec<_>>();
360            filtered = filtered.subcommand(filter_command(
361                subcommand,
362                version,
363                capabilities,
364                &next_path_refs,
365            ));
366        }
367        return filtered;
368    }
369
370    for subcommand_name in allowed_subcommands(capabilities, current_path) {
371        if let Some(subcommand) = command.find_subcommand(subcommand_name) {
372            let next_path = extend_path_owned(current_path, subcommand_name);
373            let next_path_refs = next_path.iter().map(String::as_str).collect::<Vec<_>>();
374            filtered = filtered.subcommand(filter_command(
375                subcommand,
376                version,
377                capabilities,
378                &next_path_refs,
379            ));
380        }
381    }
382
383    filtered
384}
385
386fn clone_command_metadata(
387    command: &Command,
388    version: &'static str,
389    include_version: bool,
390) -> Command {
391    let mut filtered = Command::new(command.get_name().to_owned());
392
393    if let Some(display_name) = command.get_display_name() {
394        filtered = filtered.display_name(display_name.to_owned());
395    }
396    if include_version {
397        filtered = filtered.version(version);
398    }
399    if let Some(about) = command.get_about() {
400        filtered = filtered.about(about.clone());
401    }
402    if let Some(long_about) = command.get_long_about() {
403        filtered = filtered.long_about(long_about.clone());
404    }
405    if let Some(before_help) = command.get_before_help() {
406        filtered = filtered.before_help(before_help.clone());
407    }
408    if let Some(after_help) = command.get_after_help() {
409        filtered = filtered.after_help(after_help.clone());
410    }
411    if command.is_disable_help_flag_set() {
412        filtered = filtered.disable_help_flag(true);
413    }
414    if command.is_disable_help_subcommand_set() {
415        filtered = filtered.disable_help_subcommand(true);
416    }
417    if command.is_disable_colored_help_set() {
418        filtered = filtered.disable_colored_help(true);
419    }
420    if command.is_flatten_help_set() {
421        filtered = filtered.flatten_help(true);
422    }
423    if let Some(bin_name) = command.get_bin_name() {
424        filtered.set_bin_name(bin_name.to_owned());
425    }
426
427    filtered
428}
429
430fn allowed_flags(
431    capabilities: &[AgentCapability],
432    current_path: &[&str],
433) -> BTreeSet<&'static str> {
434    let mut flags = BTreeSet::new();
435
436    for capability in capabilities {
437        for selector in capability.flags {
438            if selector.command_path == current_path {
439                flags.insert(selector.long);
440            }
441        }
442    }
443
444    flags
445}
446
447fn allowed_subcommands(
448    capabilities: &[AgentCapability],
449    current_path: &[&str],
450) -> BTreeSet<&'static str> {
451    let mut subcommands = BTreeSet::new();
452
453    for capability in capabilities {
454        for selector in capability.commands {
455            if selector.path.starts_with(current_path) {
456                if let Some(&segment) = selector.path.get(current_path.len()) {
457                    subcommands.insert(segment);
458                }
459            }
460        }
461    }
462
463    subcommands
464}
465
466fn is_within_explicit_command_subtree(
467    capabilities: &[AgentCapability],
468    current_path: &[&str],
469    command_has_subcommands: bool,
470) -> bool {
471    capabilities.iter().any(|capability| {
472        capability.commands.iter().any(|selector| {
473            !selector.path.is_empty()
474                && current_path.starts_with(selector.path)
475                && (selector.path.len() < current_path.len()
476                    || (selector.path.len() == current_path.len() && command_has_subcommands))
477        })
478    })
479}
480
481fn should_keep_arg(
482    arg: &Arg,
483    capabilities: &[AgentCapability],
484    current_path: &[&str],
485    allowed_flags: &BTreeSet<&str>,
486    keep_full_subtree: bool,
487) -> bool {
488    if is_shared_agent_flag(arg) {
489        return true;
490    }
491
492    if arg.is_positional() {
493        return capability_includes_command_path(capabilities, current_path);
494    }
495
496    if keep_full_subtree {
497        return true;
498    }
499
500    arg.get_long()
501        .is_some_and(|long| allowed_flags.contains(long))
502}
503
504fn capability_includes_command_path(
505    capabilities: &[AgentCapability],
506    current_path: &[&str],
507) -> bool {
508    capabilities
509        .iter()
510        .flat_map(|capability| capability.commands.iter())
511        .any(|selector| selector.path == current_path)
512}
513
514fn is_shared_agent_flag(arg: &Arg) -> bool {
515    matches!(arg.get_long(), Some("agent-help" | "agent-skill"))
516}
517
518fn ensure_agent_inspection_args(command: &mut Command) {
519    if !command
520        .get_arguments()
521        .any(|arg| arg.get_long() == Some("agent-help"))
522    {
523        *command = command.clone().arg(
524            Arg::new("agent-help")
525                .long("agent-help")
526                .help("Print the visible agent command surface")
527                .hide(true)
528                .global(true)
529                .action(ArgAction::SetTrue),
530        );
531    }
532
533    if !command
534        .get_arguments()
535        .any(|arg| arg.get_long() == Some("agent-skill"))
536    {
537        *command = command.clone().arg(
538            Arg::new("agent-skill")
539                .long("agent-skill")
540                .help("Print the visible agent capability contract")
541                .hide(true)
542                .global(true)
543                .value_name("NAME"),
544        );
545    }
546}
547
548fn extend_path_owned(current_path: &[&str], segment: &str) -> Vec<String> {
549    let mut next_path = current_path
550        .iter()
551        .map(|part| (*part).to_owned())
552        .collect::<Vec<_>>();
553    next_path.push(segment.to_owned());
554    next_path
555}
556
557/// Parse argv against the normal or agent-filtered surface for a clap CLI.
558///
559/// # Errors
560///
561/// Returns a `clap` error when parsing fails or when an unknown agent capability is requested.
562pub fn parse_with_agent_surface_from<T, I>(
563    spec: &ToolSpec,
564    ctx: &AgentModeContext,
565    argv: I,
566) -> Result<AgentDispatch<T>, clap::Error>
567where
568    T: CommandFactory + FromArgMatches,
569    I: IntoIterator,
570    I::Item: Into<OsString> + Clone,
571{
572    let argv = rewrite_trailing_help_subcommand(argv.into_iter().map(Into::into).collect());
573    let mut command = T::command();
574    ensure_agent_inspection_args(&mut command);
575
576    if ctx.active {
577        apply_agent_surface(&mut command, spec, ctx);
578    }
579
580    match command.try_get_matches_from_mut(argv) {
581        Ok(mut matches) => {
582            if matches.get_flag("agent-help") {
583                println!("{}", render_agent_help(spec, ctx));
584                return Ok(AgentDispatch::Printed(0));
585            }
586
587            if let Some(name) = matches.get_one::<String>("agent-skill") {
588                let text = render_agent_skill(spec, ctx, name)
589                    .map_err(|error| command.error(ErrorKind::InvalidValue, error.to_string()))?;
590                println!("{text}");
591                return Ok(AgentDispatch::Printed(0));
592            }
593
594            T::from_arg_matches_mut(&mut matches).map(AgentDispatch::Cli)
595        }
596        Err(error) => Err(sanitize_agent_parse_error(error)),
597    }
598}
599
600fn rewrite_trailing_help_subcommand(mut argv: Vec<OsString>) -> Vec<OsString> {
601    if argv.last().is_some_and(|arg| arg == "help") {
602        argv.pop();
603        argv.push(OsString::from("--help"));
604    }
605
606    argv
607}
608
609fn sanitize_agent_parse_error(mut error: clap::Error) -> clap::Error {
610    let rendered = error.to_string();
611    if rendered.contains("Did you mean") {
612        error = clap::Error::raw(error.kind(), strip_suggestion_lines(&rendered));
613    }
614    error
615}
616
617fn strip_suggestion_lines(rendered: &str) -> String {
618    rendered
619        .lines()
620        .filter(|line| !line.contains("Did you mean"))
621        .collect::<Vec<_>>()
622        .join("\n")
623}
624
625/// Render the visible agent capability surface as structured text.
626#[must_use]
627pub fn render_agent_help(spec: &ToolSpec, ctx: &AgentModeContext) -> String {
628    let capabilities = visible_capabilities(spec, ctx);
629    let capability_lines = if capabilities.is_empty() {
630        String::from("- none")
631    } else {
632        capabilities
633            .iter()
634            .map(|capability| format!("- {}: {}", capability.name, capability_summary(capability)))
635            .collect::<Vec<_>>()
636            .join("\n")
637    };
638    let argument_lines = render_surface_arguments(capabilities);
639
640    format!(
641        "tool:\n- {}\nmode:\n- {}\ncapabilities:\n{}\narguments:\n{}\noutput:\n- structured plain text for the visible command surface\nconstraints:\n- output is limited to the currently visible surface",
642        spec.bin_name,
643        if ctx.active { "agent" } else { "human" },
644        capability_lines,
645        argument_lines,
646    )
647}
648
649/// Render the visible contract for one agent capability.
650///
651/// # Errors
652///
653/// Returns [`AgentSkillError`] when the capability name is not visible in the current context.
654pub fn render_agent_skill(
655    spec: &ToolSpec,
656    ctx: &AgentModeContext,
657    name: &str,
658) -> Result<String, AgentSkillError> {
659    let capability = visible_capabilities(spec, ctx)
660        .iter()
661        .find(|capability| capability.name == name)
662        .ok_or_else(|| AgentSkillError::UnknownCapability(name.to_owned()))?;
663
664    Ok(format!(
665        "tool:\n- {}\ncapability:\n- {}\nsummary:\n- {}\ncommands:\n{}\nflags:\n{}\nexamples:\n{}\noutput:\n- {}\nconstraints:\n- {}",
666        spec.bin_name,
667        capability.name,
668        capability_summary(capability),
669        render_command_lines(capability),
670        render_flag_lines(capability),
671        render_example_lines(capability),
672        capability_output(capability),
673        capability_constraints(capability),
674    ))
675}
676
677fn capability_summary(capability: &AgentCapability) -> String {
678    if let Some(summary) = capability.summary {
679        return String::from(summary);
680    }
681
682    if let Some(primary_command) = capability.commands.first() {
683        return format!(
684            "Use {} via {}",
685            capability.name.replace('-', " "),
686            primary_command.path.join(" ")
687        );
688    }
689
690    format!("Use {}", capability.name.replace('-', " "))
691}
692
693fn capability_output(capability: &AgentCapability) -> String {
694    capability.output.map_or_else(
695        || {
696            capability.commands.first().map_or_else(
697                || String::from("output follows the existing CLI contract"),
698                |primary_command| {
699                    format!(
700                        "output follows the existing CLI contract for {}",
701                        primary_command.path.join(" ")
702                    )
703                },
704            )
705        },
706        String::from,
707    )
708}
709
710fn capability_constraints(capability: &AgentCapability) -> String {
711    capability.constraints.map_or_else(
712        || String::from("existing command validation and auth rules still apply"),
713        String::from,
714    )
715}
716
717fn render_example_lines(capability: &AgentCapability) -> String {
718    capability.examples.map_or_else(
719        || String::from("- none declared"),
720        |examples| {
721            if examples.is_empty() {
722                String::from("- none declared")
723            } else {
724                examples
725                    .iter()
726                    .map(|example| format!("- {example}"))
727                    .collect::<Vec<_>>()
728                    .join("\n")
729            }
730        },
731    )
732}
733
734fn render_surface_arguments(capabilities: &[AgentCapability]) -> String {
735    let mut lines = vec![
736        String::from("- --agent-help"),
737        String::from("- --agent-skill <NAME>"),
738    ];
739
740    for capability in capabilities {
741        for command in capability.commands {
742            lines.push(format!("- command {}", command.path.join(" ")));
743        }
744        for flag in capability.flags {
745            let prefix = if flag.command_path.is_empty() {
746                String::new()
747            } else {
748                format!("{} ", flag.command_path.join(" "))
749            };
750            lines.push(format!("- {prefix}--{}", flag.long));
751        }
752    }
753
754    lines.join("\n")
755}
756
757fn render_command_lines(capability: &AgentCapability) -> String {
758    if capability.commands.is_empty() {
759        String::from("- none declared")
760    } else {
761        capability
762            .commands
763            .iter()
764            .map(|selector| format!("- {}", selector.path.join(" ")))
765            .collect::<Vec<_>>()
766            .join("\n")
767    }
768}
769
770fn render_flag_lines(capability: &AgentCapability) -> String {
771    if capability.flags.is_empty() {
772        String::from("- none declared")
773    } else {
774        capability
775            .flags
776            .iter()
777            .map(|selector| {
778                if selector.command_path.is_empty() {
779                    format!("- --{}", selector.long)
780                } else {
781                    format!("- {} --{}", selector.command_path.join(" "), selector.long)
782                }
783            })
784            .collect::<Vec<_>>()
785            .join("\n")
786    }
787}
788
789// ---- Agent subcommand types (moved from agent_skill.rs) ----
790
791use clap::{Subcommand, ValueEnum};
792use std::path::PathBuf;
793
794/// Output format for `agent describe`.
795#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
796pub enum DescribeFormat {
797    /// Structured plain text.
798    Text,
799    /// Canonical JSON.
800    Json,
801    /// Markdown skill artifact (`SKILL.md` body) with frontmatter.
802    SkillMd,
803}
804
805/// Output format for `agent list`.
806#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
807pub enum ListFormat {
808    /// Structured plain text.
809    Text,
810    /// Canonical JSON.
811    Json,
812}
813
814/// Target agent runtime for `agent emit-skills`.
815#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
816pub enum EmitTarget {
817    /// `Claude Code` skill directory layout.
818    Claude,
819    /// `OpenAI Codex CLI` skill directory layout.
820    Codex,
821}
822
823/// Installation scope for `agent emit-skills --install`.
824#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
825pub enum EmitScope {
826    /// User-wide skill directory (e.g. `~/.claude/skills`).
827    User,
828    /// Project-local skill directory (e.g. `.claude/skills`).
829    Project,
830}
831
832/// Ungated agent-skill subcommand surface.
833#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
834pub enum AgentSubcommand {
835    /// List declared agent capabilities for this tool.
836    List {
837        /// Output format.
838        #[arg(long, value_enum, default_value_t = ListFormat::Text)]
839        format: ListFormat,
840    },
841    /// Describe one declared agent capability.
842    Describe {
843        /// Capability name as declared by the tool.
844        name: String,
845        /// Output format.
846        #[arg(long, value_enum, default_value_t = DescribeFormat::Text)]
847        format: DescribeFormat,
848    },
849    /// Emit one skill artifact per declared capability.
850    EmitSkills {
851        /// Target agent runtime.
852        #[arg(long, value_enum)]
853        target: EmitTarget,
854        /// Installation scope (used with `--install`).
855        #[arg(long, value_enum, default_value_t = EmitScope::User)]
856        scope: EmitScope,
857        /// Write artifacts under this directory instead of the runtime default.
858        #[arg(long, value_name = "DIR")]
859        out: Option<PathBuf>,
860        /// Install artifacts into the resolved runtime skill directory.
861        #[arg(long)]
862        install: bool,
863    },
864}
865
866#[cfg(test)]
867mod tests {
868    use clap::{Arg, Args, Command, Parser, Subcommand};
869
870    use super::*;
871    use crate::{LicenseType, RepoInfo, ToolSpec, test_support::env_lock};
872
873    const QUERY_COMMAND: CommandSelector = CommandSelector::new(&["query"]);
874    const STATUS_COMMAND: CommandSelector = CommandSelector::new(&["status"]);
875    const QUERY_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["query"], "limit");
876    const QUERY_OFFSET_FLAG: FlagSelector = FlagSelector::new(&["query"], "offset");
877
878    const QUERY_CAPABILITY: AgentCapability = AgentCapability::new(
879        "query-posts",
880        "Read paginated post records",
881        &[QUERY_COMMAND],
882        &[QUERY_LIMIT_FLAG, QUERY_OFFSET_FLAG],
883    );
884
885    const STATUS_CAPABILITY: AgentCapability = AgentCapability::new(
886        "inspect-status",
887        "Inspect current status",
888        &[STATUS_COMMAND],
889        &[],
890    );
891    const QUERY_MINIMAL_CAPABILITY: AgentCapability =
892        AgentCapability::minimal("query-minimal", &[QUERY_COMMAND], &[QUERY_LIMIT_FLAG]);
893
894    const AGENT_SURFACE: AgentSurfaceSpec =
895        AgentSurfaceSpec::new(&[QUERY_CAPABILITY, STATUS_CAPABILITY]);
896    const MINIMAL_AGENT_SURFACE: AgentSurfaceSpec =
897        AgentSurfaceSpec::new(&[QUERY_MINIMAL_CAPABILITY]);
898
899    fn spec() -> ToolSpec {
900        ToolSpec::new(
901            "tool",
902            "Tool",
903            "1.2.3",
904            LicenseType::MIT,
905            RepoInfo::new("owner", "repo"),
906            true,
907            false,
908        )
909        .with_agent_surface(&AGENT_SURFACE)
910    }
911
912    fn minimal_spec() -> ToolSpec {
913        ToolSpec::new(
914            "tool",
915            "Tool",
916            "1.2.3",
917            LicenseType::MIT,
918            RepoInfo::new("owner", "repo"),
919            true,
920            false,
921        )
922        .with_agent_surface(&MINIMAL_AGENT_SURFACE)
923    }
924
925    fn detect_from_env() -> AgentModeContext {
926        AgentModeContext::from_tokens(
927            std::env::var(AGENT_TOKEN_ENV).ok(),
928            std::env::var(AGENT_TOKEN_EXPECTED_ENV).ok(),
929        )
930    }
931
932    #[allow(unsafe_code)]
933    fn set_tokens(presented: Option<&str>, expected: Option<&str>) {
934        unsafe {
935            std::env::remove_var(AGENT_TOKEN_ENV);
936            std::env::remove_var(AGENT_TOKEN_EXPECTED_ENV);
937            if let Some(presented) = presented {
938                std::env::set_var(AGENT_TOKEN_ENV, presented);
939            }
940            if let Some(expected) = expected {
941                std::env::set_var(AGENT_TOKEN_EXPECTED_ENV, expected);
942            }
943        }
944    }
945
946    #[test]
947    fn agent_mode_activation_is_inactive_without_presented_token() {
948        let ctx = AgentModeContext::from_tokens(None, Some("expected".into()));
949
950        assert!(!ctx.active);
951    }
952
953    #[test]
954    fn agent_mode_activation_is_inactive_without_expected_token() {
955        let ctx = AgentModeContext::from_tokens(Some("presented".into()), None);
956
957        assert!(!ctx.active);
958    }
959
960    #[test]
961    fn agent_mode_activation_is_inactive_on_exact_string_mismatch() {
962        let ctx = AgentModeContext::from_tokens(Some("presented".into()), Some("expected".into()));
963
964        assert!(!ctx.active);
965    }
966
967    #[test]
968    fn agent_mode_activation_is_active_on_exact_string_match() {
969        let ctx =
970            AgentModeContext::from_tokens(Some("shared-token".into()), Some("shared-token".into()));
971
972        assert!(ctx.active);
973    }
974
975    #[test]
976    fn agent_mode_activation_preserves_capability_declarations() {
977        let spec = spec();
978        let capability = spec
979            .agent_surface
980            .expect("agent surface present")
981            .capabilities
982            .first()
983            .expect("capability present");
984
985        assert_eq!(capability.name, "query-posts");
986        assert_eq!(capability.commands[0].path, ["query"]);
987        assert_eq!(capability.flags[0].command_path, ["query"]);
988        assert_eq!(capability.flags[0].long, "limit");
989        assert_eq!(capability.flags[1].long, "offset");
990    }
991
992    #[test]
993    fn capability_policy_removes_undeclared_subcommand() {
994        let mut command = sample_command();
995
996        apply_agent_surface(&mut command, &spec(), &AgentModeContext { active: true });
997
998        assert!(command.find_subcommand("query").is_some());
999        assert!(command.find_subcommand("status").is_some());
1000        assert!(command.find_subcommand("admin").is_none());
1001    }
1002
1003    #[test]
1004    fn capability_policy_removes_undeclared_flag() {
1005        let mut command = sample_command();
1006
1007        apply_agent_surface(&mut command, &spec(), &AgentModeContext { active: true });
1008
1009        let query = command.find_subcommand("query").expect("query present");
1010        assert!(
1011            query
1012                .get_arguments()
1013                .any(|arg| arg.get_long() == Some("limit"))
1014        );
1015        assert!(
1016            query
1017                .get_arguments()
1018                .any(|arg| arg.get_long() == Some("offset"))
1019        );
1020        assert!(
1021            !query
1022                .get_arguments()
1023                .any(|arg| arg.get_long() == Some("secret"))
1024        );
1025    }
1026
1027    #[test]
1028    fn capability_policy_returns_empty_surface_without_declared_capabilities() {
1029        let spec = ToolSpec::new(
1030            "tool",
1031            "Tool",
1032            "1.2.3",
1033            LicenseType::MIT,
1034            RepoInfo::new("owner", "repo"),
1035            true,
1036            false,
1037        );
1038        let mut command = sample_command();
1039
1040        apply_agent_surface(&mut command, &spec, &AgentModeContext { active: true });
1041
1042        assert!(visible_capabilities(&spec, &AgentModeContext { active: true }).is_empty());
1043        assert!(command.find_subcommand("query").is_none());
1044        assert!(command.find_subcommand("status").is_none());
1045        assert!(command.find_subcommand("admin").is_none());
1046        assert!(
1047            command
1048                .get_arguments()
1049                .any(|arg| arg.get_long() == Some("agent-help"))
1050        );
1051        assert!(
1052            command
1053                .get_arguments()
1054                .any(|arg| arg.get_long() == Some("agent-skill"))
1055        );
1056    }
1057
1058    fn sample_command() -> Command {
1059        Command::new("tool")
1060            .arg(Arg::new("agent-help").long("agent-help"))
1061            .arg(
1062                Arg::new("agent-skill")
1063                    .long("agent-skill")
1064                    .value_name("NAME"),
1065            )
1066            .subcommand(
1067                Command::new("query")
1068                    .arg(Arg::new("limit").long("limit"))
1069                    .arg(Arg::new("offset").long("offset"))
1070                    .arg(Arg::new("secret").long("secret")),
1071            )
1072            .subcommand(Command::new("status"))
1073            .subcommand(Command::new("admin").arg(Arg::new("danger").long("danger")))
1074    }
1075
1076    #[derive(Debug, Parser, PartialEq, Eq)]
1077    #[command(name = "tool")]
1078    struct AgentTestCli {
1079        #[command(subcommand)]
1080        command: Option<AgentTestCommand>,
1081    }
1082
1083    #[derive(Debug, Subcommand, PartialEq, Eq)]
1084    enum AgentTestCommand {
1085        Query(QueryArgs),
1086        Status,
1087        Admin(AdminArgs),
1088    }
1089
1090    #[derive(Debug, Args, PartialEq, Eq)]
1091    struct QueryArgs {
1092        #[arg(long)]
1093        limit: Option<u32>,
1094        #[arg(long)]
1095        offset: Option<u32>,
1096        #[arg(long)]
1097        secret: bool,
1098    }
1099
1100    #[derive(Debug, Args, PartialEq, Eq)]
1101    struct AdminArgs {
1102        #[arg(long)]
1103        danger: bool,
1104    }
1105
1106    #[test]
1107    fn agent_surface_redaction_rejects_hidden_command_and_flag() {
1108        let _guard = env_lock();
1109        set_tokens(Some("shared-token"), Some("shared-token"));
1110        let ctx = detect_from_env();
1111        let spec = spec();
1112
1113        let hidden_command_error =
1114            parse_with_agent_surface_from::<AgentTestCli, _>(&spec, &ctx, ["tool", "admin"])
1115                .expect_err("hidden subcommand should be rejected")
1116                .to_string();
1117        assert!(hidden_command_error.contains("unrecognized subcommand"));
1118
1119        let hidden_command_typo_error =
1120            parse_with_agent_surface_from::<AgentTestCli, _>(&spec, &ctx, ["tool", "admni"])
1121                .expect_err("hidden subcommand typo should not leak suggestions")
1122                .to_string();
1123        assert!(hidden_command_typo_error.contains("unrecognized subcommand"));
1124        assert!(!hidden_command_typo_error.contains("Did you mean"));
1125
1126        let hidden_flag_error = parse_with_agent_surface_from::<AgentTestCli, _>(
1127            &spec,
1128            &ctx,
1129            ["tool", "query", "--secre"],
1130        )
1131        .expect_err("hidden flag typo should be rejected")
1132        .to_string();
1133        assert!(hidden_flag_error.contains("unexpected argument"));
1134        assert!(!hidden_flag_error.contains("--secret"));
1135        assert!(!hidden_flag_error.contains("Did you mean"));
1136    }
1137
1138    #[test]
1139    fn agent_surface_redaction_help_omits_hidden_entries() {
1140        let _guard = env_lock();
1141        set_tokens(Some("shared-token"), Some("shared-token"));
1142        let ctx = detect_from_env();
1143        let spec = spec();
1144
1145        let long_help =
1146            parse_with_agent_surface_from::<AgentTestCli, _>(&spec, &ctx, ["tool", "--help"])
1147                .expect_err("help should short-circuit through clap")
1148                .to_string();
1149        assert!(long_help.contains("query"));
1150        assert!(long_help.contains("status"));
1151        assert!(!long_help.contains("admin"));
1152        assert!(!long_help.contains("--secret"));
1153
1154        let help_subcommand =
1155            parse_with_agent_surface_from::<AgentTestCli, _>(&spec, &ctx, ["tool", "help"])
1156                .expect_err("help subcommand should short-circuit through clap")
1157                .to_string();
1158        assert!(help_subcommand.contains("query"));
1159        assert!(help_subcommand.contains("status"));
1160        assert!(!help_subcommand.contains("admin"));
1161        assert!(!help_subcommand.contains("--secret"));
1162    }
1163
1164    #[test]
1165    fn agent_surface_redaction_preserves_human_mode_surface() {
1166        let _guard = env_lock();
1167        set_tokens(None, None);
1168        let ctx = detect_from_env();
1169        let spec = spec();
1170
1171        let admin = parse_with_agent_surface_from::<AgentTestCli, _>(
1172            &spec,
1173            &ctx,
1174            ["tool", "admin", "--danger"],
1175        )
1176        .expect("human mode should keep the full command tree");
1177        assert_eq!(
1178            admin,
1179            AgentDispatch::Cli(AgentTestCli {
1180                command: Some(AgentTestCommand::Admin(AdminArgs { danger: true })),
1181            })
1182        );
1183
1184        let query = parse_with_agent_surface_from::<AgentTestCli, _>(
1185            &spec,
1186            &ctx,
1187            ["tool", "query", "--secret"],
1188        )
1189        .expect("human mode should keep hidden flags available");
1190        assert_eq!(
1191            query,
1192            AgentDispatch::Cli(AgentTestCli {
1193                command: Some(AgentTestCommand::Query(QueryArgs {
1194                    limit: None,
1195                    offset: None,
1196                    secret: true,
1197                })),
1198            })
1199        );
1200    }
1201
1202    #[test]
1203    fn agent_surface_redaction_agent_flags_short_circuit() {
1204        let _guard = env_lock();
1205        set_tokens(Some("shared-token"), Some("shared-token"));
1206        let ctx = detect_from_env();
1207        let spec = spec();
1208
1209        let help =
1210            parse_with_agent_surface_from::<AgentTestCli, _>(&spec, &ctx, ["tool", "--agent-help"])
1211                .expect("agent help should print and exit");
1212        assert_eq!(help, AgentDispatch::Printed(0));
1213
1214        let skill = parse_with_agent_surface_from::<AgentTestCli, _>(
1215            &spec,
1216            &ctx,
1217            ["tool", "--agent-skill", "query-posts"],
1218        )
1219        .expect("agent skill should print and exit");
1220        assert_eq!(skill, AgentDispatch::Printed(0));
1221    }
1222
1223    #[test]
1224    fn agent_help_render_sections_are_structured_and_redacted() {
1225        let rendered = render_agent_help(&spec(), &AgentModeContext { active: true });
1226
1227        let section_positions = [
1228            rendered.find("tool:\n").expect("tool section"),
1229            rendered.find("mode:\n").expect("mode section"),
1230            rendered
1231                .find("capabilities:\n")
1232                .expect("capabilities section"),
1233            rendered.find("arguments:\n").expect("arguments section"),
1234            rendered.find("output:\n").expect("output section"),
1235            rendered
1236                .find("constraints:\n")
1237                .expect("constraints section"),
1238        ];
1239        assert!(section_positions.windows(2).all(|pair| pair[0] < pair[1]));
1240        assert!(rendered.contains("query-posts"));
1241        assert!(rendered.contains("inspect-status"));
1242        assert!(!rendered.contains("admin"));
1243        assert!(!rendered.contains("--secret"));
1244    }
1245
1246    #[test]
1247    fn agent_help_render_skill_output_is_single_capability_only() {
1248        let rendered =
1249            render_agent_skill(&spec(), &AgentModeContext { active: true }, "query-posts")
1250                .expect("capability should render");
1251
1252        let section_positions = [
1253            rendered.find("tool:\n").expect("tool section"),
1254            rendered.find("capability:\n").expect("capability section"),
1255            rendered.find("summary:\n").expect("summary section"),
1256            rendered.find("commands:\n").expect("commands section"),
1257            rendered.find("flags:\n").expect("flags section"),
1258            rendered.find("examples:\n").expect("examples section"),
1259            rendered.find("output:\n").expect("output section"),
1260            rendered
1261                .find("constraints:\n")
1262                .expect("constraints section"),
1263        ];
1264        assert!(section_positions.windows(2).all(|pair| pair[0] < pair[1]));
1265        assert!(rendered.contains("query-posts"));
1266        assert!(!rendered.contains("inspect-status"));
1267        assert!(rendered.contains("query"));
1268        assert!(rendered.contains("--limit"));
1269        assert!(rendered.contains("--offset"));
1270    }
1271
1272    #[test]
1273    fn agent_help_render_unknown_skill_is_bounded() {
1274        let error = render_agent_skill(&spec(), &AgentModeContext { active: true }, "missing")
1275            .expect_err("unknown capability should fail");
1276
1277        assert_eq!(error.to_string(), "unknown agent capability: missing");
1278        assert!(!error.to_string().contains("query-posts"));
1279        assert!(!error.to_string().contains("inspect-status"));
1280    }
1281
1282    #[test]
1283    fn agent_help_render_fills_missing_prose_metadata() {
1284        let rendered = render_agent_skill(
1285            &minimal_spec(),
1286            &AgentModeContext { active: true },
1287            "query-minimal",
1288        )
1289        .expect("minimal capability should render");
1290
1291        assert!(rendered.contains("capability:\n- query-minimal"));
1292        assert!(rendered.contains("summary:\n-"));
1293        assert!(rendered.contains("commands:\n- query"));
1294        assert!(rendered.contains("flags:\n- query --limit"));
1295        assert!(rendered.contains("examples:\n- none declared"));
1296        assert!(rendered.contains("output:\n-"));
1297        assert!(rendered.contains("constraints:\n-"));
1298    }
1299}