From 733c604e3b67b1377efc3d4e2c23d6f87c644f12 Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Wed, 2 Sep 2026 16:21:24 +0200 Subject: [PATCH] runtime: Route unknown commands to parent help Treat unknown command tokens as successful help requests by traversing the longest valid command path and rendering the existing parent help surface, while preserving unknown-option errors. Add regression coverage and update the CLI contract documentation. --- cli/src/services/parse/command_runtime.rs | 177 ++++++++++++++++++---- context/architecture.md | 4 +- context/cli/cli-command-surface.md | 4 +- context/glossary.md | 2 +- context/overview.md | 2 +- context/sce/cli-exit-code-contract.md | 2 +- 6 files changed, 156 insertions(+), 35 deletions(-) diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 2903c9655..036afa328 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -1,4 +1,5 @@ use crate::{cli_schema, command_surface, services}; +use clap::{ArgAction, CommandFactory}; use services::command_registry::{CommandRegistry, RuntimeCommand}; use services::error::{CliError, FailureClass}; use services::observability::traits::Logger as LoggerTrait; @@ -68,6 +69,16 @@ fn handle_clap_error( )); } + if error.kind() == clap::error::ErrorKind::InvalidSubcommand { + if let Some((name, text)) = render_unknown_subcommand_help(args, error) { + return Ok(RuntimeCommand::HelpText( + services::help::command::HelpTextCommand { name, text }, + )); + } + + return registry_command(registry, services::help::NAME); + } + if error.kind() == clap::error::ErrorKind::DisplayVersion { return registry_command(registry, services::version::NAME); } @@ -108,24 +119,84 @@ fn classify_clap_error(error: &clap::Error) -> CliError { } fn render_subcommand_help_from_args(args: &[String]) -> Option<(String, String)> { - let command_name = args.get(1)?.to_owned(); - let command_path = args[1..] + let command_path = command_path_from_args(args, args.len()); + + render_help_for_command_path(&command_path) +} + +fn render_unknown_subcommand_help( + args: &[String], + error: &clap::Error, +) -> Option<(String, String)> { + let unknown = extract_quoted_value(&error.to_string())?; + let unknown_index = args .iter() - .take_while(|arg| !arg.starts_with('-')) - .map(String::as_str) - .collect::>(); + .enumerate() + .skip(1) + .find_map(|(index, arg)| (arg == &unknown).then_some(index))?; + let command_path = command_path_from_args(args, unknown_index); - if command_path.is_empty() { - return None; - } + render_help_for_command_path(&command_path) +} + +fn render_help_for_command_path(command_path: &[String]) -> Option<(String, String)> { + let command_name = command_path.first()?.clone(); - if command_path.as_slice() == [services::auth_command::NAME] { + if command_path.len() == 1 && command_path[0] == services::auth_command::NAME { return Some((command_name, cli_schema::auth_help_text())); } + let command_path = command_path.iter().map(String::as_str).collect::>(); cli_schema::render_help_for_path(&command_path).map(|text| (command_name, text)) } +fn command_path_from_args(args: &[String], end: usize) -> Vec { + let mut command = cli_schema::Cli::command(); + let mut command_path = Vec::new(); + let mut index = 1; + + while index < end { + let arg = &args[index]; + if arg == "--" { + break; + } + if arg.starts_with('-') { + if option_takes_value(&command, arg) && !arg.contains('=') { + index += 2; + } else { + index += 1; + } + continue; + } + + let Some(subcommand) = command.find_subcommand(arg) else { + break; + }; + + command_path.push(arg.clone()); + command = subcommand.clone(); + index += 1; + } + + command_path +} + +fn option_takes_value(command: &clap::Command, token: &str) -> bool { + let (long_name, short_name) = if let Some(name) = token.strip_prefix("--") { + (name.split('=').next(), None) + } else if let Some(name) = token.strip_prefix('-') { + (None, name.chars().next().filter(|_| name.len() == 1)) + } else { + return false; + }; + + command.get_arguments().any(|argument| { + let matches_name = long_name.is_some_and(|name| argument.get_long() == Some(name)) + || short_name.is_some_and(|name| argument.get_short() == Some(name)); + matches_name && matches!(argument.get_action(), ArgAction::Set | ArgAction::Append) + }) +} + fn render_missing_subcommand_help(args: &[String]) -> Option { let command_name = args.get(1)?.as_str(); @@ -466,6 +537,7 @@ fn parse_optional_hook_remote_url(remote_url: Option) -> Result RuntimeCommand { parse_runtime_command( @@ -476,6 +548,14 @@ mod tests { .expect("command should parse") } + fn help_payload(command: RuntimeCommand) -> String { + match command { + RuntimeCommand::Help(_) => services::help::help_text(), + RuntimeCommand::HelpText(command) => command.text, + _ => panic!("expected a help command"), + } + } + #[test] fn sync_parses_to_sync_request_with_default_text_format() { let command = parse(&["sce", "sync"]); @@ -519,17 +599,64 @@ mod tests { } #[test] - fn removed_trace_command_is_rejected() { - let result = parse_runtime_command( - ["sce", "trace", "sync"].into_iter().map(String::from), + fn unknown_top_level_command_routes_to_top_level_help() { + let fallback = help_payload(parse(&["sce", "some_random_non_existing_command"])); + let expected = help_payload(parse(&["sce", "--help"])); + + assert_eq!(fallback, expected); + } + + #[test] + fn unknown_auth_subcommand_routes_to_auth_help() { + let fallback = help_payload(parse(&["sce", "auth", "some_random_non_existing_command"])); + let expected = cli_schema::auth_help_text(); + + assert_eq!(fallback, expected); + } + + #[test] + fn unknown_non_auth_subcommand_routes_to_closest_parent_help() { + let fallback = help_payload(parse(&["sce", "hooks", "some_random_non_existing_command"])); + let expected = help_payload(parse(&["sce", "hooks", "--help"])); + + assert_eq!(fallback, expected); + } + + #[test] + fn unknown_command_help_uses_the_success_rendering_path() { + let payload = help_payload(parse(&["sce", "some_random_non_existing_command"])); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let exit_code = services::app_support::render_run_outcome( + services::app_support::RunOutcome { + result: Ok(payload.clone()), + logger: None::, + startup_diagnostic: None, + }, + &mut stdout, + &mut stderr, + ); + + assert_eq!(exit_code, ExitCode::SUCCESS); + assert_eq!(stdout, format!("{payload}\n").as_bytes()); + assert!(stderr.is_empty()); + } + + #[test] + fn valid_command_and_unknown_option_keep_existing_routing() { + assert!(matches!(parse(&["sce", "sync"]), RuntimeCommand::Sync(_))); + + let Err(error) = parse_runtime_command( + ["sce", "--not-an-option"].into_iter().map(String::from), &CommandRegistry::default(), None, - ); + ) else { + panic!("unknown option should remain an error"); + }; - match result { - Ok(_) => panic!("trace should be unavailable"), - Err(error) => assert!(error.to_string().contains("Unknown command 'trace'")), - } + assert_eq!(error.class(), FailureClass::Parse); + assert!(error.to_string().contains("Unknown option")); } #[test] @@ -547,16 +674,10 @@ mod tests { } #[test] - fn auth_renew_is_rejected_as_parse_error() { - let Err(error) = parse_runtime_command( - ["sce", "auth", "renew"].into_iter().map(String::from), - &CommandRegistry::default(), - None, - ) else { - panic!("removed auth renew command should not parse") - }; - - assert_eq!(error.class(), FailureClass::Parse); - assert!(error.to_string().contains("Unknown command 'renew'")); + fn removed_auth_renew_command_routes_to_auth_help() { + assert_eq!( + help_payload(parse(&["sce", "auth", "renew"])), + cli_schema::auth_help_text() + ); } } diff --git a/context/architecture.md b/context/architecture.md index feda173da..81d50b217 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -105,10 +105,10 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/main.rs` is the executable entrypoint (`sce`) and delegates to `app::run`. - `cli/src/cli_schema.rs` defines the clap-based CLI schema using derive macros for all top-level commands and subcommands, including the top-level `sync` command, and renders command-local help text for the `auth` command tree (`auth`, `auth login`, `auth logout`, `auth whoami`). -- `cli/src/app.rs` provides the clap-based argument dispatch loop with deterministic help/setup execution, bare-command help routing for `sce auth` and `sce config`, centralized stream routing (`stdout` success payloads, `stderr` redacted diagnostics), stable class-based exit-code mapping (`2` parse, `3` validation, `4` runtime, `5` dependency), and stable class-based stderr diagnostic codes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) with default `Try:` remediation injection when missing. +- `cli/src/app.rs` provides the clap-based argument dispatch loop with deterministic help/setup execution, bare-command help routing for `sce auth` and `sce config`, successful unknown-command fallback to top-level or closest-parent help, centralized stream routing (`stdout` success payloads, `stderr` redacted diagnostics), stable class-based exit-code mapping (`2` parse, `3` validation, `4` runtime, `5` dependency), and stable class-based stderr diagnostic codes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) with default `Try:` remediation injection when missing. - The app runtime now moves through explicit startup phases in `cli/src/app.rs`: dependency bootstrapping (`perform_dependency_check`), startup context construction (`build_startup_context`), runtime initialization (`initialize_runtime`), command parse/execute inside telemetry subscriber context (`run_command_lifecycle`, `parse_command_phase` plus `services::app_support::execute_command_phase`), and final output rendering through `services::app_support::render_run_outcome`. `AppRuntime` owns the concrete production logger, no-op telemetry runtime, filesystem ops, git ops, static `CommandRegistry`, and startup-diagnostic state across those phases; `RunOutcome` carries final render data with an optional generic logger implementing `services::observability::traits::Logger`, so render support can log classified errors without production-logger type coupling. If a telemetry implementation attempts to invoke the command action more than once, dispatch returns a runtime-classified error instead of panicking or reusing consumed arguments. - `AppContext` is the CLI's borrowed dependency view in `cli/src/app.rs`: it is generic over logger, telemetry, filesystem, and git capability implementations and stores references plus an optional `repo_root: Option` instead of owning `Arc` trait objects. Because it borrows from `AppRuntime`, `AppContext` is a lightweight, short-lived view and must not be stored long-term (e.g., in structs or across await points). Startup creates a context view over `AppRuntime`'s concrete production dependencies with `repo_root` set to `None`; command paths can derive repo-root-scoped context views through the `ContextWithRepoRoot` accessor trait / `AppContext::with_repo_root(...)`, which reuses the same borrowed dependencies while attaching the resolved root. Narrow accessor traits expose associated concrete capability types for logger, telemetry, fs, and git (`&Self::...`) plus repo-root access, so call sites can express capability requirements without erasing the borrowed dependencies back to trait objects; lifecycle providers consume the repo-root accessor rather than the full context type. -- Command parse-time conversion and run-time handling are separated by an internal static `RuntimeCommand` seam. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` enum with variants for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync, plus a deterministic `CommandRegistry` name catalog populated by `build_default_registry()`. `parse_command_phase` in `cli/src/app.rs` delegates clap-output conversion to `cli/src/services/parse/command_runtime.rs`, which owns clap error classification, help rendering bridges, and parsed-request-to-enum conversion while returning concrete enum values. Service-owned `command.rs` modules define command payload structs and generic execution methods with narrow context requirements: context-free commands accept any context, hooks requires logger access, setup/doctor require repo-root scoping, and central dispatch requires the union of logger plus repo-root-scoping capabilities. `services::app_support::execute_command_phase` emits lifecycle logs around `RuntimeCommand::execute_with_stderr(...)`; the enum performs the only central dispatch match and delegates business behavior to the service-owned command structs, with sync receiving the app-owned stderr writer for format-gated progress. +- Command parse-time conversion and run-time handling are separated by an internal static `RuntimeCommand` seam. `cli/src/services/command_registry.rs` defines the `RuntimeCommand` enum with variants for help/help-text, version, completion, auth, config, setup, doctor, hooks, policy, and sync, plus a deterministic `CommandRegistry` name catalog populated by `build_default_registry()`. `parse_command_phase` in `cli/src/app.rs` delegates clap-output conversion to `cli/src/services/parse/command_runtime.rs`, which owns clap error classification, help rendering bridges, longest-valid-parent traversal for unknown command paths, and parsed-request-to-enum conversion while returning concrete enum values. Service-owned `command.rs` modules define command payload structs and generic execution methods with narrow context requirements: context-free commands accept any context, hooks requires logger access, setup/doctor require repo-root scoping, and central dispatch requires the union of logger plus repo-root-scoping capabilities. `services::app_support::execute_command_phase` emits lifecycle logs around `RuntimeCommand::execute_with_stderr(...)`; the enum performs the only central dispatch match and delegates business behavior to the service-owned command structs, with sync receiving the app-owned stderr writer for format-gated progress. - Startup observability bootstrapping in `cli/src/app.rs` still tolerates invalid default-discovered config files by continuing with degraded defaults plus `sce.config.invalid_config` warn-level logs, but the warning/logging work is now isolated behind the startup-context and runtime-initialization phases rather than one inline startup function. - `cli/src/services/observability.rs` provides deterministic runtime observability controls and rendering for app lifecycle logs, including shared config-resolved threshold/format, explicit config-file/default `log_to_file`, and `log_dir` inputs with precedence `env > config file > defaults` for non-flag observability keys, stable event identifiers, severity filtering, the forced-emission warning path used for invalid discovered config startup diagnostics, error-specific stderr suppression when file logging is enabled while non-error records and file-write diagnostics remain on stderr, redaction-safe emission through the shared security helper, and log-directory writes with bounded retention. Config resolution also carries a positive config-file/default-only `log_file_retention_limit` (`10` by default) into startup observability config and `sce config show`; the concrete logger stores that resolved value and threads it through primary and v2 cleanup. When `log_dir` resolves from `SCE_LOG_DIR`, config, or the `/sce/logs` default, each enabled or forced log operation selects `/sce-.log` or `/sce--.log` using the machine-local date and optional logger session context, with deterministic percent-encoding for unsafe session filename bytes; after successfully writing a newly created selected file, retention keeps the configured number of newest direct regular `*.log` files by mtime plus path/name tie-break and fails open on cleanup errors. Its `observability::traits` submodule exposes the current `Logger` API with `Option<&str>` session context plus object-safe `Telemetry` trait boundaries and `NoopLogger`; the concrete observability logger and telemetry runtime still own behavior and implement those traits. `services::app_support::render_run_outcome` consumes the logger through that trait boundary when logging classified errors and stdout-write failures. - `cli/src/services/observability.rs` no longer owns duplicate log enums or parsing helpers; it consumes the canonical primitive seam from `cli/src/services/config/mod.rs` and stays focused on logger and telemetry runtime behavior. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index cd5600997..a842eae3c 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -74,9 +74,9 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `parse_command_phase` delegates clap output conversion to `cli/src/services/parse/command_runtime.rs`, which returns concrete `RuntimeCommand` enum variants; `services::app_support::execute_command_phase` emits lifecycle logs around `RuntimeCommand::execute(...)`, and the enum delegates behavior to service-owned command payload structs. - Top-level failures are classified into stable exit-code classes owned by `cli/src/app.rs`: `2` parse, `3` validation, `4` runtime, and `5` dependency. - User-facing diagnostics are rendered on `stderr` as `Error [SCE-ERR-]: ...` with class-default `Try:` remediation appended only when missing; when stderr color is enabled the heading, error code, and diagnostic body all render through shared stderr styling helpers. -- Unknown commands/options and extra positional arguments return deterministic, actionable guidance to run `sce --help`. +- Unknown commands and subcommands are successful help requests: an unknown top-level token returns the same stdout payload as `sce --help`, while an unknown token below a valid command returns the closest valid parent's existing help payload (including the custom `auth` help). Unknown options, extra positional arguments, and other parse/validation failures retain deterministic actionable diagnostics and their existing failure classes. - `sce setup --help` returns setup-specific usage output with target-flag contract details and deterministic examples, including one-run non-interactive setup+hooks and composable follow-up validation/repair-intent flows (`sce doctor --format json`, `sce doctor --fix`). -- `sce auth` and `sce auth --help` return auth-specific usage output with available subcommands and deterministic examples, while `sce auth --help` stays scoped to the selected auth subcommand. The removed `sce auth renew` and `sce auth status` routes are rejected as invalid commands. +- `sce auth` and `sce auth --help` return auth-specific usage output with available subcommands and deterministic examples, while `sce auth --help` stays scoped to the selected auth subcommand. Unknown `sce auth ` paths, including removed `renew` and `status` routes, return the same auth help successfully; valid auth routes remain unchanged. - `sce doctor --help` and `sce hooks --help` return command-local usage output and deterministic copy-ready examples. - Interactive `sce setup` prompt cancellation/interrupt exits cleanly with: `Setup cancelled. No files were changed.` - Command handlers return deterministic status messaging: diff --git a/context/glossary.md b/context/glossary.md index 33ab4ccef..e0827df96 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -70,7 +70,7 @@ - `auth login stored-credential renewal`: The `sce auth login` behavior that first validates every stored credential through the existing non-forced token path, preserves valid credentials, refreshes expired credentials, and falls back to device authorization when credentials are absent or renewal fails. Renewal reports retain `login` labels in text and JSON; credential renewal is not exposed as a public subcommand. - `command surface contract`: The current top-level command/help catalog split where `cli/src/cli_schema.rs` owns the real clap-backed command metadata (top-level purpose text plus help visibility for `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion`) and `cli/src/command_surface.rs` consumes that catalog for the custom banner/help surface plus known-command classification, while still adding the synthetic `help` row. - `top-level help visibility metadata`: Per-command `show_in_top_level_help` metadata in `cli/src/cli_schema.rs` that controls whether a known command appears in `sce`, `sce help`, and `sce --help` without affecting direct invocation; the current hidden top-level commands are `hooks` and `policy`, while `auth` is visible, and `cli/src/command_surface.rs` renders the curated top-level help list from that shared metadata. -- `command loop`: The `clap` derive-based parser + dispatcher in `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, and `cli/src/app.rs` that routes `help`, `config`, `setup`, `doctor`, `auth`, `hooks`, `policy`, `sync`, `version`, and `completion`, executes implemented command flows, emits command-local help payloads for supported subcommand trees, and returns deterministic actionable errors for invalid invocation. +- `command loop`: The `clap` derive-based parser + dispatcher in `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, and `cli/src/app.rs` that routes `help`, `config`, `setup`, `doctor`, `auth`, `hooks`, `policy`, `sync`, `version`, and `completion`, executes implemented command flows, emits command-local help payloads for supported subcommand trees, resolves unknown command paths to the longest valid parent's help surface, and returns deterministic actionable errors for unknown options and other invalid invocation. - `RuntimeCommand seam`: Internal static command-execution abstraction in `cli/src/services/command_registry.rs` where clap-parsed commands are represented as a `RuntimeCommand` enum with variants for `Help`, `HelpText`, `Version`, `Completion`, `Auth`, `Config`, `Setup`, `Doctor`, `Hooks`, `Policy`, and `Sync`. The enum owns `name()` and `execute(...)` dispatch, delegating behavior to service-owned command payload structs while avoiding boxed `dyn RuntimeCommand` handles. Parsed request construction lives in `cli/src/services/parse/command_runtime.rs` when user-provided options or subcommands are required. - `sce dependency baseline`: Current crate dependency set declared in `cli/Cargo.toml` (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `indicatif`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends). No CLI dev-dependencies are currently declared, and the baseline is validated through normal compile/test coverage. - `progress reporter contract`: Library-independent, consumer-typed sync seam in `cli/src/services/sync/progress.rs` where `ProgressReporter` carries an arbitrary consumer event type, supports explicit successful finalization and closure-based collectors, and provides `NoopProgressReporter` for callers without human progress output. The sync-owned `SyncProgressEvent` in `cli/src/services/sync/sync.rs` carries lifecycle, accepted-batch, and stream-completion payloads for the four concurrent Agent Trace streams; `services::sync::progress` also owns the fixed `indicatif` terminal adapter and focused contract tests, while `sync/command.rs` selects text versus JSON behavior. No top-level `services::progress` module exists. diff --git a/context/overview.md b/context/overview.md index 34fae06a5..f7ba0b261 100644 --- a/context/overview.md +++ b/context/overview.md @@ -17,7 +17,7 @@ The generated `/next-task` workflow persists task-level context-synchronization - **Install channels:** repo-flake Nix, Cargo, npm, and source-built Flatpak (`dev.crocoder.sce`); Homebrew deferred (see `context/sce/cli-first-install-channels-contract.md`). The CLI crate currently depends on `anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `indicatif`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, and `uuid`, with target-specific keyring backend dependencies for Linux/FreeBSD, macOS, and Windows. No CLI dev-dependencies are currently declared. -Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan to magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels; `auth` is visible while `hooks` and `policy` remain directly invocable but hidden. The real top-level command catalog/help-visibility contract is centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|whoami`), with authenticated whoami reading the Control Plane `GET /me` profile and rendering flat email/name/role/permissions/organization labels, optional names and missing values handled deterministically, and exact login guidance returned when logged out, alongside config inspection/validation, setup orchestration, doctor diagnosis/repair, attribution-only hooks, shell completion, and the top-level `sync` command. Parse-time command conversion plus run-time command handling flow through the internal `RuntimeCommand` seam in `cli/src/app.rs`. +Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan to magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels; `auth` is visible while `hooks` and `policy` remain directly invocable but hidden. The real top-level command catalog/help-visibility contract is centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|whoami`), with authenticated whoami reading the Control Plane `GET /me` profile and rendering flat email/name/role/permissions/organization labels, optional names and missing values handled deterministically, and exact login guidance returned when logged out, alongside config inspection/validation, setup orchestration, doctor diagnosis/repair, attribution-only hooks, shell completion, and the top-level `sync` command. Unknown command paths are successful help requests: top-level unknown tokens use the normal top-level help payload and nested unknown tokens use the closest valid parent's help surface, while unknown options and other parse/validation failures retain their existing errors. Parse-time command conversion plus run-time command handling flow through the internal `RuntimeCommand` seam in `cli/src/app.rs`. The current doctor presentation contract supersedes the earlier output-shape scaffolding wording above: human text uses the compact Environment/Repository/Integrations hierarchy with healthy rows collapsed and unhealthy branches expanded, while JSON retains complete path, identity, problem, and fix-result detail. See `context/sce/doctor-human-text-contract.md`. The command loop now enforces a stable exit-code contract in `cli/src/app.rs`: `2` parse failures, `3` invocation validation failures, `4` runtime failures, and `5` dependency startup failures. The same runtime also emits stable user-facing stderr error classes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) using deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing. The command boundary's former flat, string-only `ClassifiedError` has been replaced by typed `CliError` in `cli/src/services/error.rs`: `CliError::User` carries a closed `UserError` catalog (`NotAuthenticated`, `NotGitRepository`, and payload-bearing `NotGitRemote { remote_name }`) for expected, deliberately-explained failures rendered without a `Try:` suffix, while `CliError::Internal` carries a live `anyhow::Error` source rendered as the real error chain with class-default remediation; `app_support` is the sole owner of turning either into the final styled stderr diagnostic, and `sce sync` is the first command to classify a failure (authentication) into `CliError::User`. Setup preflight errors preserve technical sources for observability, identify the configured missing remote by name, and keep raw remote URLs out of user-facing diagnostics. See `context/sce/cli-error-code-taxonomy.md` for the full contract. diff --git a/context/sce/cli-exit-code-contract.md b/context/sce/cli-exit-code-contract.md index 32662e427..839113cdd 100644 --- a/context/sce/cli-exit-code-contract.md +++ b/context/sce/cli-exit-code-contract.md @@ -8,7 +8,7 @@ The contract is intentionally class-based so automation can branch on failure ca ## Exit-code classes - `0` (`success`): command completed successfully. -- `2` (`parse_failure`): top-level CLI parsing failed (for example unknown top-level command/option or malformed command token). +- `2` (`parse_failure`): CLI parsing failed for an invocation that is not an unknown command/subcommand help request (for example an unknown option or malformed command token). Unknown top-level commands and unknown subcommands instead resolve to successful help with exit code `0`. - `3` (`validation_failure`): command/subcommand arguments parsed but failed invocation validation (for example incompatible or missing command-local arguments). - `4` (`runtime_failure`): command invocation was valid but runtime execution failed (filesystem/process/environment/runtime operation errors). - `5` (`dependency_failure`): startup dependency checks failed before command parsing/dispatch.