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