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 New(Box<NewArgs>),
34 Wizard(Box<WizardCliArgs>),
36 Templates(TemplatesArgs),
38 Doctor(DoctorArgs),
40 Inspect(InspectArgs),
42 Hash(HashArgs),
44 Build(BuildArgs),
46 #[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 #[command(subcommand)]
54 Flow(FlowCommand),
55 #[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}