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