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