Skip to main content

tftio_cli_common/
command.rs

1//! Shared standard CLI commands.
2
3use 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
15/// Shared doctorless adapter for tools that do not expose a doctor command.
16pub 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/// Shared metadata commands exposed by workspace binaries.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum StandardCommand {
31    /// Show version information.
32    Version {
33        /// Output format to render.
34        output: JsonOutput,
35    },
36    /// Show license text.
37    License,
38    /// Generate shell completion scripts.
39    Completions {
40        /// Shell to generate completions for.
41        shell: Shell,
42    },
43    /// Run health checks.
44    Doctor {
45        /// Output format to render.
46        output: JsonOutput,
47    },
48    /// Inspect or emit agent-skill artifacts.
49    Agent {
50        /// Agent-skill subcommand to dispatch.
51        command: AgentSubcommand,
52    },
53}
54
55/// Map crate-local metadata commands onto the shared [`StandardCommand`] surface.
56pub trait StandardCommandMap {
57    /// Convert a local metadata command into a shared command.
58    fn to_standard_command(&self, output: JsonOutput) -> StandardCommand;
59}
60
61/// Convert a crate-local metadata command into a shared [`StandardCommand`].
62#[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/// Run a mapped standard command when a tool exposes one.
71#[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/// Run a mapped standard command for a tool with no doctor support.
94#[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
114/// Parse argv against the current normal or agent-filtered surface.
115///
116/// # Errors
117///
118/// Returns a `clap` error when parsing fails.
119pub 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
132/// Parse argv and hand the typed CLI to a borrowed callback when parsing succeeds.
133///
134/// # Errors
135///
136/// Returns a `clap` error when parsing fails.
137pub 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/// Execute a shared standard command.
202#[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                // A closed stdout pipe is the only realistic failure; report it
226                // via exit code rather than panicking.
227                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/// Execute a shared standard command for a tool with no doctor support.
242#[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/// Implement [`StandardCommandMap`] for a crate-local metadata enum that uses the
255/// workspace-standard variant names.
256#[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}