1use clap::{CommandFactory, FromArgMatches};
4use clap_complete::Shell;
5
6use crate::{
7 AgentDispatch, AgentModeContext, DoctorChecks, JsonOutput, ProcessEnv, ToolSpec,
8 agent::AgentSubcommand, agent_skill::run_agent_subcommand, apply_agent_surface,
9 display_license, doctor::run_doctor_with_output, generate_completions_from_command,
10 parse_with_agent_surface_from,
11};
12#[cfg(test)]
13use crate::{CompletionOutput, render_completion_from_command};
14
15pub struct NoDoctor;
17
18impl DoctorChecks for NoDoctor {
19 fn repo_info() -> crate::RepoInfo {
20 crate::app::WORKSPACE_REPO
21 }
22
23 fn current_version() -> &'static str {
24 "unknown"
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum StandardCommand {
31 Version {
33 output: JsonOutput,
35 },
36 License,
38 Completions {
40 shell: Shell,
42 },
43 Doctor {
45 output: JsonOutput,
47 },
48 Agent {
50 command: AgentSubcommand,
52 },
53}
54
55pub trait StandardCommandMap {
57 fn to_standard_command(&self, output: JsonOutput) -> StandardCommand;
59}
60
61#[must_use]
63pub fn map_standard_command<C>(command: &C, output: JsonOutput) -> StandardCommand
64where
65 C: StandardCommandMap + ?Sized,
66{
67 command.to_standard_command(output)
68}
69
70#[must_use]
72#[allow(
73 clippy::single_option_map,
74 reason = "ergonomic entry point: callers pass Option<command> and get the mapped run result; pushing the map to callers would duplicate it across every binary"
75)]
76pub fn maybe_run_standard_command<T, D, C>(
77 spec: &ToolSpec,
78 env: &ProcessEnv,
79 command: Option<&C>,
80 output: JsonOutput,
81 doctor: Option<&D>,
82) -> Option<i32>
83where
84 T: CommandFactory,
85 D: DoctorChecks,
86 C: StandardCommandMap + ?Sized,
87{
88 command.map(|command| {
89 run_standard_command::<T, D>(spec, env, &map_standard_command(command, output), doctor)
90 })
91}
92
93#[must_use]
95#[allow(
96 clippy::single_option_map,
97 reason = "ergonomic entry point: callers pass Option<command> and get the mapped run result; pushing the map to callers would duplicate it across every binary"
98)]
99pub fn maybe_run_standard_command_no_doctor<T, C>(
100 spec: &ToolSpec,
101 env: &ProcessEnv,
102 command: Option<&C>,
103 output: JsonOutput,
104) -> Option<i32>
105where
106 T: CommandFactory,
107 C: StandardCommandMap + ?Sized,
108{
109 command.map(|command| {
110 run_standard_command_no_doctor::<T>(spec, env, &map_standard_command(command, output))
111 })
112}
113
114pub fn parse_command_with_agent_surface_from<T, I>(
120 spec: &ToolSpec,
121 ctx: &AgentModeContext,
122 argv: I,
123) -> Result<AgentDispatch<T>, clap::Error>
124where
125 T: CommandFactory + FromArgMatches,
126 I: IntoIterator,
127 I::Item: Into<std::ffi::OsString> + Clone,
128{
129 parse_with_agent_surface_from(spec, ctx, argv)
130}
131
132pub fn parse_command_ref_with_agent_surface_from<T, I, R, F>(
138 spec: &ToolSpec,
139 ctx: &AgentModeContext,
140 argv: I,
141 run: F,
142) -> Result<AgentDispatch<R>, clap::Error>
143where
144 T: CommandFactory + FromArgMatches,
145 I: IntoIterator,
146 I::Item: Into<std::ffi::OsString> + Clone,
147 F: FnOnce(&T) -> R,
148{
149 match parse_with_agent_surface_from(spec, ctx, argv)? {
150 AgentDispatch::Cli(cli) => Ok(AgentDispatch::Cli(run(&cli))),
151 AgentDispatch::Printed(code) => Ok(AgentDispatch::Printed(code)),
152 }
153}
154
155fn render_version(spec: &ToolSpec, output: JsonOutput) -> String {
156 if output.is_json() {
157 format!(r#"{{"version":"{}"}}"#, spec.version)
158 } else {
159 format!("{} {}", spec.bin_name, spec.version)
160 }
161}
162
163fn render_license(spec: &ToolSpec) -> String {
164 display_license(spec.bin_name, spec.license)
165}
166
167fn completion_command_for_spec<T>(spec: &ToolSpec, ctx: AgentModeContext) -> clap::Command
168where
169 T: CommandFactory,
170{
171 let mut command = T::command();
172 if ctx.active {
173 apply_agent_surface(&mut command, spec, &ctx);
174 }
175 command
176}
177
178#[cfg(test)]
179fn render_standard_completion_for_command<T>(
180 spec: &ToolSpec,
181 ctx: AgentModeContext,
182 shell: Shell,
183) -> CompletionOutput
184where
185 T: CommandFactory,
186{
187 render_completion_from_command(shell, completion_command_for_spec::<T>(spec, ctx))
188}
189
190fn generate_standard_completion_for_command<T>(
191 spec: &ToolSpec,
192 ctx: AgentModeContext,
193 shell: Shell,
194) -> std::io::Result<()>
195where
196 T: CommandFactory,
197{
198 generate_completions_from_command(shell, completion_command_for_spec::<T>(spec, ctx))
199}
200
201#[must_use]
203pub fn run_standard_command<T, D>(
204 spec: &ToolSpec,
205 env: &ProcessEnv,
206 command: &StandardCommand,
207 doctor: Option<&D>,
208) -> i32
209where
210 T: CommandFactory,
211 D: DoctorChecks,
212{
213 match command {
214 StandardCommand::Version { output } => {
215 println!("{}", render_version(spec, *output));
216 0
217 }
218 StandardCommand::License => {
219 println!("{}", render_license(spec));
220 0
221 }
222 StandardCommand::Completions { shell } => {
223 match generate_standard_completion_for_command::<T>(spec, env.agent, *shell) {
224 Ok(()) => 0,
225 Err(_) => 1,
228 }
229 }
230 StandardCommand::Doctor { output } => {
231 let Some(tool) = doctor else {
232 eprintln!("doctor support not configured");
233 return 1;
234 };
235 run_doctor_with_output(tool, *output)
236 }
237 StandardCommand::Agent { command } => run_agent_subcommand(spec, env, command),
238 }
239}
240
241#[must_use]
243pub fn run_standard_command_no_doctor<T>(
244 spec: &ToolSpec,
245 env: &ProcessEnv,
246 command: &StandardCommand,
247) -> i32
248where
249 T: CommandFactory,
250{
251 run_standard_command::<T, NoDoctor>(spec, env, command, None)
252}
253
254#[macro_export]
257macro_rules! impl_standard_command_map {
258 ($type:ty, global_json $(,)?) => {
259 impl $crate::command::StandardCommandMap for $type {
260 fn to_standard_command(&self, output: $crate::JsonOutput) -> $crate::StandardCommand {
261 match self {
262 Self::Version => $crate::StandardCommand::Version { output },
263 Self::License => $crate::StandardCommand::License,
264 Self::Completions { shell } => {
265 $crate::StandardCommand::Completions { shell: *shell }
266 }
267 }
268 }
269 }
270 };
271 ($type:ty, global_json, doctor $(,)?) => {
272 impl $crate::command::StandardCommandMap for $type {
273 fn to_standard_command(&self, output: $crate::JsonOutput) -> $crate::StandardCommand {
274 match self {
275 Self::Version => $crate::StandardCommand::Version { output },
276 Self::License => $crate::StandardCommand::License,
277 Self::Completions { shell } => {
278 $crate::StandardCommand::Completions { shell: *shell }
279 }
280 Self::Doctor => $crate::StandardCommand::Doctor { output },
281 }
282 }
283 }
284 };
285 ($type:ty, field_json $(,)?) => {
286 impl $crate::command::StandardCommandMap for $type {
287 fn to_standard_command(&self, _output: $crate::JsonOutput) -> $crate::StandardCommand {
288 match self {
289 Self::Version { json } => $crate::StandardCommand::Version {
290 output: $crate::JsonOutput::from_flag(*json),
291 },
292 Self::License => $crate::StandardCommand::License,
293 Self::Completions { shell } => {
294 $crate::StandardCommand::Completions { shell: *shell }
295 }
296 }
297 }
298 }
299 };
300 ($type:ty, field_json, doctor $(,)?) => {
301 impl $crate::command::StandardCommandMap for $type {
302 fn to_standard_command(&self, _output: $crate::JsonOutput) -> $crate::StandardCommand {
303 match self {
304 Self::Version { json } => $crate::StandardCommand::Version {
305 output: $crate::JsonOutput::from_flag(*json),
306 },
307 Self::License => $crate::StandardCommand::License,
308 Self::Completions { shell } => {
309 $crate::StandardCommand::Completions { shell: *shell }
310 }
311 Self::Doctor { json } => $crate::StandardCommand::Doctor {
312 output: $crate::JsonOutput::from_flag(*json),
313 },
314 }
315 }
316 }
317 };
318 ($type:ty, fixed_json = $json:expr $(,)?) => {
319 impl $crate::command::StandardCommandMap for $type {
320 fn to_standard_command(&self, _output: $crate::JsonOutput) -> $crate::StandardCommand {
321 match self {
322 Self::Version => $crate::StandardCommand::Version {
323 output: $crate::JsonOutput::from_flag($json),
324 },
325 Self::License => $crate::StandardCommand::License,
326 Self::Completions { shell } => {
327 $crate::StandardCommand::Completions { shell: *shell }
328 }
329 }
330 }
331 }
332 };
333 ($type:ty, fixed_json = $json:expr, doctor $(,)?) => {
334 impl $crate::command::StandardCommandMap for $type {
335 fn to_standard_command(&self, _output: $crate::JsonOutput) -> $crate::StandardCommand {
336 match self {
337 Self::Version => $crate::StandardCommand::Version {
338 output: $crate::JsonOutput::from_flag($json),
339 },
340 Self::License => $crate::StandardCommand::License,
341 Self::Completions { shell } => {
342 $crate::StandardCommand::Completions { shell: *shell }
343 }
344 Self::Doctor => $crate::StandardCommand::Doctor {
345 output: $crate::JsonOutput::from_flag($json),
346 },
347 }
348 }
349 }
350 };
351}
352
353#[cfg(test)]
354mod tests {
355 use clap::{Parser, Subcommand};
356
357 use super::*;
358 use crate::{
359 AGENT_TOKEN_ENV, AGENT_TOKEN_EXPECTED_ENV, AgentCapability, AgentDispatch,
360 AgentSurfaceSpec, CommandSelector, FlagSelector, LicenseType, RepoInfo, ToolContract,
361 test_support::env_lock, workspace_tool,
362 };
363
364 const QUERY_COMMAND: CommandSelector = CommandSelector::new(&["query"]);
365 const QUERY_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["query"], "limit");
366 const QUERY_CAPABILITY: AgentCapability = AgentCapability::new(
367 "query-posts",
368 "Read paginated post records",
369 &[QUERY_COMMAND],
370 &[QUERY_LIMIT_FLAG],
371 );
372 const AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[QUERY_CAPABILITY]);
373
374 #[derive(Parser)]
375 struct TestCli;
376
377 #[derive(Debug, Parser, PartialEq, Eq)]
378 #[command(name = "tool")]
379 struct ParseTestCli {
380 #[command(subcommand)]
381 command: ParseTestCommand,
382 }
383
384 #[derive(Debug, Subcommand, PartialEq, Eq)]
385 enum ParseTestCommand {
386 Query {
387 #[arg(long)]
388 limit: u32,
389 },
390 Admin,
391 }
392
393 #[derive(Debug, Parser, PartialEq, Eq)]
394 #[command(name = "tool")]
395 struct CompletionTestCli {
396 #[command(subcommand)]
397 command: CompletionTestCommand,
398 }
399
400 #[derive(Debug, Subcommand, PartialEq, Eq)]
401 enum CompletionTestCommand {
402 Query {
403 #[arg(long)]
404 limit: Option<u32>,
405 #[arg(long)]
406 secret: bool,
407 },
408 Admin,
409 }
410
411 struct TestDoctor;
412
413 impl DoctorChecks for TestDoctor {
414 fn repo_info() -> RepoInfo {
415 RepoInfo::new("owner", "doctor-tool")
416 }
417
418 fn current_version() -> &'static str {
419 "1.0.0"
420 }
421 }
422
423 fn spec() -> ToolSpec {
424 ToolSpec::new(
425 "tool",
426 "Tool",
427 "1.2.3",
428 LicenseType::MIT,
429 RepoInfo::new("owner", "repo"),
430 true,
431 true,
432 )
433 }
434
435 fn agent_spec() -> ToolSpec {
436 workspace_tool("tool", "Tool", "1.2.3", LicenseType::MIT, true, true)
437 .with_agent_surface(&AGENT_SURFACE)
438 }
439
440 #[allow(unsafe_code)]
441 fn set_tokens(presented: Option<&str>, expected: Option<&str>) {
442 unsafe {
443 std::env::remove_var(AGENT_TOKEN_ENV);
444 std::env::remove_var(AGENT_TOKEN_EXPECTED_ENV);
445 if let Some(presented) = presented {
446 std::env::set_var(AGENT_TOKEN_ENV, presented);
447 }
448 if let Some(expected) = expected {
449 std::env::set_var(AGENT_TOKEN_EXPECTED_ENV, expected);
450 }
451 }
452 }
453
454 fn detect_from_env() -> AgentModeContext {
455 AgentModeContext::from_tokens(
456 std::env::var(AGENT_TOKEN_ENV).ok(),
457 std::env::var(AGENT_TOKEN_EXPECTED_ENV).ok(),
458 )
459 }
460
461 fn env_from_detected() -> ProcessEnv {
462 ProcessEnv {
463 agent: detect_from_env(),
464 home: None,
465 }
466 }
467
468 fn inactive_env() -> ProcessEnv {
469 ProcessEnv::default()
470 }
471
472 #[test]
473 fn version_json_contains_version_key() {
474 let rendered = render_version(&spec(), JsonOutput::Json);
475 assert!(rendered.contains("\"version\""));
476 }
477
478 #[test]
479 fn spec_with_all_capabilities_is_marked_authoritative() {
480 assert_eq!(agent_spec().contract, ToolContract::CliCommonBase);
481 assert!(agent_spec().has_authoritative_contract());
482 }
483
484 #[test]
485 fn license_render_uses_display_license_text() {
486 let rendered = render_license(&spec());
487 assert!(rendered.contains("MIT License"));
488 }
489
490 #[test]
491 fn run_standard_command_version_returns_success() {
492 let exit_code = run_standard_command::<TestCli, TestDoctor>(
493 &spec(),
494 &inactive_env(),
495 &StandardCommand::Version {
496 output: JsonOutput::Text,
497 },
498 Some(&TestDoctor),
499 );
500 assert_eq!(exit_code, 0);
501 }
502
503 #[test]
504 fn run_standard_command_no_doctor_version_returns_success() {
505 let exit_code = run_standard_command_no_doctor::<TestCli>(
506 &spec(),
507 &inactive_env(),
508 &StandardCommand::Version {
509 output: JsonOutput::Json,
510 },
511 );
512 assert_eq!(exit_code, 0);
513 }
514
515 #[allow(dead_code)]
516 #[derive(Debug, Clone, PartialEq, Eq)]
517 enum GlobalJsonMetaCommand {
518 Version,
519 License,
520 Completions { shell: Shell },
521 }
522
523 impl_standard_command_map!(GlobalJsonMetaCommand, global_json);
524
525 #[allow(dead_code)]
526 #[derive(Debug, Clone, PartialEq, Eq)]
527 enum FixedJsonMetaCommand {
528 Version,
529 License,
530 Completions { shell: Shell },
531 Doctor,
532 }
533
534 impl_standard_command_map!(FixedJsonMetaCommand, fixed_json = false, doctor);
535
536 #[allow(dead_code)]
537 #[derive(Debug, Clone, PartialEq, Eq)]
538 enum VersionFieldMetaCommand {
539 Version { json: bool },
540 License,
541 Completions { shell: Shell },
542 }
543
544 impl_standard_command_map!(VersionFieldMetaCommand, field_json);
545
546 #[test]
547 fn impl_standard_command_map_uses_global_json_flag() {
548 let command = map_standard_command(&GlobalJsonMetaCommand::Version, JsonOutput::Json);
549 assert_eq!(
550 command,
551 StandardCommand::Version {
552 output: JsonOutput::Json
553 }
554 );
555 }
556
557 #[test]
558 fn impl_standard_command_map_supports_fixed_json_and_doctor_variants() {
559 let command = map_standard_command(&FixedJsonMetaCommand::Doctor, JsonOutput::Json);
560 assert_eq!(
561 command,
562 StandardCommand::Doctor {
563 output: JsonOutput::Text
564 }
565 );
566 }
567
568 #[allow(dead_code)]
569 #[derive(Debug, Clone, PartialEq, Eq)]
570 enum FieldJsonDoctorMetaCommand {
571 Version { json: bool },
572 License,
573 Completions { shell: Shell },
574 Doctor { json: bool },
575 }
576
577 impl_standard_command_map!(FieldJsonDoctorMetaCommand, field_json, doctor);
578
579 #[test]
580 fn impl_standard_command_map_reads_json_from_version_field() {
581 let command = map_standard_command(
582 &VersionFieldMetaCommand::Version { json: true },
583 JsonOutput::Text,
584 );
585 assert_eq!(
586 command,
587 StandardCommand::Version {
588 output: JsonOutput::Json
589 }
590 );
591 }
592
593 #[test]
594 fn impl_standard_command_map_reads_json_from_doctor_field() {
595 let command = map_standard_command(
596 &FieldJsonDoctorMetaCommand::Doctor { json: true },
597 JsonOutput::Text,
598 );
599 assert_eq!(
600 command,
601 StandardCommand::Doctor {
602 output: JsonOutput::Json
603 }
604 );
605 }
606
607 #[test]
608 fn maybe_run_standard_command_no_doctor_executes_mapped_metadata_command() {
609 let exit_code = maybe_run_standard_command_no_doctor::<TestCli, _>(
610 &spec(),
611 &inactive_env(),
612 Some(&GlobalJsonMetaCommand::License),
613 JsonOutput::Text,
614 );
615 assert_eq!(exit_code, Some(0));
616 }
617
618 #[test]
619 fn maybe_run_standard_command_returns_none_without_metadata_command() {
620 let exit_code = maybe_run_standard_command_no_doctor::<TestCli, GlobalJsonMetaCommand>(
621 &spec(),
622 &inactive_env(),
623 None,
624 JsonOutput::Text,
625 );
626 assert_eq!(exit_code, None);
627 }
628
629 #[test]
630 fn parse_command_with_agent_surface_from_returns_owned_cli() {
631 let _guard = env_lock();
632 set_tokens(None, None);
633 let ctx = detect_from_env();
634
635 let parsed = parse_command_with_agent_surface_from::<ParseTestCli, _>(
636 &agent_spec(),
637 &ctx,
638 ["tool", "query", "--limit", "5"],
639 )
640 .expect("parse should succeed");
641
642 assert_eq!(
643 parsed,
644 AgentDispatch::Cli(ParseTestCli {
645 command: ParseTestCommand::Query { limit: 5 },
646 })
647 );
648 }
649
650 #[test]
651 fn parse_command_ref_with_agent_surface_from_borrows_cli() {
652 let _guard = env_lock();
653 set_tokens(Some("shared-token"), Some("shared-token"));
654 let ctx = detect_from_env();
655
656 let parsed = parse_command_ref_with_agent_surface_from::<ParseTestCli, _, _, _>(
657 &agent_spec(),
658 &ctx,
659 ["tool", "query", "--limit", "7"],
660 |cli| match cli.command {
661 ParseTestCommand::Query { limit } => limit,
662 ParseTestCommand::Admin => 0,
663 },
664 )
665 .expect("parse should succeed");
666
667 assert_eq!(parsed, AgentDispatch::Cli(7));
668 }
669
670 #[test]
671 fn agent_surface_redaction_completion_metadata_path_omits_hidden_entries() {
672 let _guard = env_lock();
673 set_tokens(Some("shared-token"), Some("shared-token"));
674 let env = env_from_detected();
675
676 let output = render_standard_completion_for_command::<CompletionTestCli>(
677 &agent_spec(),
678 env.agent,
679 Shell::Bash,
680 );
681
682 assert!(output.script.contains("query"));
683 assert!(!output.script.contains("admin"));
684 assert!(!output.script.contains("--secret"));
685 }
686}