1use clap::{Arg, ArgAction, ArgMatches, Command};
4
5use super::catalog::normalize_command_path;
6use crate::contracts::{
7 canonical_bijux_tool_namespace, known_bijux_tool_namespaces, ColorMode, LogLevel, OutputFormat,
8 PrettyMode,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ParsedGlobalFlags {
14 pub output_format: Option<OutputFormat>,
16 pub pretty_mode: Option<PrettyMode>,
18 pub color_mode: Option<ColorMode>,
20 pub log_level: Option<LogLevel>,
22 pub quiet: bool,
24 pub config_path: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ParsedIntent {
31 pub command_path: Vec<String>,
33 pub normalized_path: Vec<String>,
35 pub global_flags: ParsedGlobalFlags,
37}
38
39#[derive(Debug, thiserror::Error, PartialEq, Eq)]
41pub enum ParseError {
42 #[error("invalid format: {0}")]
44 InvalidFormat(String),
45 #[error("invalid color mode: {0}")]
47 InvalidColor(String),
48 #[error("invalid log level: {0}")]
50 InvalidLogLevel(String),
51}
52
53fn parse_output_format(raw: Option<&String>) -> Result<Option<OutputFormat>, ParseError> {
54 raw.map(|v| match v.as_str() {
55 "json" => Ok(OutputFormat::Json),
56 "jsonl" => Ok(OutputFormat::Jsonl),
57 "yaml" => Ok(OutputFormat::Yaml),
58 "text" => Ok(OutputFormat::Text),
59 other => Err(ParseError::InvalidFormat(other.to_string())),
60 })
61 .transpose()
62}
63
64fn parse_color(raw: Option<&String>) -> Result<Option<ColorMode>, ParseError> {
65 raw.map(|v| match v.as_str() {
66 "auto" => Ok(ColorMode::Auto),
67 "always" => Ok(ColorMode::Always),
68 "never" => Ok(ColorMode::Never),
69 other => Err(ParseError::InvalidColor(other.to_string())),
70 })
71 .transpose()
72}
73
74fn parse_log_level(raw: Option<&String>) -> Result<Option<LogLevel>, ParseError> {
75 raw.map(|v| match v.as_str() {
76 "trace" => Ok(LogLevel::Trace),
77 "debug" => Ok(LogLevel::Debug),
78 "info" => Ok(LogLevel::Info),
79 "warning" => Ok(LogLevel::Warning),
80 "error" => Ok(LogLevel::Error),
81 "critical" => Ok(LogLevel::Critical),
82 other => Err(ParseError::InvalidLogLevel(other.to_string())),
83 })
84 .transpose()
85}
86
87fn is_global_flag_without_value(token: &str) -> bool {
88 matches!(token, "--quiet" | "-q" | "--pretty" | "--no-pretty" | "--json" | "--text")
89}
90
91fn is_global_flag_with_value(token: &str) -> bool {
92 matches!(token, "--format" | "-f" | "--log-level" | "--color" | "--config-path")
93}
94
95fn is_global_flag_with_equals(token: &str) -> bool {
96 token.starts_with("--format=")
97 || token.starts_with("--log-level=")
98 || token.starts_with("--color=")
99 || token.starts_with("--config-path=")
100}
101
102fn parse_argv_with_global_flags_front(argv: &[String]) -> Vec<String> {
103 if argv.is_empty() {
104 return Vec::new();
105 }
106
107 let mut globals = Vec::new();
108 let mut command_tail = Vec::new();
109 let mut idx = 1;
110
111 while idx < argv.len() {
112 let token = argv[idx].as_str();
113 if token == "--" {
114 command_tail.extend(argv.iter().skip(idx).cloned());
115 break;
116 }
117 if is_global_flag_without_value(token) || is_global_flag_with_equals(token) {
118 globals.push(argv[idx].clone());
119 idx += 1;
120 continue;
121 }
122 if is_global_flag_with_value(token) {
123 globals.push(argv[idx].clone());
124 if let Some(value) = argv.get(idx + 1) {
125 globals.push(value.clone());
126 idx += 2;
127 } else {
128 idx += 1;
129 }
130 continue;
131 }
132
133 command_tail.push(argv[idx].clone());
134 idx += 1;
135 }
136
137 let mut normalized = Vec::with_capacity(1 + globals.len() + command_tail.len());
138 normalized.push(argv[0].clone());
139 normalized.extend(globals);
140 normalized.extend(command_tail);
141 normalized
142}
143
144fn global_flags_from_matches(matches: &ArgMatches) -> Result<ParsedGlobalFlags, ParseError> {
145 let output_format = if matches.get_flag("json") {
146 Some(OutputFormat::Json)
147 } else if matches.get_flag("text") {
148 Some(OutputFormat::Text)
149 } else {
150 parse_output_format(matches.get_one::<String>("format"))?
151 };
152 let color_mode = parse_color(matches.get_one::<String>("color"))?;
153 let log_level = parse_log_level(matches.get_one::<String>("log-level"))?;
154
155 let pretty_mode = if matches.get_flag("pretty") {
156 Some(PrettyMode::Pretty)
157 } else if matches.get_flag("no-pretty") {
158 Some(PrettyMode::Compact)
159 } else {
160 None
161 };
162
163 Ok(ParsedGlobalFlags {
164 output_format,
165 pretty_mode,
166 color_mode,
167 log_level,
168 quiet: matches.get_flag("quiet"),
169 config_path: matches.get_one::<String>("config-path").cloned(),
170 })
171}
172
173#[must_use]
175#[allow(clippy::too_many_lines)]
176pub fn root_command() -> Command {
177 let format_arg = Arg::new("format")
178 .long("format")
179 .short('f')
180 .num_args(1)
181 .global(true)
182 .value_name("FORMAT")
183 .help("Output format: text, json, jsonl, or yaml");
184
185 let quiet_arg = Arg::new("quiet")
186 .long("quiet")
187 .short('q')
188 .action(ArgAction::SetTrue)
189 .global(true)
190 .help("Suppress command output");
191
192 let log_level_arg = Arg::new("log-level")
193 .long("log-level")
194 .num_args(1)
195 .global(true)
196 .value_name("LEVEL")
197 .help("Log verbosity level");
198
199 let color_arg = Arg::new("color")
200 .long("color")
201 .num_args(1)
202 .global(true)
203 .value_name("MODE")
204 .help("ANSI color policy");
205
206 let pretty_arg = Arg::new("pretty")
207 .long("pretty")
208 .action(ArgAction::SetTrue)
209 .overrides_with("no-pretty")
210 .global(true)
211 .help("Pretty-print structured output");
212
213 let no_pretty_arg = Arg::new("no-pretty")
214 .long("no-pretty")
215 .action(ArgAction::SetTrue)
216 .overrides_with("pretty")
217 .global(true)
218 .help("Emit compact structured output");
219 let config_path_arg = Arg::new("config-path")
220 .long("config-path")
221 .num_args(1)
222 .global(true)
223 .value_name("PATH")
224 .help("Use explicit config file path");
225 let json_arg = Arg::new("json")
226 .long("json")
227 .action(ArgAction::SetTrue)
228 .overrides_with_all(["text", "format"])
229 .hide(true)
230 .global(true);
231 let text_arg = Arg::new("text")
232 .long("text")
233 .action(ArgAction::SetTrue)
234 .overrides_with_all(["json", "format"])
235 .hide(true)
236 .global(true);
237
238 let profile_arg = || Arg::new("profile").long("profile").num_args(1).value_name("PROFILE");
239 let portable_arg = || {
240 Arg::new("portable")
241 .long("portable")
242 .action(ArgAction::SetTrue)
243 .help("Use the portable config bundle format")
244 };
245 let include_secrets_arg = || {
246 Arg::new("include-secrets")
247 .long("include-secrets")
248 .action(ArgAction::SetTrue)
249 .help("Include secret values in output")
250 };
251 let override_arg = || {
252 Arg::new("override")
253 .long("override")
254 .num_args(1)
255 .action(ArgAction::Append)
256 .value_name("KEY=VALUE")
257 .help("Apply one highest-precedence config override (repeatable)")
258 };
259
260 let config_group = Command::new("config")
261 .subcommand_required(false)
262 .subcommand(Command::new("list"))
263 .subcommand(Command::new("get").arg(Arg::new("key").num_args(1)))
264 .subcommand(Command::new("set").arg(Arg::new("pair").num_args(1)))
265 .subcommand(Command::new("unset").arg(Arg::new("key").num_args(1)))
266 .subcommand(Command::new("clear"))
267 .subcommand(Command::new("reload"))
268 .subcommand(Command::new("validate").arg(profile_arg()).arg(override_arg()))
269 .subcommand(Command::new("schema").arg(Arg::new("scope").num_args(1)))
270 .subcommand(Command::new("docs").arg(Arg::new("scope").num_args(1)))
271 .subcommand(
272 Command::new("explain")
273 .arg(Arg::new("key").num_args(1))
274 .arg(profile_arg())
275 .arg(override_arg())
276 .arg(include_secrets_arg()),
277 )
278 .subcommand(
279 Command::new("diff")
280 .arg(Arg::new("key").num_args(1))
281 .arg(
282 Arg::new("from-profile").long("from-profile").num_args(1).value_name("PROFILE"),
283 )
284 .arg(Arg::new("to-profile").long("to-profile").num_args(1).value_name("PROFILE"))
285 .arg(override_arg())
286 .arg(include_secrets_arg()),
287 )
288 .subcommand(Command::new("repair"))
289 .subcommand(
290 Command::new("export")
291 .arg(Arg::new("path").num_args(1))
292 .arg(profile_arg())
293 .arg(portable_arg())
294 .arg(include_secrets_arg()),
295 )
296 .subcommand(
297 Command::new("load")
298 .arg(Arg::new("path").num_args(1))
299 .arg(profile_arg())
300 .arg(portable_arg()),
301 );
302
303 let plugins_group = Command::new("plugins")
304 .subcommand(Command::new("list"))
305 .subcommand(Command::new("info"))
306 .subcommand(Command::new("inspect").arg(Arg::new("plugin").num_args(1)))
307 .subcommand(Command::new("check").arg(Arg::new("plugin").num_args(1)))
308 .subcommand(Command::new("enable").arg(Arg::new("plugin").num_args(1)))
309 .subcommand(Command::new("disable").arg(Arg::new("plugin").num_args(1)))
310 .subcommand(
311 Command::new("install")
312 .arg(Arg::new("manifest").num_args(1))
313 .arg(
314 Arg::new("source")
315 .long("source")
316 .num_args(1)
317 .value_name("LABEL")
318 .help("Override the displayed provenance label without changing local manifest resolution"),
319 )
320 .arg(
321 Arg::new("trust")
322 .long("trust")
323 .num_args(1)
324 .value_parser(["core", "verified", "community", "unknown"]),
325 ),
326 )
327 .subcommand(Command::new("uninstall").arg(Arg::new("namespace").num_args(1)))
328 .subcommand(
329 Command::new("scaffold")
330 .arg(Arg::new("kind").num_args(1).required(true))
331 .arg(Arg::new("namespace").num_args(1).required(true))
332 .arg(Arg::new("path").long("path").num_args(1))
333 .arg(Arg::new("force").long("force").action(ArgAction::SetTrue)),
334 )
335 .subcommand(Command::new("doctor"))
336 .subcommand(Command::new("reserved-names"))
337 .subcommand(Command::new("where"))
338 .subcommand(Command::new("explain").arg(Arg::new("plugin").num_args(1)))
339 .subcommand(Command::new("schema"));
340 let completion_group = Command::new("completion").arg(
341 Arg::new("shell")
342 .long("shell")
343 .num_args(1)
344 .value_name("SHELL")
345 .value_parser(["bash", "zsh", "fish", "pwsh"])
346 .help("Generate completion output for an explicit shell target"),
347 );
348
349 let cli_group = Command::new("cli")
350 .subcommand(Command::new("status"))
351 .subcommand(Command::new("paths"))
352 .subcommand(Command::new("routes"))
353 .subcommand(Command::new("shims"))
354 .subcommand(Command::new("script-contract"))
355 .subcommand(
356 Command::new("doctor").arg(Arg::new("subject").num_args(1)).arg(
357 Arg::new("bundle")
358 .long("bundle")
359 .action(ArgAction::SetTrue)
360 .help("Write a diagnostics bundle under ./artifacts"),
361 ),
362 )
363 .subcommand(Command::new("version"))
364 .subcommand(Command::new("repl"))
365 .subcommand(completion_group.clone())
366 .subcommand(Command::new("inspect").hide(true))
367 .subcommand(config_group.clone())
368 .subcommand(Command::new("self-test"))
369 .subcommand(plugins_group.clone());
370 let apps_group = Command::new("apps")
371 .subcommand(Command::new("list"))
372 .subcommand(Command::new("doctor").arg(Arg::new("namespace").num_args(1)))
373 .subcommand(Command::new("which").arg(Arg::new("namespace").num_args(1).required(true)))
374 .subcommand(Command::new("version").arg(Arg::new("namespace").num_args(1).required(true)))
375 .subcommand(
376 Command::new("capabilities").arg(Arg::new("namespace").num_args(1).required(true)),
377 )
378 .subcommand(Command::new("schema"))
379 .subcommand(
380 Command::new("validate-manifest").arg(Arg::new("path").num_args(1).required(true)),
381 )
382 .subcommand(
383 Command::new("scaffold")
384 .arg(Arg::new("kind").num_args(1).required(true))
385 .arg(Arg::new("namespace").num_args(1).required(true))
386 .arg(Arg::new("path").long("path").num_args(1))
387 .arg(Arg::new("force").long("force").action(ArgAction::SetTrue)),
388 );
389
390 Command::new("bijux")
391 .args([
392 format_arg,
393 quiet_arg,
394 log_level_arg,
395 color_arg,
396 pretty_arg,
397 no_pretty_arg,
398 config_path_arg,
399 json_arg,
400 text_arg,
401 ])
402 .subcommand_required(false)
403 .allow_external_subcommands(true)
404 .subcommand(cli_group)
405 .subcommand(Command::new("status"))
407 .subcommand(Command::new("audit"))
408 .subcommand(Command::new("docs"))
409 .subcommand(
410 Command::new("doctor").arg(Arg::new("subject").num_args(1)).arg(
411 Arg::new("bundle")
412 .long("bundle")
413 .action(ArgAction::SetTrue)
414 .help("Write a diagnostics bundle under ./artifacts"),
415 ),
416 )
417 .subcommand(Command::new("version"))
418 .subcommand(
419 Command::new("install")
420 .arg(Arg::new("target").num_args(1))
421 .arg(Arg::new("dry-run").long("dry-run").action(ArgAction::SetTrue)),
422 )
423 .subcommand(
424 Command::new("explain")
425 .arg(Arg::new("command").num_args(1..).required(true).trailing_var_arg(true)),
426 )
427 .subcommand(apps_group)
428 .subcommand(config_group)
429 .subcommand(plugins_group)
430 .subcommand(Command::new("repl"))
431 .subcommand(completion_group)
432 .subcommand(Command::new("inspect").hide(true))
433 .subcommand(
434 Command::new("history")
435 .subcommand(
436 Command::new("clear").arg(
437 Arg::new("force")
438 .long("force")
439 .action(ArgAction::SetTrue)
440 .help("Clear history even when existing state is malformed"),
441 ),
442 )
443 .arg(
444 Arg::new("limit")
445 .long("limit")
446 .short('l')
447 .num_args(1)
448 .value_parser(clap::value_parser!(usize)),
449 )
450 .arg(Arg::new("filter").long("filter").short('F').num_args(1))
451 .arg(Arg::new("sort").long("sort").num_args(1).value_parser(["timestamp"])),
452 )
453 .subcommand(
454 Command::new("memory")
455 .subcommand(Command::new("list"))
456 .subcommand(Command::new("get").arg(Arg::new("key").num_args(1)))
457 .subcommand(Command::new("set").arg(Arg::new("pair").num_args(1)))
458 .subcommand(Command::new("delete").arg(Arg::new("key").num_args(1)))
459 .subcommand(Command::new("clear")),
460 )
461}
462
463fn extract_path(matches: &ArgMatches) -> Vec<String> {
464 let mut out = Vec::<String>::new();
465 let mut curr = matches;
466
467 while let Some((name, next)) = curr.subcommand() {
468 out.push(name.to_string());
469 curr = next;
470 }
471
472 out
473}
474
475pub fn parse_intent(argv: &[String]) -> Result<ParsedIntent, ParseError> {
477 let Ok(raw_matches) = root_command().try_get_matches_from(argv) else {
478 return Ok(ParsedIntent {
480 command_path: Vec::new(),
481 normalized_path: Vec::new(),
482 global_flags: ParsedGlobalFlags {
483 output_format: None,
484 pretty_mode: None,
485 color_mode: None,
486 log_level: None,
487 quiet: false,
488 config_path: None,
489 },
490 });
491 };
492
493 let command_path = extract_path(&raw_matches);
494 let normalize_external_globals = matches!(
495 command_path.as_slice(),
496 [a, ..] if known_bijux_tool_namespaces().contains(&a.as_str())
497 || canonical_bijux_tool_namespace(a).is_some()
498 );
499
500 let global_flags = if normalize_external_globals {
501 let parse_argv = parse_argv_with_global_flags_front(argv);
502 let Ok(reparsed) = root_command().try_get_matches_from(&parse_argv) else {
503 return Ok(ParsedIntent {
504 command_path: Vec::new(),
505 normalized_path: Vec::new(),
506 global_flags: ParsedGlobalFlags {
507 output_format: None,
508 pretty_mode: None,
509 color_mode: None,
510 log_level: None,
511 quiet: false,
512 config_path: None,
513 },
514 });
515 };
516 global_flags_from_matches(&reparsed)?
517 } else {
518 global_flags_from_matches(&raw_matches)?
519 };
520
521 let normalized_path = normalize_command_path(&command_path);
522
523 Ok(ParsedIntent { command_path, normalized_path, global_flags })
524}
525
526#[cfg(test)]
527mod tests {
528 use super::{parse_intent, root_command};
529 use crate::contracts::OutputFormat;
530
531 #[test]
532 fn cli_help_lists_registered_subcommands() {
533 let argv = vec!["bijux".to_string(), "cli".to_string(), "--help".to_string()];
534 let help = match root_command().try_get_matches_from(argv) {
535 Err(error) if matches!(error.kind(), clap::error::ErrorKind::DisplayHelp) => {
536 error.to_string()
537 }
538 other => panic!("expected clap help output, got {other:?}"),
539 };
540
541 assert!(help.contains("Commands:"));
542 assert!(help.contains("status"));
543 assert!(help.contains("plugins"));
544 }
545
546 #[test]
547 fn parse_intent_accepts_jsonl_output_format() {
548 let argv = vec![
549 "bijux".to_string(),
550 "--format".to_string(),
551 "jsonl".to_string(),
552 "status".to_string(),
553 ];
554 let intent = parse_intent(&argv).expect("intent");
555 assert_eq!(intent.global_flags.output_format, Some(OutputFormat::Jsonl));
556 }
557}