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, Default)]
17pub struct AgentModeContext {
18 pub active: bool,
20}
21
22impl AgentModeContext {
23 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
42pub struct ProcessEnv {
43 pub agent: AgentModeContext,
45 pub home: Option<std::path::PathBuf>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct AgentSurfaceSpec {
52 pub(crate) capabilities: &'static [AgentCapability],
54}
55
56impl AgentSurfaceSpec {
57 #[must_use]
59 pub const fn new(capabilities: &'static [AgentCapability]) -> Self {
60 Self { capabilities }
61 }
62
63 #[must_use]
65 pub const fn capabilities(&self) -> &'static [AgentCapability] {
66 self.capabilities
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct AgentCapability {
73 pub(crate) name: &'static str,
75 pub(crate) summary: Option<&'static str>,
77 pub(crate) commands: &'static [CommandSelector],
79 pub(crate) flags: &'static [FlagSelector],
81 pub(crate) examples: Option<&'static [&'static str]>,
83 pub(crate) output: Option<&'static str>,
85 pub(crate) constraints: Option<&'static str>,
87 pub(crate) when_to_use: Option<&'static str>,
89 pub(crate) when_not_to_use: Option<&'static str>,
91}
92
93impl AgentCapability {
94 #[must_use]
96 pub const fn name(&self) -> &'static str {
97 self.name
98 }
99
100 #[must_use]
102 pub const fn summary(&self) -> Option<&'static str> {
103 self.summary
104 }
105
106 #[must_use]
108 pub const fn commands(&self) -> &'static [CommandSelector] {
109 self.commands
110 }
111
112 #[must_use]
114 pub const fn flags(&self) -> &'static [FlagSelector] {
115 self.flags
116 }
117
118 #[must_use]
120 pub const fn examples(&self) -> Option<&'static [&'static str]> {
121 self.examples
122 }
123
124 #[must_use]
126 pub const fn output(&self) -> Option<&'static str> {
127 self.output
128 }
129
130 #[must_use]
132 pub const fn constraints(&self) -> Option<&'static str> {
133 self.constraints
134 }
135
136 #[must_use]
138 pub const fn when_to_use(&self) -> Option<&'static str> {
139 self.when_to_use
140 }
141
142 #[must_use]
144 pub const fn when_not_to_use(&self) -> Option<&'static str> {
145 self.when_not_to_use
146 }
147
148 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct CommandSelector {
238 pub(crate) path: &'static [&'static str],
240}
241
242impl CommandSelector {
243 #[must_use]
245 pub const fn new(path: &'static [&'static str]) -> Self {
246 Self { path }
247 }
248
249 #[must_use]
251 pub const fn path(&self) -> &'static [&'static str] {
252 self.path
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct FlagSelector {
259 pub(crate) command_path: &'static [&'static str],
261 pub(crate) long: &'static str,
263}
264
265impl FlagSelector {
266 #[must_use]
268 pub const fn new(command_path: &'static [&'static str], long: &'static str) -> Self {
269 Self { command_path, long }
270 }
271
272 #[must_use]
274 pub const fn command_path(&self) -> &'static [&'static str] {
275 self.command_path
276 }
277
278 #[must_use]
280 pub const fn long(&self) -> &'static str {
281 self.long
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum AgentDispatch<T> {
288 Cli(T),
290 Printed(i32),
292}
293
294#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
296pub enum AgentSkillError {
297 #[error("unknown agent capability: {0}")]
299 UnknownCapability(String),
300}
301
302#[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
316pub 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
557pub 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#[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
649pub 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
789use clap::{Subcommand, ValueEnum};
792use std::path::PathBuf;
793
794#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
796pub enum DescribeFormat {
797 Text,
799 Json,
801 SkillMd,
803}
804
805#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
807pub enum ListFormat {
808 Text,
810 Json,
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
816pub enum EmitTarget {
817 Claude,
819 Codex,
821}
822
823#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
825pub enum EmitScope {
826 User,
828 Project,
830}
831
832#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
834pub enum AgentSubcommand {
835 List {
837 #[arg(long, value_enum, default_value_t = ListFormat::Text)]
839 format: ListFormat,
840 },
841 Describe {
843 name: String,
845 #[arg(long, value_enum, default_value_t = DescribeFormat::Text)]
847 format: DescribeFormat,
848 },
849 EmitSkills {
851 #[arg(long, value_enum)]
853 target: EmitTarget,
854 #[arg(long, value_enum, default_value_t = EmitScope::User)]
856 scope: EmitScope,
857 #[arg(long, value_name = "DIR")]
859 out: Option<PathBuf>,
860 #[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}