Skip to main content

greentic_component/
cli.rs

1use std::ffi::OsString;
2
3use anyhow::{Error, Result, bail};
4use clap::{Arg, ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand};
5
6#[cfg(feature = "store")]
7use crate::cmd::store::StoreCommand;
8use crate::cmd::{
9    self, build::BuildArgs, doctor::DoctorArgs, flow::FlowCommand, hash::HashArgs,
10    inspect::InspectArgs, new::NewArgs, templates::TemplatesArgs, test::TestArgs,
11    wizard::WizardCliArgs,
12};
13use crate::scaffold::engine::ScaffoldEngine;
14
15#[derive(Parser, Debug)]
16#[command(
17    name = "greentic-component",
18    about = "Toolkit for Greentic component developers",
19    version,
20    arg_required_else_help = true
21)]
22pub struct Cli {
23    #[arg(long = "locale", value_name = "LOCALE", global = true)]
24    locale: Option<String>,
25
26    #[command(subcommand)]
27    command: Commands,
28}
29
30#[derive(Subcommand, Debug)]
31enum Commands {
32    /// Scaffold a new Greentic component project
33    New(Box<NewArgs>),
34    /// Component wizard helpers
35    Wizard(Box<WizardCliArgs>),
36    /// List available component templates
37    Templates(TemplatesArgs),
38    /// Run component doctor checks
39    Doctor(DoctorArgs),
40    /// Inspect manifests and describe payloads
41    Inspect(InspectArgs),
42    /// Recompute manifest hashes
43    Hash(HashArgs),
44    /// Build component wasm + update config flows
45    Build(BuildArgs),
46    /// Invoke a component locally with an in-memory state/secrets harness
47    #[command(
48        long_about = "Invoke a component locally with in-memory state/secrets. \
49See docs/component-developer-guide.md for a walkthrough."
50    )]
51    Test(Box<TestArgs>),
52    /// Flow utilities (config flow regeneration)
53    #[command(subcommand)]
54    Flow(FlowCommand),
55    /// Interact with the component store
56    #[cfg(feature = "store")]
57    #[command(subcommand)]
58    Store(StoreCommand),
59}
60
61pub fn main() -> Result<()> {
62    let argv: Vec<OsString> = std::env::args_os().collect();
63    cmd::i18n::init(cmd::i18n::cli_locale_from_argv(&argv));
64
65    let mut command = localize_help(Cli::command(), true);
66    let matches = match command.try_get_matches_from_mut(argv) {
67        Ok(matches) => matches,
68        Err(err) => err.exit(),
69    };
70    if let Some(result) = cmd::wizard::maybe_run_schema_from_matches(&matches) {
71        return result;
72    }
73    let cli = Cli::from_arg_matches(&matches).map_err(|err| Error::msg(err.to_string()))?;
74    cmd::i18n::init(cli.locale.clone());
75    let engine = ScaffoldEngine::new();
76    match cli.command {
77        Commands::New(args) => cmd::new::run(*args, &engine),
78        Commands::Wizard(command) => cmd::wizard::run_cli(*command),
79        Commands::Templates(args) => cmd::templates::run(args, &engine),
80        Commands::Doctor(args) => cmd::doctor::run(args).map_err(Error::new),
81        Commands::Inspect(args) => {
82            let result = cmd::inspect::run(&args)?;
83            cmd::inspect::emit_warnings(&result.warnings);
84            if args.strict && !result.warnings.is_empty() {
85                bail!(
86                    "component-inspect: {} warning(s) treated as errors (--strict)",
87                    result.warnings.len()
88                );
89            }
90            Ok(())
91        }
92        Commands::Hash(args) => cmd::hash::run(args),
93        Commands::Build(args) => cmd::build::run(args),
94        Commands::Test(args) => cmd::test::run(*args),
95        Commands::Flow(flow_cmd) => cmd::flow::run(flow_cmd),
96        #[cfg(feature = "store")]
97        Commands::Store(store_cmd) => cmd::store::run(store_cmd),
98    }
99}
100
101fn localize_help(mut command: clap::Command, is_root: bool) -> clap::Command {
102    if let Some(about) = command.get_about().map(|s| s.to_string()) {
103        command = command.about(cmd::i18n::tr_lit(&about));
104    }
105    if let Some(long_about) = command.get_long_about().map(|s| s.to_string()) {
106        command = command.long_about(cmd::i18n::tr_lit(&long_about));
107    }
108    if let Some(before) = command.get_before_help().map(|s| s.to_string()) {
109        command = command.before_help(cmd::i18n::tr_lit(&before));
110    }
111    if let Some(after) = command.get_after_help().map(|s| s.to_string()) {
112        command = command.after_help(cmd::i18n::tr_lit(&after));
113    }
114
115    command = command
116        .disable_help_subcommand(true)
117        .disable_help_flag(true)
118        .arg(
119            Arg::new("help")
120                .short('h')
121                .long("help")
122                .action(ArgAction::Help)
123                .help(cmd::i18n::tr_lit("Print help")),
124        );
125    if is_root {
126        command = command.disable_version_flag(true).arg(
127            Arg::new("version")
128                .short('V')
129                .long("version")
130                .action(ArgAction::Version)
131                .help(cmd::i18n::tr_lit("Print version")),
132        );
133    }
134
135    let arg_ids = command
136        .get_arguments()
137        .map(|arg| arg.get_id().clone())
138        .collect::<Vec<_>>();
139    for arg_id in arg_ids {
140        command = command.mut_arg(arg_id, |arg| {
141            let mut arg = arg;
142            if let Some(help) = arg.get_help().map(ToString::to_string) {
143                arg = arg.help(cmd::i18n::tr_lit(&help));
144            }
145            if let Some(long_help) = arg.get_long_help().map(ToString::to_string) {
146                arg = arg.long_help(cmd::i18n::tr_lit(&long_help));
147            }
148            arg
149        });
150    }
151
152    let sub_names = command
153        .get_subcommands()
154        .map(|sub| sub.get_name().to_string())
155        .collect::<Vec<_>>();
156    for name in sub_names {
157        if name == "wizard" {
158            command = command.mut_subcommand(name.clone(), |sub| {
159                sub.arg(
160                    Arg::new("schema")
161                        .long("schema")
162                        .action(ArgAction::SetTrue)
163                        .help(cmd::i18n::tr_lit(
164                            "Print the current answers.json schema and exit",
165                        ))
166                        .long_help(cmd::i18n::tr_lit(
167                            "Print the current answers.json schema and exit.\n\nAgentic coding tools such as Codex and Claude should call this first to fetch the current answer schema, fill out answers.json, and replay the wizard non-interactively.",
168                        )),
169                )
170            });
171        }
172        command = command.mut_subcommand(name, |sub| localize_help(sub, false));
173    }
174    command
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn parses_new_subcommand() {
183        let cli = Cli::try_parse_from([
184            "greentic-component",
185            "--locale",
186            "nl",
187            "new",
188            "--name",
189            "demo",
190            "--json",
191        ])
192        .expect("expected CLI to parse");
193        assert_eq!(cli.locale.as_deref(), Some("nl"));
194        match cli.command {
195            Commands::New(args) => {
196                assert_eq!(args.name, "demo");
197                assert!(args.json);
198                assert!(!args.no_check);
199                assert!(!args.no_git);
200                assert!(args.operation_names.is_empty());
201                assert_eq!(args.default_operation, None);
202            }
203            _ => panic!("expected new args"),
204        }
205    }
206
207    #[test]
208    fn parses_new_operation_flags() {
209        let cli = Cli::try_parse_from([
210            "greentic-component",
211            "new",
212            "--name",
213            "demo",
214            "--operation",
215            "render,sync-state",
216            "--default-operation",
217            "sync-state",
218        ])
219        .expect("expected CLI to parse");
220        match cli.command {
221            Commands::New(args) => {
222                assert_eq!(args.operation_names, vec!["render", "sync-state"]);
223                assert_eq!(args.default_operation.as_deref(), Some("sync-state"));
224            }
225            _ => panic!("expected new args"),
226        }
227    }
228
229    #[test]
230    fn parses_wizard_command() {
231        let cli = Cli::try_parse_from([
232            "greentic-component",
233            "wizard",
234            "--mode",
235            "doctor",
236            "--execution",
237            "dry-run",
238            "--locale",
239            "ar",
240        ])
241        .expect("expected CLI to parse");
242        assert_eq!(cli.locale.as_deref(), Some("ar"));
243        match cli.command {
244            Commands::Wizard(args) => {
245                assert!(matches!(
246                    args.args.mode,
247                    crate::cmd::wizard::RunMode::Doctor
248                ));
249                assert!(matches!(
250                    args.args.execution,
251                    crate::cmd::wizard::ExecutionMode::DryRun
252                ));
253            }
254            _ => panic!("expected wizard args"),
255        }
256    }
257
258    #[test]
259    fn parses_wizard_legacy_new_command() {
260        let cli = Cli::try_parse_from([
261            "greentic-component",
262            "wizard",
263            "new",
264            "wizard-smoke",
265            "--out",
266            "/tmp",
267        ])
268        .expect("expected CLI to parse");
269        match cli.command {
270            Commands::Wizard(args) => match args.command {
271                Some(crate::cmd::wizard::WizardSubcommand::New(new_args)) => {
272                    assert_eq!(new_args.name.as_deref(), Some("wizard-smoke"));
273                    assert_eq!(new_args.out.as_deref(), Some(std::path::Path::new("/tmp")));
274                }
275                _ => panic!("expected wizard new subcommand"),
276            },
277            _ => panic!("expected wizard args"),
278        }
279    }
280
281    #[test]
282    fn parses_wizard_validate_command_alias() {
283        let cli = Cli::try_parse_from([
284            "greentic-component",
285            "wizard",
286            "validate",
287            "--mode",
288            "create",
289        ])
290        .expect("expected CLI to parse");
291        match cli.command {
292            Commands::Wizard(args) => assert!(matches!(
293                args.command,
294                Some(crate::cmd::wizard::WizardSubcommand::Validate(_))
295            )),
296            _ => panic!("expected wizard args"),
297        }
298    }
299
300    #[test]
301    fn parses_wizard_validate_flag() {
302        let cli = Cli::try_parse_from([
303            "greentic-component",
304            "wizard",
305            "--validate",
306            "--mode",
307            "doctor",
308        ])
309        .expect("expected CLI to parse");
310        match cli.command {
311            Commands::Wizard(args) => {
312                assert!(args.args.validate);
313                assert!(!args.args.apply);
314                assert!(matches!(
315                    args.args.mode,
316                    crate::cmd::wizard::RunMode::Doctor
317                ));
318            }
319            _ => panic!("expected wizard args"),
320        }
321    }
322
323    #[test]
324    fn parses_wizard_answers_aliases() {
325        let cli = Cli::try_parse_from([
326            "greentic-component",
327            "wizard",
328            "--answers",
329            "in.json",
330            "--emit-answers",
331            "out.json",
332            "--schema-version",
333            "1.2.3",
334            "--migrate",
335        ])
336        .expect("expected CLI to parse");
337        match cli.command {
338            Commands::Wizard(args) => {
339                assert_eq!(
340                    args.args.answers.as_deref(),
341                    Some(std::path::Path::new("in.json"))
342                );
343                assert_eq!(
344                    args.args.emit_answers.as_deref(),
345                    Some(std::path::Path::new("out.json"))
346                );
347                assert_eq!(args.args.schema_version.as_deref(), Some("1.2.3"));
348                assert!(args.args.migrate);
349            }
350            _ => panic!("expected wizard args"),
351        }
352    }
353
354    #[cfg(feature = "store")]
355    #[test]
356    fn parses_store_fetch_command() {
357        let cli = Cli::try_parse_from([
358            "greentic-component",
359            "--locale",
360            "nl",
361            "store",
362            "fetch",
363            "--out",
364            "/tmp/out",
365            "file:///tmp/component.wasm",
366        ])
367        .expect("expected CLI to parse");
368        assert_eq!(cli.locale.as_deref(), Some("nl"));
369        match cli.command {
370            Commands::Store(crate::cmd::store::StoreCommand::Fetch(args)) => {
371                assert_eq!(args.out, std::path::PathBuf::from("/tmp/out"));
372                assert_eq!(args.source, "file:///tmp/component.wasm");
373            }
374            _ => panic!("expected store fetch args"),
375        }
376    }
377}