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