1use std::{collections::BTreeSet, ffi::OsString};
4
5use clap::{Arg, ArgAction, Command, CommandFactory, FromArgMatches, error::ErrorKind};
6
7use crate::ToolSpec;
8
9pub const AGENT_TOKEN_ENV: &str = "TFTIO_AGENT_TOKEN";
11
12pub const AGENT_TOKEN_EXPECTED_ENV: &str = "TFTIO_AGENT_TOKEN_EXPECTED";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct AgentModeContext {
18 pub active: bool,
20}
21
22impl AgentModeContext {
23 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct AgentSurfaceSpec {
38 pub capabilities: &'static [AgentCapability],
40}
41
42impl AgentSurfaceSpec {
43 #[must_use]
45 pub const fn new(capabilities: &'static [AgentCapability]) -> Self {
46 Self { capabilities }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct AgentCapability {
53 pub name: &'static str,
55 pub summary: Option<&'static str>,
57 pub commands: &'static [CommandSelector],
59 pub flags: &'static [FlagSelector],
61 pub examples: Option<&'static [&'static str]>,
63 pub output: Option<&'static str>,
65 pub constraints: Option<&'static str>,
67}
68
69impl AgentCapability {
70 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct CommandSelector {
138 pub path: &'static [&'static str],
140}
141
142impl CommandSelector {
143 #[must_use]
145 pub const fn new(path: &'static [&'static str]) -> Self {
146 Self { path }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct FlagSelector {
153 pub command_path: &'static [&'static str],
155 pub long: &'static str,
157}
158
159impl FlagSelector {
160 #[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#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum AgentDispatch<T> {
170 Cli(T),
172 Printed(i32),
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct AgentSkillError {
179 message: String,
180}
181
182impl AgentSkillError {
183 #[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#[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
214pub 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
454pub 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
506pub 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#[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
558pub 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}