From 97714cb027599df3c46c2449de815660fb116a20 Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Thu, 20 Aug 2026 15:05:39 +0200 Subject: [PATCH 1/3] runtime: Classify sync failures with typed user-error catalog Expose credential-storage and unexpected sync failures through the closed user-error catalog, preserving technical sources for observability while emitting redacted, unstyled terminal messages. Add typed storage predicates across sync error layers. Co-authored-by: SCE --- .../agent_trace_sync/control_plane.rs | 6 +++ cli/src/services/agent_trace_sync/mod.rs | 11 +++++ cli/src/services/app_support.rs | 41 ++++++++-------- cli/src/services/error.rs | 43 +++++++++++++++- cli/src/services/sync/command.rs | 49 +++++++++++++++---- cli/src/services/sync/sync.rs | 9 ++++ context/architecture.md | 2 + context/cli/agent-trace-sync-command.md | 2 +- context/cli/styling-service.md | 15 +++--- context/cli/sync-command.md | 32 +++++++----- context/context-map.md | 1 + context/glossary.md | 4 +- context/overview.md | 6 +-- context/sce/cli-error-code-taxonomy.md | 11 +++-- context/sce/cli-stdout-stderr-contract.md | 4 +- 15 files changed, 172 insertions(+), 64 deletions(-) diff --git a/cli/src/services/agent_trace_sync/control_plane.rs b/cli/src/services/agent_trace_sync/control_plane.rs index ab994d82..11b5a163 100644 --- a/cli/src/services/agent_trace_sync/control_plane.rs +++ b/cli/src/services/agent_trace_sync/control_plane.rs @@ -184,6 +184,12 @@ impl ControlPlaneError { Self::MissingCredentials | Self::AuthenticationFailed(_) ) } + + /// True when the failure came from loading or saving local authentication + /// credentials, rather than from the control-plane request itself. + pub fn is_storage_failure(&self) -> bool { + matches!(self, Self::Storage(_)) + } } impl From for ControlPlaneError { diff --git a/cli/src/services/agent_trace_sync/mod.rs b/cli/src/services/agent_trace_sync/mod.rs index 7be8c685..6f51fcdf 100644 --- a/cli/src/services/agent_trace_sync/mod.rs +++ b/cli/src/services/agent_trace_sync/mod.rs @@ -125,6 +125,17 @@ impl StreamSyncError { Self::Read(_) | Self::InvalidResponse(_) | Self::DidNotConverge => false, } } + + /// True only when the underlying `ControlPlaneError` (from a `Refresh` + /// or `Terminal` failure) means local credential storage is unavailable. + /// `Read`, `InvalidResponse`, and `DidNotConverge` never carry a + /// `ControlPlaneError` and are never storage failures. + pub fn is_storage_failure(&self) -> bool { + match self { + Self::Refresh(error) | Self::Terminal(error) => error.is_storage_failure(), + Self::Read(_) | Self::InvalidResponse(_) | Self::DidNotConverge => false, + } + } } /// Outcome of a fully converged [`sync_stream`] run for one stream. diff --git a/cli/src/services/app_support.rs b/cli/src/services/app_support.rs index 14671d7e..418aa986 100644 --- a/cli/src/services/app_support.rs +++ b/cli/src/services/app_support.rs @@ -184,7 +184,12 @@ fn write_error_diagnostic_with_color_policy( } CliError::User { error: user_error, .. - } => user_error.message().to_string(), + } => { + let message = services::security::redact_sensitive_text(user_error.message()); + writeln!(writer, "{message}") + .expect("writing user error diagnostic to writer should not fail"); + return; + } }; let styled_message = services::style::error_text_with_color_policy( &services::security::redact_sensitive_text(&rendered), @@ -263,11 +268,12 @@ mod tests { let stderr_text = String::from_utf8(stderr).expect("stderr is valid utf8"); assert_eq!( - diagnostic_lines(&stderr_text).len(), - 1, - "exactly one terminal diagnostic must be written" + stderr_text, + "You are not logged in. Please log in using the `sce auth login` command.\n" ); - assert!(stderr_text.contains("You are not logged in")); + assert!(!stderr_text.contains("Error")); + assert!(!stderr_text.contains("SCE-ERR-")); + assert!(!stderr_text.contains("Try:")); assert!(!stderr_text.contains("missing credentials")); assert!(!stderr_text.to_lowercase().contains("control-plane")); } @@ -333,23 +339,16 @@ mod tests { } #[test] - fn user_error_diagnostic_is_styled_only_when_color_is_enabled() { + fn user_error_diagnostic_is_plain_in_every_color_policy_mode() { let error = CliError::user(UserError::NotAuthenticated); + let expected = "You are not logged in. Please log in using the `sce auth login` command.\n"; + + for color_enabled in [true, false] { + let mut stderr = Vec::new(); + write_error_diagnostic_with_color_policy(&mut stderr, &error, color_enabled); + let rendered = String::from_utf8(stderr).expect("stderr is valid utf8"); - let mut colored = Vec::new(); - write_error_diagnostic_with_color_policy(&mut colored, &error, true); - let colored_text = String::from_utf8(colored).expect("stderr is valid utf8"); - - let mut plain = Vec::new(); - write_error_diagnostic_with_color_policy(&mut plain, &error, false); - let plain_text = String::from_utf8(plain).expect("stderr is valid utf8"); - - // TTY-following (color_enabled: true) and redirected/NO_COLOR - // (color_enabled: false) diverge: only the enabled case carries ANSI. - assert_ne!(colored_text, plain_text); - assert!(!plain_text.contains('\u{1b}')); - assert!(colored_text.contains('\u{1b}')); - assert!(plain_text.contains("You are not logged in")); - assert!(colored_text.contains("You are not logged in")); + assert_eq!(rendered, expected); + } } } diff --git a/cli/src/services/error.rs b/cli/src/services/error.rs index 5e2b487c..f83d68ef 100644 --- a/cli/src/services/error.rs +++ b/cli/src/services/error.rs @@ -54,12 +54,17 @@ impl FailureClass { pub enum UserError { #[allow(dead_code)] NotAuthenticated, + AuthStorageUnavailable, + #[allow(dead_code)] + UnexpectedFailure, } impl UserError { pub fn class(self) -> FailureClass { match self { - Self::NotAuthenticated => FailureClass::Runtime, + Self::NotAuthenticated | Self::AuthStorageUnavailable | Self::UnexpectedFailure => { + FailureClass::Runtime + } } } @@ -67,6 +72,8 @@ impl UserError { pub fn key(self) -> &'static str { match self { Self::NotAuthenticated => "auth.not_authenticated", + Self::AuthStorageUnavailable => "auth.storage_unavailable", + Self::UnexpectedFailure => "general.unexpected_failure", } } @@ -75,6 +82,12 @@ impl UserError { Self::NotAuthenticated => { "You are not logged in. Please log in using the `sce auth login` command." } + Self::AuthStorageUnavailable => { + "Authentication storage is unavailable. Verify local credential storage is available, then retry the command." + } + Self::UnexpectedFailure => { + "An unexpected error occurred. Check the log files for more details." + } } } } @@ -190,6 +203,34 @@ mod tests { assert!(error.to_string().contains("You are not logged in")); } + #[test] + fn unexpected_failure_has_stable_runtime_catalog_mapping() { + let error = CliError::user(UserError::UnexpectedFailure); + + assert_eq!(error.class(), FailureClass::Runtime); + assert_eq!(error.code(), "SCE-ERR-RUNTIME"); + assert_eq!( + UserError::UnexpectedFailure.key(), + "general.unexpected_failure" + ); + assert_eq!( + UserError::UnexpectedFailure.message(), + "An unexpected error occurred. Check the log files for more details." + ); + assert_eq!( + error.to_string(), + "An unexpected error occurred. Check the log files for more details." + ); + } + + #[test] + fn unexpected_failure_has_one_static_safe_message() { + assert_eq!( + UserError::UnexpectedFailure.message(), + "An unexpected error occurred. Check the log files for more details." + ); + } + #[test] fn user_with_source_preserves_technical_source() { let error = CliError::user_with_source( diff --git a/cli/src/services/sync/command.rs b/cli/src/services/sync/command.rs index 6120bbe6..d007a89c 100644 --- a/cli/src/services/sync/command.rs +++ b/cli/src/services/sync/command.rs @@ -33,10 +33,12 @@ where #[allow(clippy::needless_pass_by_value)] fn classify_sync_error(err: TraceSyncError) -> CliError { - if err.is_authentication_failure() { + if err.is_storage_failure() { + CliError::user_with_source(UserError::AuthStorageUnavailable, err) + } else if err.is_authentication_failure() { CliError::user_with_source(UserError::NotAuthenticated, err) } else { - CliError::runtime(err) + CliError::user_with_source(UserError::UnexpectedFailure, err) } } @@ -112,10 +114,13 @@ mod tests { } } - fn assert_internal(err: TraceSyncError) { + fn assert_user_error(err: TraceSyncError, expected_key: &str) { match classify_sync_error(err) { - CliError::Internal { .. } => {} - other @ CliError::User { .. } => panic!("expected CliError::Internal, got {other:?}"), + CliError::User { error, source } => { + assert_eq!(error.key(), expected_key); + assert!(source.is_some()); + } + other @ CliError::Internal { .. } => panic!("expected CliError::User, got {other:?}"), } } @@ -152,25 +157,49 @@ mod tests { } #[test] - fn other_control_plane_errors_classify_as_internal() { + fn other_control_plane_errors_classify_as_unexpected_failure() { for error in [ ControlPlaneError::Forbidden("nope".to_string()), ControlPlaneError::BadRequest("bad".to_string()), ControlPlaneError::Transport("down".to_string()), ControlPlaneError::ServerError("500".to_string()), ControlPlaneError::InvalidResponse("garbage".to_string()), - ControlPlaneError::Storage("disk".to_string()), ControlPlaneError::Protocol { status: reqwest::StatusCode::NOT_FOUND, message: "route removed".to_string(), }, ] { - assert_internal(TraceSyncError::ControlPlane(error)); + assert_user_error( + TraceSyncError::ControlPlane(error), + "general.unexpected_failure", + ); } } #[test] - fn runtime_failure_classifies_as_internal() { - assert_internal(TraceSyncError::Runtime("local failure".to_string())); + fn credential_storage_failure_classifies_as_storage_unavailable() { + assert_user_error( + TraceSyncError::ControlPlane(ControlPlaneError::Storage("disk".to_string())), + "auth.storage_unavailable", + ); + } + + #[test] + fn runtime_failure_classifies_as_unexpected_failure() { + assert_user_error( + TraceSyncError::Runtime("local failure".to_string()), + "general.unexpected_failure", + ); + } + + #[test] + fn stream_storage_failure_does_not_classify_as_storage_unavailable() { + assert_user_error( + TraceSyncError::Stream { + stream: "prompts", + source: StreamSyncError::Terminal(ControlPlaneError::Storage("disk".to_string())), + }, + "general.unexpected_failure", + ); } } diff --git a/cli/src/services/sync/sync.rs b/cli/src/services/sync/sync.rs index 32f1b35d..c5930539 100644 --- a/cli/src/services/sync/sync.rs +++ b/cli/src/services/sync/sync.rs @@ -146,6 +146,15 @@ impl TraceSyncError { Self::Stream { source, .. } => source.is_authentication_failure(), } } + + /// True when the initial control-plane failure came from local credential + /// storage. Stream failures never carry storage errors. + pub fn is_storage_failure(&self) -> bool { + match self { + Self::ControlPlane(error) => error.is_storage_failure(), + Self::Runtime(_) | Self::Stream { .. } => false, + } + } } /// Resolves the current repository's Agent Trace storage (the same diff --git a/context/architecture.md b/context/architecture.md index 3c644ddb..fb7a79ed 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -151,6 +151,8 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/Cargo.toml` keeps crates.io publication-ready package metadata for the `shared-context-engineering` crate, and `cli/README.md` is the Cargo install surface for crates.io (`cargo install shared-context-engineering --locked`) and local checkout (`./scripts/run-cli-cargo.sh install --path cli --locked`) guidance. Direct `cargo install --git` is unsupported because it cannot invoke the repository's pre-Cargo producer. The published crate installs the `sce` binary. Tokio remains intentionally constrained (`default-features = false`) with current-thread runtime usage plus timer-backed bounded resilience wrappers for retry/timeout behavior. - `cli/Cargo.toml` now declares Tokio's `time` feature directly alongside the existing constrained current-thread runtime setup (`rt`, `io-util`, `time`) instead of relying on transitive enablement. +The `UserError::UnexpectedFailure` catalog entry (`general.unexpected_failure`) is owned by `cli/src/services/error.rs`; `sce sync` uses it for non-authentication and non-credential-storage failures, rendering one fixed log-files guidance sentence through `services::app_support` without exposing a technical error source, interpolating a path, or changing the closed catalog into an arbitrary-message surface. + ## Build / devShell / CI performance (flake-speedup) The current structure and durable before/after results for the native/release diff --git a/context/cli/agent-trace-sync-command.md b/context/cli/agent-trace-sync-command.md index 7500c311..8ac78c1f 100644 --- a/context/cli/agent-trace-sync-command.md +++ b/context/cli/agent-trace-sync-command.md @@ -44,7 +44,7 @@ Because every invocation starts from the control plane's authoritative `/state` ## Recovery semantics - **`401` (unexpected):** the control-plane client refreshes the WorkOS token exactly once, saves it, and retries the request exactly once. Concurrent callers that observed the same rejected token coalesce onto the first refresh and reuse its saved token; a second `401` (`ControlPlaneError::MissingCredentials`/`AuthenticationFailed`) fails the command with `sce auth login` guidance, and there is no further retry. -- **Typed authentication classification:** `ControlPlaneError::is_authentication_failure()` is `true` only for `MissingCredentials`/`AuthenticationFailed`, and the same typed traversal is exposed as `StreamSyncError::is_authentication_failure()` and `TraceSyncError::is_authentication_failure()` — no sync-stream path erases a `ControlPlaneError` into a bare `String` before it reaches the command boundary. `cli/src/services/sync/command.rs`'s `classify_sync_error` calls this traversal (never string/substring matching) to route an authentication failure from the initial `/state` call, a stream batch request, or a stream reconciliation `/state` refresh to `CliError::User { error: UserError::NotAuthenticated, .. }`; every other `ControlPlaneError` variant (`Forbidden`, `BadRequest`, `Transport`, `ServerError`, `InvalidResponse`, `Storage`, `Protocol`) stays `CliError::Internal` with its full technical chain preserved. See [CLI error-code taxonomy](../sce/cli-error-code-taxonomy.md) for the `CliError`/`UserError` architecture and [sync-command.md](sync-command.md#error-classification) for the command-level classifier. +- **Typed failure classification:** `ControlPlaneError::is_authentication_failure()` is `true` only for `MissingCredentials`/`AuthenticationFailed`, while `ControlPlaneError::is_storage_failure()` identifies local credential-storage failures. `TraceSyncError` uses the storage predicate only for a direct initial control-plane failure; `StreamSyncError::is_storage_failure()` delegates storage classification for `Refresh` and `Terminal` failures, while other stream error variants return false. No sync-stream path erases a `ControlPlaneError` into a bare `String` before it reaches the command boundary. `cli/src/services/sync/command.rs`'s `classify_sync_error` calls these predicates (never string/substring matching) to route authentication failures from the initial `/state` or stream paths to `CliError::User { error: UserError::NotAuthenticated, .. }` and credential-storage failures from the initial `/state` call to `CliError::User { error: UserError::AuthStorageUnavailable, .. }`; every other `ControlPlaneError` variant (`Forbidden`, `BadRequest`, `Transport`, `ServerError`, `InvalidResponse`, `Protocol`) stays `CliError::Internal` with its full technical chain preserved. See [CLI error-code taxonomy](../sce/cli-error-code-taxonomy.md) for the `CliError`/`UserError` architecture and [sync-command.md](sync-command.md#error-classification) for the command-level classifier. - **`409` (cursor conflict):** the per-stream sync engine reconciles by refetching `/state`, replacing only the affected stream's cursor, and resuming from local rows after the refreshed cursor — already-accepted rows are never resent. - **Ambiguous batch failure (`5xx`, transport failure, or an undecodable `2xx` body):** the engine reconciles via `/state` before any resend. If the refreshed cursor advanced (the batch was actually committed), sync continues from it without resending. If the cursor is unchanged (the batch was not committed), sync may resend once from the authoritative cursor. - **Reconciliation bound:** both the `409` and ambiguous-failure reconciliation paths share one bounded attempt counter per stream; exhausting it fails that stream with a "did not converge" error instead of looping unboundedly. diff --git a/context/cli/styling-service.md b/context/cli/styling-service.md index a87a418c..de0d43f0 100644 --- a/context/cli/styling-service.md +++ b/context/cli/styling-service.md @@ -24,12 +24,13 @@ The CLI styling service in `cli/src/services/style.rs` provides deterministic te - `command_name(text: &str) -> String` - Styles command names (green) for help output - `clap_help(text: &str) -> String` - Post-processes command-local clap help text so stdout help surfaces reuse shared heading, command, and placeholder styling without changing plain-text output when color is disabled -### Error Diagnostics Styling +### Internal Error Diagnostics Styling -- `error_code(text: &str) -> String` - Styles error codes (red/bold) for stderr diagnostics +- `error_code(text: &str) -> String` - Styles error codes (red/bold) for internal stderr diagnostics - `error_code_with_color_policy(text: &str, color_enabled: bool) -> String` - Internal variant accepting an explicit color policy flag for testability -- `heading(text: &str) -> String` - Styles headings for both stdout and stderr output (cyan/bold) -- `error_text_with_color_policy(text: &str, color_enabled: bool) -> String` - Internal helper styling human-readable stderr diagnostic bodies (yellow) given an explicit color policy flag; `app_support::write_error_diagnostic` is the sole production caller, passing `supports_color_stderr()` +- `heading(text: &str) -> String` - Styles headings for both stdout and internal stderr output (cyan/bold) +- `error_text_with_color_policy(text: &str, color_enabled: bool) -> String` - Internal helper styling human-readable internal stderr diagnostic bodies (yellow) given an explicit color policy flag; `app_support::write_error_diagnostic` is the sole production caller, passing `supports_color_stderr()` +- Catalog messages for expected failures are intentionally emitted redacted but unstyled and without the internal diagnostic wrapper. ### Command Output Styling @@ -54,7 +55,7 @@ The CLI styling service in `cli/src/services/style.rs` provides deterministic te - Help output uses `supports_color()` for stdout TTY detection - Command-local help styling is applied after clap renders plain help text, covering `Usage:`, section headings, command rows, and placeholder tokens on stdout surfaces - Error diagnostics use `supports_color_stderr()` for stderr TTY detection -- Top-level app diagnostics and observability log-file write failures both render through the shared stderr styling helpers when stderr color is enabled. +- Top-level internal app diagnostics and observability log-file write failures render through the shared stderr styling helpers when stderr color is enabled; user catalog diagnostics intentionally bypass those helpers. ## Sync progress styling @@ -82,7 +83,7 @@ use crate::services::style::{heading, command_name, error_code, error_text_with_ println!("{}", heading("Usage:")); println!(" {}", command_name("sce setup")); -// Error diagnostics styling (stderr) +// Internal error diagnostics styling (stderr) eprintln!( "{} [{}]: {}", heading("Error"), @@ -90,6 +91,8 @@ eprintln!( error_text_with_color_policy(message, supports_color_stderr()) ); +// Catalog messages are redacted and written without styling or wrapper. + // Command output styling println!("{}", success("Setup completed successfully.")); println!("{} {}", label("Repository root:"), value("'/path/to/repo'")); diff --git a/context/cli/sync-command.md b/context/cli/sync-command.md index fcdfb103..f6da87f4 100644 --- a/context/cli/sync-command.md +++ b/context/cli/sync-command.md @@ -102,19 +102,25 @@ client. The command change does not alter those semantics. ## Error classification `cli/src/services/sync/command.rs`'s `classify_sync_error` maps the command's -terminal `TraceSyncError` into the typed `CliError` boundary by calling -`TraceSyncError::is_authentication_failure()` — a typed traversal down to -`ControlPlaneError`, never string/substring matching. An authentication -failure from the initial `/state` call, a stream batch request, or a stream -reconciliation `/state` refresh (`ControlPlaneError::MissingCredentials` or -`AuthenticationFailed`) classifies as `CliError::User { error: -UserError::NotAuthenticated, .. }`, preserving the technical error as its -source; every other `ControlPlaneError` (`Forbidden`, `BadRequest`, -`Transport`, `ServerError`, `InvalidResponse`, `Storage`, `Protocol`) -classifies as `CliError::Internal`. `sync/command.rs` builds no friendly -sentence and applies no terminal styling itself — `app_support` renders the -single `You are not logged in...` diagnostic for the user case, and the full -`anyhow`/control-plane chain for the internal case. See [CLI error-code +terminal `TraceSyncError` into the typed `CliError` boundary through typed +predicates that traverse to `ControlPlaneError`, never string/substring +matching. An authentication failure from the initial `/state` call, a stream +batch request, or a stream reconciliation `/state` refresh +(`ControlPlaneError::MissingCredentials` or `AuthenticationFailed`) classifies +as `CliError::User { error: UserError::NotAuthenticated, .. }`. A credential +storage failure (`ControlPlaneError::Storage`) from the initial `/state` call +classifies as `CliError::User { error: UserError::AuthStorageUnavailable, .. }`. +Stream failures never classify as credential-storage user errors; their +authentication failures still use `NotAuthenticated`. Both user cases preserve +the technical error as their optional source. Every other `ControlPlaneError` +(`Forbidden`, `BadRequest`, `Transport`, `ServerError`, `InvalidResponse`, +`Protocol`) and runtime failures classify as +`CliError::User { error: UserError::UnexpectedFailure, .. }`; the technical +source remains available for observability. Stream credential-storage failures +also use `UnexpectedFailure`, because storage classification applies only to +the initial control-plane failure. +`sync/command.rs` builds no friendly sentence and applies no terminal styling +itself — `app_support` renders the catalog message for user cases. See [CLI error-code taxonomy](../sce/cli-error-code-taxonomy.md) for the full `CliError`/`UserError` architecture. diff --git a/context/context-map.md b/context/context-map.md index 0aaedc35..7a4447ee 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -123,4 +123,5 @@ Recent decision records: - `context/decisions/2026-08-13-trace-sync-progress-stream-contract.md` (keeps trace-sync progress and lifecycle timestamps on stderr while preserving stdout payload and JSON silence) - `context/decisions/2026-08-18-consumer-typed-progress-reporter-boundary.md` (keeps the reusable reporter contract generic over consumer event types while sync owns `SyncProgressEvent`) - `context/decisions/2026-08-18-sync-owned-progress-reporter-contract.md` (makes `services::sync::progress` the sole owner of the generic progress contract, no-op reporter, sync adapter, and focused tests; no top-level progress service remains) +- `context/decisions/2026-08-20-general-unexpected-user-error-catalog.md` (records the closed `UserError` catalog entry with one static log-files guidance sentence, no dynamic path interpolation or arbitrary message variant; current `sce sync` adoption is documented in `context/sce/cli-error-code-taxonomy.md`) - `context/decisions/2026-08-07-git-hook-managed-block-cooperation.md` (SCE-installed git hooks are a bounded in-place editor, not an exclusive owner: hook ownership is decided structurally by the SCE managed-block marker pair or a legacy guidance-URL marker, a foreign hook's bytes are preserved as an exact prefix with the block appended after them, and coexistence with third-party hook managers is cooperative, not authoritative) diff --git a/context/glossary.md b/context/glossary.md index b10372f5..771a5d84 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -66,7 +66,7 @@ - `cli crates.io publication posture`: Current Cargo package posture in `cli/Cargo.toml` where crates.io-facing metadata is publication-ready for the `shared-context-engineering` crate, with crate-facing install guidance owned by `cli/README.md`. - `Nix performance recommendations`: Repo-local operator guidance in `AGENTS.md` covering optional user-level `~/.config/nix/nix.conf` tuning (`max-jobs = auto`, `cores = 0`) and the explicit root/admin-only boundary for `/etc/nix/nix.conf` `auto-optimise-store = true`. - `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|logout|whoami`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), Control Plane `/me`-backed whoami profile output using flat email/name/role/permissions/organization labels, exact logged-out login guidance, implemented setup installation flow including lifecycle-aggregated local DB and Agent Trace DB bootstrap, implemented attribution-only `hooks` subcommand routing/validation entrypoints, and a fully implemented top-level `sce sync` command that synchronizes the current repository's Agent Trace DB with the control plane and renders the documented text/JSON output (see `context/cli/sync-command.md`). -- `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. +- `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. The related typed local WorkOS credential-storage failure (`ControlPlaneError::Storage`) is currently classified only at the `sce sync` command boundary as `UserError::AuthStorageUnavailable` (`auth.storage_unavailable`), with a fixed actionable terminal message that exposes no storage implementation details or automatic `Try:` suffix while preserving the technical source for structured observability; auth command classification is not yet enabled. - `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. @@ -120,13 +120,13 @@ - `sce stderr error-code taxonomy`: Stable user-facing diagnostic code classes emitted by `cli/src/app.rs` (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) via `Error []: ...` stderr formatting. - `class-default Try guidance injection`: `cli/src/app.rs` diagnostic behavior that appends `Try:` remediation text by failure class only when an error message does not already include `Try:` guidance. - `sce observability baseline`: App-runtime logging contract in `cli/src/services/observability.rs` and `cli/src/app.rs` with config-resolved observability inputs, deterministic env-over-config-file-over-default precedence for non-flag logging keys, default-backed `log_dir` / `SCE_LOG_DIR` file routing by machine-local date plus optional session filename partitioning, creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle `event_id` values, and stderr primary emission. +- `general unexpected user error`: `UserError::UnexpectedFailure` (`general.unexpected_failure`) catalog entry with the fixed sentence `An unexpected error occurred. Check the log files for more details.`. `sce sync` uses it for non-authentication and non-credential-storage failures while preserving the technical source for observability; the message exposes no path or implementation details. - `sce stdout/stderr contract`: App-level stream routing contract in `cli/src/app.rs` where command success payloads are emitted on stdout only, while redacted user-facing diagnostics and text-mode `sce sync` progress are emitted on stderr; JSON sync emits no human progress. - `SCE_LOG_LEVEL`: Optional runtime env key for `sce` observability threshold; allowed values are `error`, `warn`, `info`, and `debug`, defaulting to `error` when unset. - `SCE_LOG_FORMAT`: Optional runtime env key for `sce` observability record format; allowed values are `text` and `json`, defaulting to `text` when unset. - `SCE_LOG_FILE`: Optional runtime env key for `sce` observability file sink path; when set, rendered observability lines are mirrored to this file path with parent-directory auto-create behavior. - `SCE_LOG_FILE_MODE`: Optional runtime env key controlling `SCE_LOG_FILE` write policy; allowed values are `truncate` and `append`, defaults to `truncate`, and requires `SCE_LOG_FILE`. - `SCE_LOG_DIR`: Optional runtime env key for `sce` observability log-directory configuration; when set, it overrides config-file `log_dir` and the `/sce/logs` default and must be non-empty. - - `logger trait boundary`: `services::observability::traits::Logger` mirrors the current observability logger API (`info`, `debug`, `warn`, `error`, `log_cli_error`) for generic command/runtime bounds and tests, with each method accepting `Option<&str>` session context for file routing; the concrete `services::observability::Logger` implements it and `NoopLogger` remains available for side-effect-free tests. - `telemetry trait boundary`: `services::observability::traits::Telemetry` mirrors the current telemetry subscriber API (`with_default_subscriber`) for generic app-runtime bounds and tests, with the concrete `services::observability::TelemetryRuntime` implementing it by delegating to the existing inherent method. - `app startup phases`: Current `cli/src/app.rs` execution model that separates dependency checking, startup-context construction, runtime initialization, command parse/execute, and output rendering into named helpers while preserving the CLI's existing exit-code, stderr-diagnostic, and degraded-startup behavior; output rendering and execution-phase logging helpers live in `cli/src/services/app_support.rs`. diff --git a/context/overview.md b/context/overview.md index 45aff438..dc1d6553 100644 --- a/context/overview.md +++ b/context/overview.md @@ -9,7 +9,7 @@ The generated `/next-task` workflow persists task-level context-synchronization ## Key cross-cutting contracts - **Exit codes:** `2` parse, `3` validation, `4` runtime, `5` dependency failure (see `context/sce/cli-exit-code-contract.md`). -- **Stderr diagnostics:** stable `SCE-ERR-{PARSE,VALIDATION,RUNTIME,DEPENDENCY}` codes with class-default `Try:` remediation (see `context/sce/cli-error-code-taxonomy.md`). +- **Stderr diagnostics:** internal failures use stable `SCE-ERR-{PARSE,VALIDATION,RUNTIME,DEPENDENCY}` codes with class-default `Try:` remediation; expected catalog failures emit only redacted, unstyled messages (see `context/sce/cli-error-code-taxonomy.md`). - **Stdout/stderr:** command payloads on stdout only; redacted diagnostics and text-mode `sce sync` progress on stderr, while JSON sync remains silent (see `context/sce/cli-stdout-stderr-contract.md`). - **Observability:** config-resolved logging to stderr, optional dated/session-partitioned `log_dir` / `SCE_LOG_DIR` files with retention (see `context/sce/cli-observability-contract.md`). - **Config precedence:** `flags > env > config file > defaults` (see `context/cli/config-precedence-contract.md`); the config-file-only `agent_trace.auto_sync` setting defaults to `true` and is resolved with source metadata for the post-commit trigger boundary. Its asynchronous post-commit behavior is documented in `context/cli/agent-trace-auto-sync.md`. @@ -19,12 +19,12 @@ The generated `/next-task` workflow persists task-level context-synchronization 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`. 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 (currently only `NotAuthenticated`) for expected, deliberately-explained failures rendered as a friendly sentence with no `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`. See `context/sce/cli-error-code-taxonomy.md` for the full contract. +The same runtime also emits stable CLI stderr diagnostics: internal failures use `SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, or `SCE-ERR-DEPENDENCY` in deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing, while expected failures emit only their redacted, unstyled catalog messages. The command boundary's former flat, string-only `ClassifiedError` has been replaced by typed `CliError` in `cli/src/services/error.rs`: the expected-error variant carries a closed catalog (`NotAuthenticated`, the authentication-storage `AuthStorageUnavailable`, and the general `UnexpectedFailure`) for expected, deliberately-explained failures rendered without the technical source, wrapper, styling, or automatic `Try:` guidance; the general entry renders one fixed static log-files guidance sentence without dynamic path text. `CliError::Internal` carries a live `anyhow::Error` source rendered as the real error chain with the existing styled wrapper and class-default remediation; `app_support` is the sole owner of the distinct terminal paths, and `sce sync` classifies authentication, credential-storage, and all other sync failures as cataloged user errors while preserving their technical sources for observability. See `context/sce/cli-error-code-taxonomy.md` for the full contract. The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL` defaults to `error`; `SCE_LOG_FORMAT` defaults to `text`), default-backed log-directory routing (`SCE_LOG_DIR` / config-file `log_dir` / `/sce/logs`) with per-operation machine-local dated file selection, optional session filename partitioning, and creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle event IDs, stderr primary emission so stdout command payloads remain pipe-safe, and `observability::traits` boundaries for logger and telemetry behavior. The app command dispatcher now enforces a centralized stdout/stderr stream contract in `cli/src/app.rs`: command success payloads are emitted on stdout only, while redacted user-facing diagnostics and text-mode sync progress are emitted on stderr; JSON sync remains silent. `cli/src/app.rs` also now runs through explicit startup phases — dependency check, observability config resolution, runtime initialization, command parse/execute, and output rendering — with the app runtime carrying logger/telemetry plus static command-catalog state across those phases while preserving the existing exit-code and degraded-startup contracts. Within that lifecycle, `parse_command_phase` delegates clap-to-runtime conversion to `cli/src/services/parse/command_runtime.rs`, which returns a static `RuntimeCommand` enum, `services::app_support::execute_command_phase` logs around enum-owned `execute_with_stderr(...)` dispatch, and generic `RunOutcome` rendering logs classified errors through the logger trait boundary without coupling render support to the production logger type. Command payload structs for `help`, `version`, `completion`, `auth`, `config`, `setup`, `doctor`, `hooks`, and `sync` live in service-owned `command.rs` files; `cli/src/services/command_registry.rs` owns the deterministic static command-name catalog and enum variants instead of boxed command trait objects. The CLI now also enforces a shared output-format parser contract in `cli/src/services/output_format.rs`, with canonical `--format ` parsing and command-specific actionable invalid-value guidance reused by `config` and `version` services. A compile-safe service lifecycle seam also exists in `cli/src/services/lifecycle.rs`: `ServiceLifecycle` exposes default no-op `diagnose`, `fix`, and `setup` methods against the narrow `HasRepoRoot` accessor, uses lifecycle-owned health/fix/setup result types, and owns the shared static `LifecycleProvider` enum catalog/factory with deterministic config → local_db → auth_db → agent_trace_db → hooks ordering and no boxed provider aggregation. Hooks has a `services/hooks/lifecycle.rs` provider for hook rollout diagnosis/fix/setup, config has a `services/config/lifecycle.rs` provider for global/repo-local config validation plus repo-local config bootstrap, local_db has a `services/local_db/lifecycle.rs` provider for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup, auth_db has a `services/auth_db/lifecycle.rs` provider for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup, and agent_trace_db has a `services/agent_trace_db/lifecycle.rs` provider for repository-scoped Agent Trace DB setup and repository DB path health/parent readiness from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path). Doctor runtime aggregates the full shared provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor-owned output records; setup command aggregates the shared provider catalog for `setup` with hooks included only when requested and adapts lifecycle setup outcomes before rendering setup-owned messages. Agent Trace lifecycle setup now resolves repository storage, creates/reuses checkout identity for diagnostics, and initializes `/sce/repos//agent-trace.db` via `RepositoryAgentTraceDb`; hook runtime lazy initialization uses the same repository storage resolver when setup has not prepared the DB or schema metadata is incomplete. -The CLI now also includes a shared text styling service in `cli/src/services/style.rs` that provides deterministic color enablement via `owo-colors`, automatic TTY detection, and `NO_COLOR` compliance for human-facing text output; stdout help/text surfaces, stderr diagnostics, and interactive prompt-adjacent text now reuse that shared styling policy while JSON, completion, and other non-interactive/machine-readable flows remain unstyled. The service exports color-detection, conditional styling, help/diagnostic/label/prompt styling, and `banner_with_gradient()` helpers for use across command surfaces while preserving pipe-safe output for non-interactive environments. +The CLI now also includes a shared text styling service in `cli/src/services/style.rs` that provides deterministic color enablement via `owo-colors`, automatic TTY detection, and `NO_COLOR` compliance for human-facing text output; stdout help/text surfaces, internal stderr diagnostics, and interactive prompt-adjacent text now reuse that shared styling policy, while expected catalog messages, JSON, completion, and other non-interactive/machine-readable flows remain unstyled. The service exports color-detection, conditional styling, help/diagnostic/label/prompt styling, and `banner_with_gradient()` helpers for use across command surfaces while preserving pipe-safe output for non-interactive environments. The `setup` command includes an `inquire`-backed target-selection flow: default interactive selection for OpenCode/Claude/Pi/All with required-hook installation in the same run, explicit non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--all`), standalone `--bootstrap-context` for additive durable-context baseline creation without integration installs, deterministic mutually-exclusive validation, and non-destructive cancellation exits; the former `--both` flag was removed in favor of `--all` (opencode+claude+pi). Every normal successful setup path also ensures the same context baseline after the Git gate. Workflows the catalog marks optional are installed only when a repository opts in: the repeatable `sce setup --workflow ` flag names the selection for a run, an omitted flag reuses the selection persisted in `integrations.optional_workflows`, and the resolved selection filters the installed target assets and is written back to repo-local config. Interactive runs ask for the selection instead: a multi-select prompt follows target selection with every row unchecked on a first run and pre-checked from the persisted selection afterwards, cancelling it exits non-destructively like the target prompt, and the prompt is skipped when the catalog marks no workflow optional. `brownfield` is currently the only optional workflow, so a default run installs the five core workflows and no brownfield assets. `sce doctor` scopes its integration checks to that same recorded selection, so it never reports an unselected optional workflow's files as missing. For repository generation consumers, `config/pkl/generator-inputs.txt` declares the canonical Pkl/plugin input set and `scripts/produce-cli-generated-input.sh` owns its discovery, two-pass `config/pkl/generate.pkl` evaluation, determinism comparison, payload/input inventories, in-flight input-mutation rejection, atomic handoff publication, and staging cleanup. `scripts/run-cli-cargo.sh` creates a fresh temporary destination, delegates generation to that producer, invokes the requested Cargo workflow with `SCE_CLI_GENERATED_INPUT_DIR`, and removes the handoff after Cargo success, failure, or handled signals. `config/pkl/check-generated.sh` delegates the same production mechanics while retaining contract and path assertions. `scripts/prepare-cli-generated-assets.sh` moves the producer-validated Pkl payload and checksums into the unchanged package fallback, adds hooks, migrations, and the Agent Trace schema, and appends only those static checksums to the combined inventory. The root flake's pre-Cargo `cliGeneratedInput` derivation invokes the same producer from a declarative source containing the producer plus its declared inputs. `cli/build.rs` rejects missing, incomplete, modified, or stale repository handoffs, copies the validated payload into Cargo `OUT_DIR/pkl-generated`, stages static inputs under `OUT_DIR/static`, and writes setup-asset, optional-workflow-catalog, and migration Rust manifests into `OUT_DIR`; it never invokes Pkl. Published crates carry the ignored packaging-only fallback, and unpacked downstream builds validate and copy it into their own `OUT_DIR` without requiring Pkl or parent repository paths. The setup service also provides repository-root install orchestration: it resolves the repository root, ensures the additive durable-context baseline, then for normal modes derives a repo-root-scoped `AppContext` from the runtime command context, aggregates `ServiceLifecycle::setup` calls across lifecycle providers (config → local_db → auth_db → agent_trace_db → hooks when requested), handles interactive or flag-based target selection for config asset installation, and reports deterministic completion details (selected target(s) and installed file counts). Setup installs config assets (`.opencode`/`.claude`/`.pi`) per file: each embedded asset is staged and swapped into its own destination path, creating parent directories as needed, without removing or recreating the target directory as a whole, so files a repository owns inside an SCE-managed target directory survive a setup run untouched. Two assets are merge targets rather than verbatim writes: Claude's `.claude/settings.json` and OpenCode's `.opencode/opencode.json`. For each, setup JSON-merges the generated document into the user's existing file rather than overwriting it, and fails deterministically without writing if the existing file is not valid JSON; a missing file is still created from the generated document verbatim. Claude's merge replaces only SCE-owned hook entries (identified by a command containing `run-sce-or-show-install-guidance.sh`) and the `$schema` key while preserving every other key and hook entry untouched. OpenCode's merge replaces the `$schema` key and merges the `plugin` array as a set: any entry shaped like an SCE plugin path (`./plugins/sce-*`) is dropped, structurally, so a path an older or renamed catalog once installed is still recognized and pruned, and the generated document's canonical plugin entries are appended after the surviving user entries. Required-hook install uses the same per-file stage/atomic-swap choreography as config-asset install — the staging file is renamed directly over an existing hook without unlinking it first, so a rename failure leaves the prior hook untouched. Both flows return deterministic recovery guidance (recover from version control) on swap failure, without creating backup artifacts. After installing, config install prunes stale SCE-owned assets: it deletes every path the full embedded catalog for the target claims but the current selection did not install (a deselected optional workflow, or an asset a newer catalog renamed or dropped), then removes any parent directory left empty by that deletion, leaving a directory intact if a user file still lives inside it. The setup command gates all modes on an existing git repository before any writes. Internally, `cli/src/services/setup/mod.rs` now separates install-flow logic from interactive prompt logic through focused support seams. diff --git a/context/sce/cli-error-code-taxonomy.md b/context/sce/cli-error-code-taxonomy.md index b43c0da4..d162f651 100644 --- a/context/sce/cli-error-code-taxonomy.md +++ b/context/sce/cli-error-code-taxonomy.md @@ -14,10 +14,11 @@ It complements the numeric process exit-code classes documented in `context/sce/ ## Rendering contract -- User-facing diagnostics are emitted on `stderr` as: `Error []: `. +- Catalog diagnostics are emitted on `stderr` as the redacted catalog message followed by a newline, without an `Error` label, `SCE-ERR-*` code, separator, `Try:` guidance, or ANSI styling. This is the terminal path for `CliError::User`. +- `CliError::Internal` diagnostics are emitted on `stderr` as the styled `Error []: ` wrapper. - Before stderr emission, all `CliError` instances are logged via `Logger::log_cli_error()` with event ID `sce.error.{code}` and fields `error_code`, `error_class`. - For `CliError::Internal`, if the rendered message does not already include `Try:`, runtime appends class-default remediation guidance; if it already contains `Try:`, runtime preserves the original remediation text and does not append a second one. -- For `CliError::User`, runtime renders the catalog message from `UserError` verbatim, with no class-default `Try:` appended. +- For `CliError::User`, runtime renders the catalog message from `UserError` without technical source text or class-default `Try:` remediation. The `UserError::UnexpectedFailure` entry renders the fixed message `An unexpected error occurred. Check the log files for more details.` without dynamic path interpolation. - Diagnostic text is still redaction-filtered through `services::security::redact_sensitive_text` before emission. ## Actionable parser/invocation guidance contract @@ -31,11 +32,11 @@ It complements the numeric process exit-code classes documented in `context/sce/ ## Ownership - `FailureClass` in `cli/src/services/error.rs` owns class selection and stable code assignment (`FailureClass::code()`). -- `CliError::{User,Internal}` in `cli/src/services/error.rs` is the typed CLI-boundary error type; `CliError::code()`/`CliError::class()` delegate to the failure class. `CliError::User` carries a catalog `UserError` (currently only `NotAuthenticated`) for expected, deliberately-explained failures; `CliError::Internal` carries a live `anyhow::Error` source for every other failure. `CliError::User` may also carry an optional preserved technical `source`, kept for observability only and never rendered to the terminal. -- `UserError` in `cli/src/services/error.rs` is the closed catalog of deliberately presented terminal failures. It has no arbitrary-message variant (no `Message(String)`/`Custom(...)` escape hatch): every entry is a fixed, reviewed sentence returned by `UserError::message()`, keyed for structured logging by `UserError::key()`. +- `CliError::{User,Internal}` in `cli/src/services/error.rs` is the typed CLI-boundary error type; `CliError::code()`/`CliError::class()` delegate to the failure class. `CliError::User` carries a catalog `UserError` (`NotAuthenticated`, `AuthStorageUnavailable`, or `UnexpectedFailure`) for expected, deliberately-explained failures; `CliError::Internal` carries a live `anyhow::Error` source for every other failure. `CliError::User` may also carry an optional preserved technical `source`, kept for observability only and never rendered to the terminal. +- `UserError` in `cli/src/services/error.rs` is the closed catalog of deliberately presented terminal failures. It has no arbitrary-message variant (no `Message(String)`/`Custom(...)` escape hatch): every entry is a fixed, reviewed sentence returned by `UserError::message()`, keyed for structured logging by `UserError::key()`. `AuthStorageUnavailable` (`auth.storage_unavailable`) is currently used by `sce sync` for typed authentication credential-storage failures. `UnexpectedFailure` (`general.unexpected_failure`) is used by `sce sync` for its default failure classification; it renders one fixed, user-safe diagnostic sentence and has no automatic `Try:` suffix or dynamic path input. - Command and domain layers construct and return a `CliError`; they do not format terminal text, apply styling, or decide authentication/user-error semantics from string matching. `app_support` is the sole owner of turning a `CliError` into the final stderr sentence. - `Logger::log_cli_error` in `cli/src/services/observability.rs` owns structured error logging with `sce.error.{code}` event IDs. -- `write_error_diagnostic` in `cli/src/services/app_support.rs` owns final code-bearing stderr rendering, including styling `CliError::User`'s catalog message and `CliError::Internal`'s rendered chain through `services::style::error_text_with_color_policy` under the stderr TTY/`NO_COLOR` policy (`services::style::supports_color_stderr()`), independent of stdout's TTY state. +- `write_error_diagnostic` in `cli/src/services/app_support.rs` owns final stderr rendering: it redacts and writes the catalog variant's message without a wrapper or styling, while `CliError::Internal` retains code-bearing rendering and styles its rendered chain through `services::style::error_text_with_color_policy` under the stderr TTY/`NO_COLOR` policy (`services::style::supports_color_stderr()`), independent of stdout's TTY state. - `run_with_dependency_check_and_streams` in `cli/src/app.rs` owns error logging before stderr emission. ## Determinism and testing diff --git a/context/sce/cli-stdout-stderr-contract.md b/context/sce/cli-stdout-stderr-contract.md index cb82148c..f89a5548 100644 --- a/context/sce/cli-stdout-stderr-contract.md +++ b/context/sce/cli-stdout-stderr-contract.md @@ -8,8 +8,8 @@ This document defines the implemented stream contract for CLI command payload an - Command success payloads are emitted to `stdout` only through app-level stream handling. - User-facing diagnostics and failures are emitted to `stderr` only. -- Failure diagnostics are emitted as `Error []: ...` on `stderr`, where `` is the stable class-based `SCE-ERR-*` identifier from `CliError` in `cli/src/services/error.rs`; diagnostics are passed through shared redaction (`services::security::redact_sensitive_text`) before emission. -- The diagnostic body differs by `CliError` variant: `CliError::Internal` renders the real `anyhow` source chain (`format!("{source:#}")`) plus class-default `Try:` remediation; `CliError::User` renders its catalog `UserError` message verbatim, with no low-level technical text and no `Try:` suffix. Both bodies are styled through the same stderr TTY/`NO_COLOR` policy before redaction and emission. +- `CliError::Internal` failure diagnostics are emitted as `Error []: ...` on `stderr`, where `` is the stable class-based `SCE-ERR-*` identifier from `CliError` in `cli/src/services/error.rs`. `CliError::User` failures emit only their redacted message and trailing newline on `stderr`, without the wrapper, code, guidance, or ANSI styling. All emitted diagnostic text is passed through shared redaction (`services::security::redact_sensitive_text`) before emission. +- The diagnostic body differs by `CliError` variant: `CliError::Internal` renders the real `anyhow` source chain (`format!("{source:#}")`) plus class-default `Try:` remediation and applies the stderr TTY/`NO_COLOR` styling policy; the catalog variant renders its `UserError` message after redaction, with no low-level technical text, wrapper, styling, or `Try:` suffix. - Command handlers now return payload strings to the app dispatcher; the app owns stream selection and final emission. ## Implementation surface From f6583298dab89b0edb5ddddca3d248b0239253b2 Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Thu, 20 Aug 2026 16:57:25 +0200 Subject: [PATCH 2/3] auth: Implement typed command error classification Classify authentication, storage, and fallback failures through the shared CliError user-error catalog while preserving technical sources for observability. Co-authored-by: SCE --- cli/src/services/auth_command/command.rs | 2 +- cli/src/services/auth_command/mod.rs | 159 +++++++++++------------ cli/src/services/token_storage.rs | 6 - context/architecture.md | 2 +- context/cli/cli-command-surface.md | 6 +- context/glossary.md | 2 +- context/overview.md | 2 +- context/sce/cli-error-code-taxonomy.md | 5 +- 8 files changed, 87 insertions(+), 97 deletions(-) diff --git a/cli/src/services/auth_command/command.rs b/cli/src/services/auth_command/command.rs index 9c5abadb..5e7ac22a 100644 --- a/cli/src/services/auth_command/command.rs +++ b/cli/src/services/auth_command/command.rs @@ -7,6 +7,6 @@ pub struct AuthCommand { impl AuthCommand { pub fn execute(&self, _context: &C) -> Result { - auth_command::run_auth_subcommand(self.request).map_err(CliError::runtime) + auth_command::run_auth_subcommand(self.request) } } diff --git a/cli/src/services/auth_command/mod.rs b/cli/src/services/auth_command/mod.rs index 3e3c6763..9c8158aa 100644 --- a/cli/src/services/auth_command/mod.rs +++ b/cli/src/services/auth_command/mod.rs @@ -11,6 +11,7 @@ use crate::services::agent_trace_sync::control_plane::{ }; use crate::services::auth::{self, AuthError, DeviceAuthFlowResult}; use crate::services::config; +use crate::services::error::{CliError, UserError}; use crate::services::output_format::OutputFormat; use crate::services::style::{label, prompt_label, prompt_value, success, value}; use crate::services::token_storage::{self, StoredTokens}; @@ -33,7 +34,7 @@ pub struct AuthRequest { pub subcommand: AuthSubcommand, } -pub fn run_auth_subcommand(request: AuthRequest) -> Result { +pub fn run_auth_subcommand(request: AuthRequest) -> Result { run_auth_subcommand_with(request, run_login, run_logout, run_whoami) } @@ -42,11 +43,11 @@ fn run_auth_subcommand_with( login: L, logout: O, whoami: S, -) -> Result +) -> Result where - L: FnOnce(AuthFormat) -> Result, - O: FnOnce(AuthFormat) -> Result, - S: FnOnce(AuthFormat) -> Result, + L: FnOnce(AuthFormat) -> Result, + O: FnOnce(AuthFormat) -> Result, + S: FnOnce(AuthFormat) -> Result, { match request.subcommand { AuthSubcommand::Login { format } => login(format), @@ -55,15 +56,16 @@ where } } -pub fn run_login(format: AuthFormat) -> Result { +pub fn run_login(format: AuthFormat) -> Result { let client = reqwest::Client::new(); - let runtime = shared_runtime()?; + let runtime = shared_runtime().map_err(unexpected_auth_command_error)?; - let client_id = resolve_login_client_id()?; + let client_id = resolve_login_client_id().map_err(unexpected_auth_command_error)?; + let stored_tokens = token_storage::load_tokens().map_err(auth_storage_error)?; run_login_with_stored_credentials( format, - token_storage::load_tokens()?, + stored_tokens, |stored_tokens| maybe_renew_stored_credentials(runtime, &client, &client_id, stored_tokens), |format| match format { AuthFormat::Text => run_text_login_with_runtime(runtime, &client, &client_id), @@ -72,35 +74,39 @@ pub fn run_login(format: AuthFormat) -> Result { ) } -pub fn run_logout(format: AuthFormat) -> Result { - let deleted = token_storage::delete_tokens().map_err(|error| { - let guidance = auth_state_path_guidance( - "verify file permissions for the auth state directory and rerun 'sce auth logout'", - ); - anyhow!(format!("{error} Try: {guidance}")) - })?; - render_logout_result(deleted, format) +pub fn run_logout(format: AuthFormat) -> Result { + let deleted = token_storage::delete_tokens().map_err(auth_storage_error)?; + if !deleted { + return Err(CliError::user(UserError::NotAuthenticated)); + } + render_logout_success(format).map_err(unexpected_auth_command_error) } -pub fn run_whoami(format: AuthFormat) -> Result { - if token_storage::load_tokens()?.is_none() { - return render_unauthenticated_whoami(format); +pub fn run_whoami(format: AuthFormat) -> Result { + if token_storage::load_tokens() + .map_err(auth_storage_error)? + .is_none() + { + return Err(CliError::user(UserError::NotAuthenticated)); } let cwd = std::env::current_dir() - .context("failed to determine current directory for auth config resolution")?; - let auth_config = config::resolve_auth_runtime_config(&cwd)?; + .context("failed to determine current directory for auth config resolution") + .map_err(unexpected_auth_command_error)?; + let auth_config = + config::resolve_auth_runtime_config(&cwd).map_err(unexpected_auth_command_error)?; let client = AuthenticatedControlPlaneClient::new( reqwest::Client::new(), auth_config.control_plane_base_url.value.unwrap_or_default(), auth::WORKOS_DEFAULT_BASE_URL, auth_config.workos_client_id.value.unwrap_or_default(), ); - let profile = shared_runtime()? + let profile = shared_runtime() + .map_err(unexpected_auth_command_error)? .block_on(client.me()) - .map_err(|error| map_whoami_control_plane_error(&error))?; + .map_err(map_whoami_control_plane_error)?; - render_whoami_result(&profile, format) + render_whoami_result(&profile, format).map_err(unexpected_auth_command_error) } fn shared_runtime() -> Result<&'static tokio::runtime::Runtime> { @@ -122,14 +128,16 @@ fn maybe_renew_stored_credentials( client: &reqwest::Client, client_id: &str, stored_tokens: &StoredTokens, -) -> Result> { +) -> Result, CliError> { match runtime.block_on(auth::ensure_valid_token_returning_token( client, auth::WORKOS_DEFAULT_BASE_URL, client_id, stored_tokens, )) { - Ok(token) => Ok(Some(token_storage::save_tokens(&token)?)), + Ok(token) => token_storage::save_tokens(&token) + .map(Some) + .map_err(auth_storage_error), Err(_) => Ok(None), } } @@ -139,14 +147,15 @@ fn run_login_with_stored_credentials( stored_tokens: Option, renew: R, device_login: D, -) -> Result +) -> Result where - R: FnOnce(&StoredTokens) -> Result>, - D: FnOnce(AuthFormat) -> Result, + R: FnOnce(&StoredTokens) -> Result, CliError>, + D: FnOnce(AuthFormat) -> Result, { if let Some(stored_tokens) = stored_tokens { if let Some(renewed_tokens) = renew(&stored_tokens)? { - return render_login_refresh_result(&renewed_tokens, format); + return render_login_refresh_result(&renewed_tokens, format) + .map_err(unexpected_auth_command_error); } } @@ -157,16 +166,16 @@ fn run_text_login_with_runtime( runtime: &tokio::runtime::Runtime, client: &reqwest::Client, client_id: &str, -) -> Result { +) -> Result { let authorization = runtime .block_on(auth::request_device_authorization( client, auth::WORKOS_DEFAULT_BASE_URL, client_id, )) - .map_err(|e| map_login_error(&e))?; + .map_err(map_login_error)?; - write_login_prompt(&authorization)?; + write_login_prompt(&authorization).map_err(unexpected_auth_command_error)?; let token = runtime .block_on(auth::complete_device_auth_flow_returning_token( @@ -175,9 +184,9 @@ fn run_text_login_with_runtime( client_id, &authorization, )) - .map_err(|e| map_login_error(&e))?; + .map_err(map_login_error)?; - let stored_tokens = token_storage::save_tokens(&token)?; + let stored_tokens = token_storage::save_tokens(&token).map_err(auth_storage_error)?; render_login_result( &DeviceAuthFlowResult { @@ -186,6 +195,7 @@ fn run_text_login_with_runtime( }, AuthFormat::Text, ) + .map_err(unexpected_auth_command_error) } fn run_login_json( @@ -193,14 +203,14 @@ fn run_login_json( client: &reqwest::Client, client_id: &str, format: AuthFormat, -) -> Result { +) -> Result { let authorization = runtime .block_on(auth::request_device_authorization( client, auth::WORKOS_DEFAULT_BASE_URL, client_id, )) - .map_err(|e| map_login_error(&e))?; + .map_err(map_login_error)?; let token = runtime .block_on(auth::complete_device_auth_flow_returning_token( @@ -209,9 +219,9 @@ fn run_login_json( client_id, &authorization, )) - .map_err(|e| map_login_error(&e))?; + .map_err(map_login_error)?; - let stored_tokens = token_storage::save_tokens(&token)?; + let stored_tokens = token_storage::save_tokens(&token).map_err(auth_storage_error)?; render_login_result( &DeviceAuthFlowResult { @@ -220,6 +230,7 @@ fn run_login_json( }, format, ) + .map_err(unexpected_auth_command_error) } fn resolve_login_client_id() -> Result { @@ -260,11 +271,12 @@ fn write_login_prompt(authorization: &auth::DeviceAuthorizationResponse) -> Resu Ok(()) } -fn map_login_error(error: &AuthError) -> anyhow::Error { - anyhow!(with_try_guidance( - error.to_string(), - "verify the resolved WorkOS client ID source (WORKOS_CLIENT_ID, config file, or baked default), confirm network access, and rerun 'sce auth login'." - )) +fn map_login_error(error: AuthError) -> CliError { + let user_error = match &error { + AuthError::Io(_) | AuthError::Storage(_) => UserError::AuthStorageUnavailable, + _ => UserError::UnexpectedFailure, + }; + CliError::user_with_source(user_error, error) } fn render_login_result(result: &DeviceAuthFlowResult, format: AuthFormat) -> Result { @@ -316,41 +328,20 @@ fn render_login_refresh_result(tokens: &StoredTokens, format: AuthFormat) -> Res } } -fn render_logout_result(deleted: bool, format: AuthFormat) -> Result { +fn render_logout_success(format: AuthFormat) -> Result { match format { - AuthFormat::Text => Ok(if deleted { - success("Logged out") - } else { - value("No user logged in") - }), + AuthFormat::Text => Ok(success("Logged out")), AuthFormat::Json => serde_json::to_string_pretty(&json!({ "status": "ok", "command": NAME, "subcommand": "logout", "authenticated": false, - "credentials_removed": deleted, + "credentials_removed": true, })) .context("failed to serialize auth logout report to JSON. Try: rerun 'sce auth logout --format json'."), } } -fn render_unauthenticated_whoami(format: AuthFormat) -> Result { - match format { - AuthFormat::Text => Ok(format!( - "You are not logged in. Please log in using the {} command.", - success("sce auth login") - )), - AuthFormat::Json => serde_json::to_string_pretty(&json!({ - "status": "ok", - "command": NAME, - "subcommand": "whoami", - "authentication_state": "unauthenticated", - "has_stored_credentials": false, - })) - .context("failed to serialize auth whoami report to JSON. Try: rerun 'sce auth whoami --format json'."), - } -} - fn render_whoami_result(profile: &MeResponse, format: AuthFormat) -> Result { match format { AuthFormat::Text => { @@ -402,21 +393,25 @@ fn render_whoami_result(profile: &MeResponse, format: AuthFormat) -> Result anyhow::Error { - anyhow!("failed to fetch authenticated user information from the Control Plane: {error}") +fn map_whoami_control_plane_error(error: ControlPlaneError) -> CliError { + let user_error = if error.is_authentication_failure() { + UserError::NotAuthenticated + } else if error.is_storage_failure() { + UserError::AuthStorageUnavailable + } else { + UserError::UnexpectedFailure + }; + + CliError::user_with_source( + user_error, + anyhow!("failed to fetch authenticated user information from the Control Plane: {error}"), + ) } -fn with_try_guidance(message: String, guidance: &str) -> String { - if message.contains("Try:") { - message - } else { - format!("{message} Try: {guidance}") - } +fn auth_storage_error(error: crate::services::token_storage::TokenStorageError) -> CliError { + CliError::user_with_source(UserError::AuthStorageUnavailable, error) } -fn auth_state_path_guidance(action: &str) -> String { - match token_storage::token_file_path() { - Ok(path) => format!("{action}; expected path: '{}'", path.display()), - Err(_) => action.to_string(), - } +fn unexpected_auth_command_error(error: anyhow::Error) -> CliError { + CliError::user_with_source(UserError::UnexpectedFailure, error) } diff --git a/cli/src/services/token_storage.rs b/cli/src/services/token_storage.rs index c6b3a997..ea1dd508 100644 --- a/cli/src/services/token_storage.rs +++ b/cli/src/services/token_storage.rs @@ -1,5 +1,4 @@ use std::fmt; -use std::path::PathBuf; use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; @@ -7,7 +6,6 @@ use serde::{Deserialize, Serialize}; use crate::services::auth::TokenResponse; use crate::services::auth_db::AuthDb; -use crate::services::default_paths::auth_db_path; /// Constant row ID for the single token row in `auth_credentials`. const DEFAULT_TOKEN_ROW_ID: i64 = 1; @@ -160,10 +158,6 @@ pub fn delete_tokens() -> Result { Ok(affected > 0) } -pub fn token_file_path() -> Result { - auth_db_path().map_err(|error| TokenStorageError::PathResolution(error.to_string())) -} - fn current_unix_timestamp_seconds() -> Result { Ok(SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/context/architecture.md b/context/architecture.md index fb7a79ed..994bb0c1 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -118,7 +118,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/capabilities.rs` defines the current broad CLI capability traits consumed by the borrowed, compile-time-typed `AppContext`: `FsOps` with `StdFsOps` for filesystem operations and `GitOps` with `ProcessGitOps` for git command execution plus repository-root/hooks-directory resolution. Existing service internals do not consume these traits directly yet; command execution uses narrow accessors and repo-root-scoped context derivation. - `cli/src/services/lifecycle.rs` defines the current compile-safe lifecycle seam. `ServiceLifecycle` has default no-op generic `diagnose`, `fix`, and `setup` methods over `C: HasRepoRoot`, with lifecycle-owned health, fix, and setup result types so the trait contract is not publicly anchored to doctor/setup module types or the full `AppContext` shape. The same module owns the static `LifecycleProvider` enum and shared `lifecycle_providers(include_hooks)` catalog/factory, returning providers in deterministic order (config → local_db → auth_db → agent_trace_db → hooks when requested); enum dispatch calls each concrete provider through generic context methods without boxed lifecycle-provider allocation or repo-root trait-object context erasure. Hooks exposes a `HooksLifecycle` provider in `cli/src/services/hooks/lifecycle.rs` for hook rollout diagnosis/fix/setup using lifecycle-owned health records plus the canonical required-hook installer. Config exposes a `ConfigLifecycle` provider in `cli/src/services/config/lifecycle.rs` for global/repo-local config validation and repo-local `.sce/config.json` bootstrap. local_db exposes a `LocalDbLifecycle` provider in `cli/src/services/local_db/lifecycle.rs` for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup. auth_db exposes an `AuthDbLifecycle` provider in `cli/src/services/auth_db/lifecycle.rs` for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup. agent_trace_db exposes an `AgentTraceDbLifecycle` provider in `cli/src/services/agent_trace_db/lifecycle.rs` for setup-time repository-scoped Agent Trace storage initialization when a repo root is available and repository Agent Trace DB path health/fix from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path; the former fallback was removed by the `retire-legacy-agent-trace-db` plan). Doctor runtime aggregates the full provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor report/fix records at the orchestration boundary; setup command aggregates the shared catalog for `setup` with hooks included only when requested and adapts hook setup outcomes before rendering setup-owned messages. - Agent Trace lifecycle setup resolves `agent_trace.repository_id` / `agent_trace.repository_remote`, creates/reuses checkout identity for diagnostics, and initializes the repository-scoped DB through `agent_trace_storage::resolve_agent_trace_storage(...)`; hook runtime uses the same storage resolver and `RepositoryAgentTraceDb` lazy fast-path-then-migrate open with bounded retry plus narrow migration-metadata repair for concurrent first-open races. -- `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|logout|whoami`, including device-flow login, stored-credential validation/renewal through login with device-flow fallback, logout, and Control Plane `/me`-backed whoami rendering in text/JSON formats; text mode uses flat `Email`, `First Name`, `Last Name`, `Role`, `Permissions`, and `Organization Name` labels, with optional names and missing role/permissions/workspace values handled deterministically. Logged-out text returns exact login guidance and renewal reports retain the `login` operation label. `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. There is no public renewal or status subcommand. +- `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|logout|whoami`, including device-flow login, stored-credential validation/renewal through login with device-flow fallback, logout, and Control Plane `/me`-backed whoami rendering in text/JSON formats; text mode uses flat `Email`, `First Name`, `Last Name`, `Role`, `Permissions`, and `Organization Name` labels, with optional names and missing role/permissions/workspace values handled deterministically. Expected auth failures are classified at this boundary as typed `CliError::User` entries (`NotAuthenticated` for missing/authentication failures, `AuthStorageUnavailable` for token-storage plus `AuthError::Io`/`Storage` failures, and `UnexpectedFailure` for approved user-facing rendering/prompt fallbacks), with technical sources retained for observability; remaining auth-command failures also use `UnexpectedFailure` rather than a separate runtime mapping. Stored-credential login applies that classification inside `run_login_with_stored_credentials` and its renewal/device-login call path before returning to the command caller. Logged-out text returns exact login guidance and renewal reports retain the `login` operation label. `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. There is no public renewal or status subcommand. - `cli/src/services/db/mod.rs` provides the shared generic Turso infrastructure seam: `DbSpec` supplies a service-specific name, path, ordered embedded migrations, and config-file lookup key (`db_config_key()`), while `TursoDb` owns parent-directory creation, `Builder::new_local(...)` initialization (with `experimental_multiprocess_wal(true)` for safe concurrent access), Turso connection setup, tokio current-thread runtime bridging, retry-backed blocking `execute`/`query`/`query_values`/`query_map` wrappers, and generic migration execution with per-database `__sce_migrations` metadata. `TursoDb::new()` and `EncryptedTursoDb::new()` wrap only their local open/connect block in `run_with_retry_sync` using a config-driven connection-open policy resolved from the `DATABASE_RETRY_CONFIG` `OnceLock` with fallback to hardcoded defaults, while operation methods use a config-driven operation policy from the same source. `query_values()` returns fully fetched column names plus raw `turso::Value` rows for deterministic operator-facing rendering; `query_map()` retries the initial query and row-fetch loop, then applies caller row mapping after retry completion. Migration execution is not retried and uses batch execution so one migration file may contain multiple SQL statements while still recording one migration ID. The same module also provides `EncryptedTursoDb`, a structurally parallel encrypted adapter that resolves the encryption key through `encryption_key::get_or_create_encryption_key()`, enables Turso local encryption with strict `aegis256` cipher selection, and exposes retry-backed synchronous wrappers plus migration execution. `cli/src/services/db/encryption_key.rs` first derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text when present, otherwise falls back to keyring-backed credential-store get-or-create behavior; no plaintext auth DB fallback exists. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 9443425d..93e4fa7c 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -59,7 +59,7 @@ Deferred or gated command surfaces currently avoid claiming unimplemented behavi `setup` defaults to an `inquire` interactive target selection (OpenCode, Claude, Pi, All) and accepts mutually-exclusive non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--all`); the former `--both` flag was removed in favor of `--all` (opencode+claude+pi); the interactive prompt title and target labels reuse shared prompt styling helpers when stdout color is enabled. `setup` also accepts `--bootstrap-context` as a standalone context-only mode that ensures the durable-context baseline without prompts or integration installs; every normal successful setup path also ensures that baseline after the Git gate. `setup` accepts a repeatable `--workflow ` flag selecting which optional workflows to install (currently only `brownfield`). Passing it makes the listed slugs the exact selection for that run; omitting it reuses the persisted `integrations.optional_workflows`, so a repeat run preserves an earlier opt-in. Unknown slugs fail request resolution with a validation error naming the embedded catalog's available slugs and write no files, and `--workflow` is rejected alongside `--bootstrap-context` or on a hooks-only run because neither installs target assets. The resolved selection filters the installed assets and is persisted; see [config precedence contract](config-precedence-contract.md) and [setup local bootstrap](../sce/setup-repo-local-config-bootstrap.md). An interactive `setup` run instead resolves the selection through an `inquire` multi-select shown after the target prompt, titled `Select optional workflows` with one `{title} — {description}` row per optional workflow using the shared prompt styling. Rows are unchecked when nothing is persisted and pre-checked from `integrations.optional_workflows` otherwise (a supplied `--workflow` list seeds them instead); the answered prompt is the run's exact selection. Cancelling either prompt yields the existing `Setup cancelled. No files were changed.` outcome, a non-TTY run keeps the existing actionable guidance, and the prompt is skipped when the catalog has no optional workflow. -`auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `whoami` plus copy-ready next steps. `sce auth login` uses the existing token-validation path whenever stored credentials are present: valid credentials are preserved, expired credentials are refreshed, and a failed renewal falls back to device authorization. First login without stored credentials still starts device authorization, and renewal reports remain labeled as `login` in text and JSON output. Authenticated `sce auth whoami` retrieves the authoritative profile from the Control Plane `GET /me` endpoint and renders flat text labels for `Email`, `First Name`, `Last Name`, `Role`, `Permissions`, and `Organization Name`; missing first/last names render empty and missing role, permissions, or workspace values render `none`. +`auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `whoami` plus copy-ready next steps. `sce auth login` uses the existing token-validation path whenever stored credentials are present: valid credentials are preserved, expired credentials are refreshed, and a failed renewal falls back to device authorization. First login without stored credentials still starts device authorization, and renewal reports remain labeled as `login` in text and JSON output. Expected authentication failures at the auth command boundary are typed as `CliError::User`: missing credentials from `logout`/`whoami` and Control Plane authentication failures from `whoami` render `NotAuthenticated`, token-storage plus `AuthError::Io`/`Storage` failures across `login`, `logout`, and `whoami` render `AuthStorageUnavailable`, and approved user-facing rendering/prompt fallbacks render `UnexpectedFailure`; stored-credential login renewal, token-save, and device-flow paths classify before returning through `run_login_with_stored_credentials`, and technical error chains remain attached for observability. Remaining auth-command failures are surfaced as `UnexpectedFailure` with their technical sources preserved. Authenticated `sce auth whoami` retrieves the authoritative profile from the Control Plane `GET /me` endpoint and renders flat text labels for `Email`, `First Name`, `Last Name`, `Role`, `Permissions`, and `Organization Name`; missing first/last names render empty and missing role, permissions, or workspace values render `none`. `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` all support command-local `--help`/`-h` usage output via top-level parser routing in `cli/src/app.rs`. `setup` now also exposes compile-time embedded config assets for OpenCode/Claude/Pi targets, sourced from the generated `config/.opencode/**`, `config/.claude/**`, and `config/.pi/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. `setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. @@ -97,8 +97,8 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/sync/sync.rs` implements `sce sync` orchestration (control-plane authentication, per-stream reconciliation, and report assembly); local DB initialization and health ownership remain split between setup and doctor. `cli/src/services/sync/command.rs` owns format-gated stderr progress and `cli/src/services/sync/render_sync.rs` owns text/JSON report rendering. See [agent-trace-sync-command.md](agent-trace-sync-command.md). - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. - `cli/src/services/agent_trace.rs` defines the canonical Rust SCE web base URL and helpers for Agent Trace conversation URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created repo-local config schema URLs. -- `cli/src/services/token_storage.rs` defines WorkOS token persistence (`save_tokens`, `load_tokens`, `delete_tokens`) via the encrypted `AuthDb` `auth_credentials` table using a `OnceLock` lazy singleton with constant integer row ID `1`. `token_file_path()` returns the auth DB path. `TokenStorageError` exposes `PathResolution` and `Database` variants. No JSON file I/O remains. -- `cli/src/services/auth_command/mod.rs` defines the auth command orchestration surface (`AuthRequest`, `AuthSubcommand`, `run_auth_subcommand`) for `login`, `logout`, and `whoami`, including shared text/JSON rendering, login's stored-token validation and refresh path for any stored credential, device-flow fallback after absent or unsuccessfully renewed credentials, token-storage-backed logout deletion with path-aware remediation guidance, Control Plane `/me` profile retrieval for authenticated whoami, flat safe-field rendering with optional-name/null-value handling, exact logged-out text guidance, precedence-aware client-ID guidance sourced from the shared auth-runtime resolver instead of env-only assumptions, and a lazily initialized current-thread Tokio runtime with both I/O and time enabled so the auth flows can drive the WorkOS device/refresh paths without the prior I/O-disabled panic; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. +- `cli/src/services/token_storage.rs` defines WorkOS token persistence (`save_tokens`, `load_tokens`, `delete_tokens`) via the encrypted `AuthDb` `auth_credentials` table using a `OnceLock` lazy singleton with constant integer row ID `1`. `TokenStorageError` exposes `PathResolution` and `Database` variants. No JSON file I/O remains. +- `cli/src/services/auth_command/mod.rs` defines the auth command orchestration surface (`AuthRequest`, `AuthSubcommand`, `run_auth_subcommand`) for `login`, `logout`, and `whoami`, including shared text/JSON rendering, login's stored-token validation and refresh path for any stored credential, device-flow fallback after absent or unsuccessfully renewed credentials, source-level typed `CliError` propagation through `run_login_with_stored_credentials` for renewal, token-save, and device-login failures, token-storage-backed logout deletion, Control Plane `/me` profile retrieval for authenticated whoami, flat safe-field rendering with optional-name/null-value handling, exact logged-out text guidance, precedence-aware client-ID guidance sourced from the shared auth-runtime resolver instead of env-only assumptions, and a lazily initialized current-thread Tokio runtime with both I/O and time enabled so the auth flows can drive the WorkOS device/refresh paths without the prior I/O-disabled panic; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/app.rs` parses `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` into service-owned runtime command handlers so runtime messages are sourced from domain modules instead of inline strings. ## Local and Agent Trace Turso adapter behavior diff --git a/context/glossary.md b/context/glossary.md index 771a5d84..53951540 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -66,7 +66,7 @@ - `cli crates.io publication posture`: Current Cargo package posture in `cli/Cargo.toml` where crates.io-facing metadata is publication-ready for the `shared-context-engineering` crate, with crate-facing install guidance owned by `cli/README.md`. - `Nix performance recommendations`: Repo-local operator guidance in `AGENTS.md` covering optional user-level `~/.config/nix/nix.conf` tuning (`max-jobs = auto`, `cores = 0`) and the explicit root/admin-only boundary for `/etc/nix/nix.conf` `auto-optimise-store = true`. - `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|logout|whoami`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), Control Plane `/me`-backed whoami profile output using flat email/name/role/permissions/organization labels, exact logged-out login guidance, implemented setup installation flow including lifecycle-aggregated local DB and Agent Trace DB bootstrap, implemented attribution-only `hooks` subcommand routing/validation entrypoints, and a fully implemented top-level `sce sync` command that synchronizes the current repository's Agent Trace DB with the control plane and renders the documented text/JSON output (see `context/cli/sync-command.md`). -- `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. The related typed local WorkOS credential-storage failure (`ControlPlaneError::Storage`) is currently classified only at the `sce sync` command boundary as `UserError::AuthStorageUnavailable` (`auth.storage_unavailable`), with a fixed actionable terminal message that exposes no storage implementation details or automatic `Try:` suffix while preserving the technical source for structured observability; auth command classification is not yet enabled. +- `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. The auth command boundary classifies missing credentials and Control Plane authentication failures as `UserError::NotAuthenticated`, token-storage plus `AuthError::Io`/`Storage` failures as `UserError::AuthStorageUnavailable` (`auth.storage_unavailable`), and approved user-facing rendering/prompt fallbacks as `UserError::UnexpectedFailure` (`general.unexpected_failure`); fixed catalog messages expose no storage or implementation details while technical sources remain available for structured observability, and remaining auth-command failures use `UnexpectedFailure` with preserved technical sources. - `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. diff --git a/context/overview.md b/context/overview.md index dc1d6553..44474b68 100644 --- a/context/overview.md +++ b/context/overview.md @@ -18,7 +18,7 @@ The generated `/next-task` workflow persists task-level context-synchronization 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`. -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 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. Auth command orchestration routes expected missing/authentication failures, token-storage failures, and approved user-facing fallback failures through typed `CliError::User` catalog entries (`NotAuthenticated`, `AuthStorageUnavailable`, and `UnexpectedFailure`) while retaining technical sources for observability; internal auth failures remain runtime errors. The same runtime also emits stable CLI stderr diagnostics: internal failures use `SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, or `SCE-ERR-DEPENDENCY` in deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing, while expected failures emit only their redacted, unstyled catalog messages. The command boundary's former flat, string-only `ClassifiedError` has been replaced by typed `CliError` in `cli/src/services/error.rs`: the expected-error variant carries a closed catalog (`NotAuthenticated`, the authentication-storage `AuthStorageUnavailable`, and the general `UnexpectedFailure`) for expected, deliberately-explained failures rendered without the technical source, wrapper, styling, or automatic `Try:` guidance; the general entry renders one fixed static log-files guidance sentence without dynamic path text. `CliError::Internal` carries a live `anyhow::Error` source rendered as the real error chain with the existing styled wrapper and class-default remediation; `app_support` is the sole owner of the distinct terminal paths, and `sce sync` classifies authentication, credential-storage, and all other sync failures as cataloged user errors while preserving their technical sources for observability. See `context/sce/cli-error-code-taxonomy.md` for the full contract. The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL` defaults to `error`; `SCE_LOG_FORMAT` defaults to `text`), default-backed log-directory routing (`SCE_LOG_DIR` / config-file `log_dir` / `/sce/logs`) with per-operation machine-local dated file selection, optional session filename partitioning, and creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle event IDs, stderr primary emission so stdout command payloads remain pipe-safe, and `observability::traits` boundaries for logger and telemetry behavior. The app command dispatcher now enforces a centralized stdout/stderr stream contract in `cli/src/app.rs`: command success payloads are emitted on stdout only, while redacted user-facing diagnostics and text-mode sync progress are emitted on stderr; JSON sync remains silent. `cli/src/app.rs` also now runs through explicit startup phases — dependency check, observability config resolution, runtime initialization, command parse/execute, and output rendering — with the app runtime carrying logger/telemetry plus static command-catalog state across those phases while preserving the existing exit-code and degraded-startup contracts. Within that lifecycle, `parse_command_phase` delegates clap-to-runtime conversion to `cli/src/services/parse/command_runtime.rs`, which returns a static `RuntimeCommand` enum, `services::app_support::execute_command_phase` logs around enum-owned `execute_with_stderr(...)` dispatch, and generic `RunOutcome` rendering logs classified errors through the logger trait boundary without coupling render support to the production logger type. Command payload structs for `help`, `version`, `completion`, `auth`, `config`, `setup`, `doctor`, `hooks`, and `sync` live in service-owned `command.rs` files; `cli/src/services/command_registry.rs` owns the deterministic static command-name catalog and enum variants instead of boxed command trait objects. diff --git a/context/sce/cli-error-code-taxonomy.md b/context/sce/cli-error-code-taxonomy.md index d162f651..0c645724 100644 --- a/context/sce/cli-error-code-taxonomy.md +++ b/context/sce/cli-error-code-taxonomy.md @@ -33,11 +33,12 @@ It complements the numeric process exit-code classes documented in `context/sce/ - `FailureClass` in `cli/src/services/error.rs` owns class selection and stable code assignment (`FailureClass::code()`). - `CliError::{User,Internal}` in `cli/src/services/error.rs` is the typed CLI-boundary error type; `CliError::code()`/`CliError::class()` delegate to the failure class. `CliError::User` carries a catalog `UserError` (`NotAuthenticated`, `AuthStorageUnavailable`, or `UnexpectedFailure`) for expected, deliberately-explained failures; `CliError::Internal` carries a live `anyhow::Error` source for every other failure. `CliError::User` may also carry an optional preserved technical `source`, kept for observability only and never rendered to the terminal. -- `UserError` in `cli/src/services/error.rs` is the closed catalog of deliberately presented terminal failures. It has no arbitrary-message variant (no `Message(String)`/`Custom(...)` escape hatch): every entry is a fixed, reviewed sentence returned by `UserError::message()`, keyed for structured logging by `UserError::key()`. `AuthStorageUnavailable` (`auth.storage_unavailable`) is currently used by `sce sync` for typed authentication credential-storage failures. `UnexpectedFailure` (`general.unexpected_failure`) is used by `sce sync` for its default failure classification; it renders one fixed, user-safe diagnostic sentence and has no automatic `Try:` suffix or dynamic path input. -- Command and domain layers construct and return a `CliError`; they do not format terminal text, apply styling, or decide authentication/user-error semantics from string matching. `app_support` is the sole owner of turning a `CliError` into the final stderr sentence. +- `UserError` in `cli/src/services/error.rs` is the closed catalog of deliberately presented terminal failures. It has no arbitrary-message variant (no `Message(String)`/`Custom(...)` escape hatch): every entry is a fixed, reviewed sentence returned by `UserError::message()`, keyed for structured logging by `UserError::key()`. `NotAuthenticated` (`auth.not_authenticated`) covers missing credentials from `sce auth logout`/`whoami` and Control Plane authentication failures from `whoami`; `AuthStorageUnavailable` (`auth.storage_unavailable`) is used by `sce sync` and the `sce auth login`, `logout`, and `whoami` command boundary for token-storage plus `AuthError::Io`/`Storage` failures. Both auth-command mappings preserve technical sources for observability. `UnexpectedFailure` (`general.unexpected_failure`) is used by `sce sync` for its default failure classification and by the auth-command boundary for all non-storage failures, including rendering, prompt, runtime, configuration, and unrelated Control Plane failures; it renders one fixed, user-safe diagnostic sentence and has no automatic `Try:` suffix or dynamic path input. +- Command and domain layers construct and return a `CliError`; they do not format terminal text or apply styling. Auth command orchestration classifies expected authentication and credential-storage failures into the existing `UserError` catalog by typed domain variants, never by string matching, and preserves the original technical chain as the optional user-error source. `app_support` is the sole owner of turning a `CliError` into the final stderr sentence. - `Logger::log_cli_error` in `cli/src/services/observability.rs` owns structured error logging with `sce.error.{code}` event IDs. - `write_error_diagnostic` in `cli/src/services/app_support.rs` owns final stderr rendering: it redacts and writes the catalog variant's message without a wrapper or styling, while `CliError::Internal` retains code-bearing rendering and styles its rendered chain through `services::style::error_text_with_color_policy` under the stderr TTY/`NO_COLOR` policy (`services::style::supports_color_stderr()`), independent of stdout's TTY state. - `run_with_dependency_check_and_streams` in `cli/src/app.rs` owns error logging before stderr emission. +- The auth-command typed user-error boundary is an accepted system-wide contract; see [the auth-command decision](../decisions/2026-08-20-auth-command-typed-user-errors.md) and [the auth fallback decision](../decisions/2026-08-20-auth-command-unexpected-fallbacks.md). ## Determinism and testing From 8d52c2057d0ea9057f0a1797a3cb0191eba3e07e Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Fri, 21 Aug 2026 10:52:04 +0200 Subject: [PATCH 3/3] runtime: Add typed setup command error handling Expose missing Git repository failures through the stable user-error catalog while preserving technical sources for observability. Co-authored-by: SCE --- cli/src/services/auth_command/mod.rs | 4 ++-- cli/src/services/config/command.rs | 5 +++-- cli/src/services/doctor/command.rs | 5 +++-- cli/src/services/error.rs | 29 +++++++++++++++++++++++--- cli/src/services/setup/command.rs | 20 +++++++++++------- cli/src/services/version/command.rs | 5 +++-- context/overview.md | 4 ++-- context/sce/cli-error-code-taxonomy.md | 6 +++--- context/sce/setup-githooks-cli-ux.md | 2 +- 9 files changed, 55 insertions(+), 25 deletions(-) diff --git a/cli/src/services/auth_command/mod.rs b/cli/src/services/auth_command/mod.rs index 9c8158aa..cf442757 100644 --- a/cli/src/services/auth_command/mod.rs +++ b/cli/src/services/auth_command/mod.rs @@ -104,7 +104,7 @@ pub fn run_whoami(format: AuthFormat) -> Result { let profile = shared_runtime() .map_err(unexpected_auth_command_error)? .block_on(client.me()) - .map_err(map_whoami_control_plane_error)?; + .map_err(|error| map_whoami_control_plane_error(&error))?; render_whoami_result(&profile, format).map_err(unexpected_auth_command_error) } @@ -393,7 +393,7 @@ fn render_whoami_result(profile: &MeResponse, format: AuthFormat) -> Result CliError { +fn map_whoami_control_plane_error(error: &ControlPlaneError) -> CliError { let user_error = if error.is_authentication_failure() { UserError::NotAuthenticated } else if error.is_storage_failure() { diff --git a/cli/src/services/config/command.rs b/cli/src/services/config/command.rs index 0f7385a0..af7a6fff 100644 --- a/cli/src/services/config/command.rs +++ b/cli/src/services/config/command.rs @@ -1,5 +1,5 @@ use crate::services::config; -use crate::services::error::CliError; +use crate::services::error::{CliError, UserError}; pub struct ConfigCommand { pub subcommand: config::ConfigSubcommand, @@ -7,6 +7,7 @@ pub struct ConfigCommand { impl ConfigCommand { pub fn execute(&self, _context: &C) -> Result { - config::run_config_subcommand(self.subcommand.clone()).map_err(CliError::runtime) + config::run_config_subcommand(self.subcommand.clone()) + .map_err(|source| CliError::user_with_source(UserError::UnexpectedFailure, source)) } } diff --git a/cli/src/services/doctor/command.rs b/cli/src/services/doctor/command.rs index 3edf1029..a8b8f40c 100644 --- a/cli/src/services/doctor/command.rs +++ b/cli/src/services/doctor/command.rs @@ -1,6 +1,6 @@ use crate::app::ContextWithRepoRoot; use crate::services::doctor; -use crate::services::error::CliError; +use crate::services::error::{CliError, UserError}; pub struct DoctorCommand { pub request: doctor::DoctorRequest, @@ -8,6 +8,7 @@ pub struct DoctorCommand { impl DoctorCommand { pub fn execute(&self, context: &C) -> Result { - doctor::run_doctor_with_context(self.request, context).map_err(CliError::runtime) + doctor::run_doctor_with_context(self.request, context) + .map_err(|source| CliError::user_with_source(UserError::UnexpectedFailure, source)) } } diff --git a/cli/src/services/error.rs b/cli/src/services/error.rs index f83d68ef..86bd06d2 100644 --- a/cli/src/services/error.rs +++ b/cli/src/services/error.rs @@ -55,6 +55,7 @@ pub enum UserError { #[allow(dead_code)] NotAuthenticated, AuthStorageUnavailable, + NotGitRepository, #[allow(dead_code)] UnexpectedFailure, } @@ -62,9 +63,10 @@ pub enum UserError { impl UserError { pub fn class(self) -> FailureClass { match self { - Self::NotAuthenticated | Self::AuthStorageUnavailable | Self::UnexpectedFailure => { - FailureClass::Runtime - } + Self::NotAuthenticated + | Self::AuthStorageUnavailable + | Self::NotGitRepository + | Self::UnexpectedFailure => FailureClass::Runtime, } } @@ -73,6 +75,7 @@ impl UserError { match self { Self::NotAuthenticated => "auth.not_authenticated", Self::AuthStorageUnavailable => "auth.storage_unavailable", + Self::NotGitRepository => "setup.not_git_repository", Self::UnexpectedFailure => "general.unexpected_failure", } } @@ -85,6 +88,9 @@ impl UserError { Self::AuthStorageUnavailable => { "Authentication storage is unavailable. Verify local credential storage is available, then retry the command." } + Self::NotGitRepository => { + "This directory is not a Git repository. Run `git init`, then rerun `sce setup`." + } Self::UnexpectedFailure => { "An unexpected error occurred. Check the log files for more details." } @@ -203,6 +209,23 @@ mod tests { assert!(error.to_string().contains("You are not logged in")); } + #[test] + fn not_git_repository_has_stable_runtime_catalog_mapping() { + let error = CliError::user(UserError::NotGitRepository); + + assert_eq!(error.class(), FailureClass::Runtime); + assert_eq!(error.code(), "SCE-ERR-RUNTIME"); + assert_eq!( + UserError::NotGitRepository.key(), + "setup.not_git_repository" + ); + assert_eq!( + UserError::NotGitRepository.message(), + "This directory is not a Git repository. Run `git init`, then rerun `sce setup`." + ); + assert_eq!(error.to_string(), UserError::NotGitRepository.message()); + } + #[test] fn unexpected_failure_has_stable_runtime_catalog_mapping() { let error = CliError::user(UserError::UnexpectedFailure); diff --git a/cli/src/services/setup/command.rs b/cli/src/services/setup/command.rs index 86bb6fe3..ede78032 100644 --- a/cli/src/services/setup/command.rs +++ b/cli/src/services/setup/command.rs @@ -1,7 +1,7 @@ use anyhow::Context; use crate::app::ContextWithRepoRoot; -use crate::services::error::CliError; +use crate::services::error::{CliError, UserError}; use crate::services::lifecycle::{ lifecycle_providers, RequiredHookInstallStatus, RequiredHooksInstallOutcome, }; @@ -17,13 +17,13 @@ impl SetupCommand { Some(path) => path.clone(), None => std::env::current_dir() .context("Failed to determine current directory") - .map_err(CliError::runtime)?, + .map_err(unexpected_failure)?, }; // The repository root is resolved before any prompt so the interactive // optional-workflow prompt can pre-check the persisted selection. - let repository_root = - setup::ensure_git_repository(&setup_start_path).map_err(CliError::runtime)?; + let repository_root = setup::ensure_git_repository(&setup_start_path) + .map_err(|source| CliError::user_with_source(UserError::NotGitRepository, source))?; let setup_dispatch = if self.request.context_only { None @@ -40,7 +40,7 @@ impl SetupCommand { &setup::InquireSetupTargetPrompter, &optional_workflow_defaults, ) - .map_err(CliError::runtime)? + .map_err(unexpected_failure)? { setup::SetupDispatch::Proceed { mode: resolved_mode, @@ -58,7 +58,7 @@ impl SetupCommand { // Every successful setup path ensures the durable-context baseline exists. let context_message = - setup::bootstrap_context_baseline(&repository_root).map_err(CliError::runtime)?; + setup::bootstrap_context_baseline(&repository_root).map_err(unexpected_failure)?; sections.push(context_message); if self.request.context_only { @@ -73,7 +73,7 @@ impl SetupCommand { let providers = lifecycle_providers(self.request.install_hooks); for provider in &providers { - let outcome = provider.setup(&ctx).map_err(CliError::runtime)?; + let outcome = provider.setup(&ctx).map_err(unexpected_failure)?; sections.extend(outcome.messages); @@ -94,7 +94,7 @@ impl SetupCommand { let setup_message = setup::run_setup_for_mode(&repository_root, resolved_mode, optional_workflows) - .map_err(CliError::runtime)?; + .map_err(unexpected_failure)?; sections.push(setup_message); } @@ -102,6 +102,10 @@ impl SetupCommand { } } +fn unexpected_failure(source: impl Into) -> CliError { + CliError::user_with_source(UserError::UnexpectedFailure, source) +} + fn setup_required_hooks_outcome_from_lifecycle( outcome: &RequiredHooksInstallOutcome, ) -> setup::RequiredHooksInstallOutcome { diff --git a/cli/src/services/version/command.rs b/cli/src/services/version/command.rs index c5fd2ae4..5056ca32 100644 --- a/cli/src/services/version/command.rs +++ b/cli/src/services/version/command.rs @@ -1,4 +1,4 @@ -use crate::services::error::CliError; +use crate::services::error::{CliError, UserError}; use crate::services::version; pub struct VersionCommand { @@ -7,6 +7,7 @@ pub struct VersionCommand { impl VersionCommand { pub fn execute(&self, _context: &C) -> Result { - version::render_version(self.request).map_err(CliError::runtime) + version::render_version(self.request) + .map_err(|source| CliError::user_with_source(UserError::UnexpectedFailure, source)) } } diff --git a/context/overview.md b/context/overview.md index 44474b68..3c6da9e5 100644 --- a/context/overview.md +++ b/context/overview.md @@ -18,8 +18,8 @@ The generated `/next-task` workflow persists task-level context-synchronization 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`. -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. Auth command orchestration routes expected missing/authentication failures, token-storage failures, and approved user-facing fallback failures through typed `CliError::User` catalog entries (`NotAuthenticated`, `AuthStorageUnavailable`, and `UnexpectedFailure`) while retaining technical sources for observability; internal auth failures remain runtime errors. -The same runtime also emits stable CLI stderr diagnostics: internal failures use `SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, or `SCE-ERR-DEPENDENCY` in deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing, while expected failures emit only their redacted, unstyled catalog messages. The command boundary's former flat, string-only `ClassifiedError` has been replaced by typed `CliError` in `cli/src/services/error.rs`: the expected-error variant carries a closed catalog (`NotAuthenticated`, the authentication-storage `AuthStorageUnavailable`, and the general `UnexpectedFailure`) for expected, deliberately-explained failures rendered without the technical source, wrapper, styling, or automatic `Try:` guidance; the general entry renders one fixed static log-files guidance sentence without dynamic path text. `CliError::Internal` carries a live `anyhow::Error` source rendered as the real error chain with the existing styled wrapper and class-default remediation; `app_support` is the sole owner of the distinct terminal paths, and `sce sync` classifies authentication, credential-storage, and all other sync failures as cataloged user errors while preserving their technical sources for observability. See `context/sce/cli-error-code-taxonomy.md` for the full contract. +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. Auth command orchestration routes expected missing/authentication failures, token-storage failures, and approved user-facing fallback failures through typed `CliError::User` catalog entries (`NotAuthenticated`, `AuthStorageUnavailable`, `NotGitRepository`, and `UnexpectedFailure`) while retaining technical sources for observability; internal auth failures remain runtime errors. +The same runtime also emits stable CLI stderr diagnostics: internal failures use `SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, or `SCE-ERR-DEPENDENCY` in deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing, while expected failures emit only their redacted, unstyled catalog messages. The command boundary's former flat, string-only `ClassifiedError` has been replaced by typed `CliError` in `cli/src/services/error.rs`: the expected-error variant carries a closed catalog (`NotAuthenticated`, the authentication-storage `AuthStorageUnavailable`, the setup `NotGitRepository`, and the general `UnexpectedFailure`) for expected, deliberately-explained failures rendered without the technical source, wrapper, styling, or automatic `Try:` guidance; the general entry renders one fixed static log-files guidance sentence without dynamic path text. `CliError::Internal` carries a live `anyhow::Error` source rendered as the real error chain with the existing styled wrapper and class-default remediation; `app_support` is the sole owner of the distinct terminal paths, and `sce sync` classifies authentication, credential-storage, and all other sync failures as cataloged user errors while preserving their technical sources for observability. See `context/sce/cli-error-code-taxonomy.md` for the full contract. The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL` defaults to `error`; `SCE_LOG_FORMAT` defaults to `text`), default-backed log-directory routing (`SCE_LOG_DIR` / config-file `log_dir` / `/sce/logs`) with per-operation machine-local dated file selection, optional session filename partitioning, and creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle event IDs, stderr primary emission so stdout command payloads remain pipe-safe, and `observability::traits` boundaries for logger and telemetry behavior. The app command dispatcher now enforces a centralized stdout/stderr stream contract in `cli/src/app.rs`: command success payloads are emitted on stdout only, while redacted user-facing diagnostics and text-mode sync progress are emitted on stderr; JSON sync remains silent. `cli/src/app.rs` also now runs through explicit startup phases — dependency check, observability config resolution, runtime initialization, command parse/execute, and output rendering — with the app runtime carrying logger/telemetry plus static command-catalog state across those phases while preserving the existing exit-code and degraded-startup contracts. Within that lifecycle, `parse_command_phase` delegates clap-to-runtime conversion to `cli/src/services/parse/command_runtime.rs`, which returns a static `RuntimeCommand` enum, `services::app_support::execute_command_phase` logs around enum-owned `execute_with_stderr(...)` dispatch, and generic `RunOutcome` rendering logs classified errors through the logger trait boundary without coupling render support to the production logger type. Command payload structs for `help`, `version`, `completion`, `auth`, `config`, `setup`, `doctor`, `hooks`, and `sync` live in service-owned `command.rs` files; `cli/src/services/command_registry.rs` owns the deterministic static command-name catalog and enum variants instead of boxed command trait objects. The CLI now also enforces a shared output-format parser contract in `cli/src/services/output_format.rs`, with canonical `--format ` parsing and command-specific actionable invalid-value guidance reused by `config` and `version` services. A compile-safe service lifecycle seam also exists in `cli/src/services/lifecycle.rs`: `ServiceLifecycle` exposes default no-op `diagnose`, `fix`, and `setup` methods against the narrow `HasRepoRoot` accessor, uses lifecycle-owned health/fix/setup result types, and owns the shared static `LifecycleProvider` enum catalog/factory with deterministic config → local_db → auth_db → agent_trace_db → hooks ordering and no boxed provider aggregation. Hooks has a `services/hooks/lifecycle.rs` provider for hook rollout diagnosis/fix/setup, config has a `services/config/lifecycle.rs` provider for global/repo-local config validation plus repo-local config bootstrap, local_db has a `services/local_db/lifecycle.rs` provider for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup, auth_db has a `services/auth_db/lifecycle.rs` provider for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup, and agent_trace_db has a `services/agent_trace_db/lifecycle.rs` provider for repository-scoped Agent Trace DB setup and repository DB path health/parent readiness from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path). Doctor runtime aggregates the full shared provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor-owned output records; setup command aggregates the shared provider catalog for `setup` with hooks included only when requested and adapts lifecycle setup outcomes before rendering setup-owned messages. diff --git a/context/sce/cli-error-code-taxonomy.md b/context/sce/cli-error-code-taxonomy.md index 0c645724..33d9a685 100644 --- a/context/sce/cli-error-code-taxonomy.md +++ b/context/sce/cli-error-code-taxonomy.md @@ -18,7 +18,7 @@ It complements the numeric process exit-code classes documented in `context/sce/ - `CliError::Internal` diagnostics are emitted on `stderr` as the styled `Error []: ` wrapper. - Before stderr emission, all `CliError` instances are logged via `Logger::log_cli_error()` with event ID `sce.error.{code}` and fields `error_code`, `error_class`. - For `CliError::Internal`, if the rendered message does not already include `Try:`, runtime appends class-default remediation guidance; if it already contains `Try:`, runtime preserves the original remediation text and does not append a second one. -- For `CliError::User`, runtime renders the catalog message from `UserError` without technical source text or class-default `Try:` remediation. The `UserError::UnexpectedFailure` entry renders the fixed message `An unexpected error occurred. Check the log files for more details.` without dynamic path interpolation. +- For `CliError::User`, runtime renders the catalog message from `UserError` without technical source text or class-default `Try:` remediation. The `UserError::NotGitRepository` entry renders the fixed setup guidance `This directory is not a Git repository. Run \`git init\`, then rerun \`sce setup\`.`. The `UserError::UnexpectedFailure` entry renders the fixed message `An unexpected error occurred. Check the log files for more details.` without dynamic path interpolation. - Diagnostic text is still redaction-filtered through `services::security::redact_sensitive_text` before emission. ## Actionable parser/invocation guidance contract @@ -32,8 +32,8 @@ It complements the numeric process exit-code classes documented in `context/sce/ ## Ownership - `FailureClass` in `cli/src/services/error.rs` owns class selection and stable code assignment (`FailureClass::code()`). -- `CliError::{User,Internal}` in `cli/src/services/error.rs` is the typed CLI-boundary error type; `CliError::code()`/`CliError::class()` delegate to the failure class. `CliError::User` carries a catalog `UserError` (`NotAuthenticated`, `AuthStorageUnavailable`, or `UnexpectedFailure`) for expected, deliberately-explained failures; `CliError::Internal` carries a live `anyhow::Error` source for every other failure. `CliError::User` may also carry an optional preserved technical `source`, kept for observability only and never rendered to the terminal. -- `UserError` in `cli/src/services/error.rs` is the closed catalog of deliberately presented terminal failures. It has no arbitrary-message variant (no `Message(String)`/`Custom(...)` escape hatch): every entry is a fixed, reviewed sentence returned by `UserError::message()`, keyed for structured logging by `UserError::key()`. `NotAuthenticated` (`auth.not_authenticated`) covers missing credentials from `sce auth logout`/`whoami` and Control Plane authentication failures from `whoami`; `AuthStorageUnavailable` (`auth.storage_unavailable`) is used by `sce sync` and the `sce auth login`, `logout`, and `whoami` command boundary for token-storage plus `AuthError::Io`/`Storage` failures. Both auth-command mappings preserve technical sources for observability. `UnexpectedFailure` (`general.unexpected_failure`) is used by `sce sync` for its default failure classification and by the auth-command boundary for all non-storage failures, including rendering, prompt, runtime, configuration, and unrelated Control Plane failures; it renders one fixed, user-safe diagnostic sentence and has no automatic `Try:` suffix or dynamic path input. +- `CliError::{User,Internal}` in `cli/src/services/error.rs` is the typed CLI-boundary error type; `CliError::code()`/`CliError::class()` delegate to the failure class. `CliError::User` carries a catalog `UserError` (`NotAuthenticated`, `AuthStorageUnavailable`, `NotGitRepository`, or `UnexpectedFailure`) for expected, deliberately-explained failures; `CliError::Internal` carries a live `anyhow::Error` source for every other failure. `CliError::User` may also carry an optional preserved technical `source`, kept for observability only and never rendered to the terminal. +- `UserError` in `cli/src/services/error.rs` is the closed catalog of deliberately presented terminal failures. It has no arbitrary-message variant (no `Message(String)`/`Custom(...)` escape hatch): every entry is a fixed, reviewed sentence returned by `UserError::message()`, keyed for structured logging by `UserError::key()`. `NotAuthenticated` (`auth.not_authenticated`) covers missing credentials from `sce auth logout`/`whoami` and Control Plane authentication failures from `whoami`; `AuthStorageUnavailable` (`auth.storage_unavailable`) is used by `sce sync` and the `sce auth login`, `logout`, and `whoami` command boundary for token-storage plus `AuthError::Io`/`Storage` failures. Both auth-command mappings preserve technical sources for observability. `NotGitRepository` (`setup.not_git_repository`) is used by the setup command when repository-root resolution fails and renders fixed Git-init/rerun guidance; its technical source is preserved for observability. `UnexpectedFailure` (`general.unexpected_failure`) is used by `sce sync`, the config-command boundary for config execution failures, the version and doctor command boundaries for service execution failures, the auth-command boundary, and remaining setup execution failures; it renders one fixed, user-safe diagnostic sentence and has no automatic `Try:` suffix or dynamic path input. - Command and domain layers construct and return a `CliError`; they do not format terminal text or apply styling. Auth command orchestration classifies expected authentication and credential-storage failures into the existing `UserError` catalog by typed domain variants, never by string matching, and preserves the original technical chain as the optional user-error source. `app_support` is the sole owner of turning a `CliError` into the final stderr sentence. - `Logger::log_cli_error` in `cli/src/services/observability.rs` owns structured error logging with `sce.error.{code}` event IDs. - `write_error_diagnostic` in `cli/src/services/app_support.rs` owns final stderr rendering: it redacts and writes the catalog variant's message without a wrapper or styling, while `CliError::Internal` retains code-bearing rendering and styles its rendered chain through `services::style::error_text_with_color_policy` under the stderr TTY/`NO_COLOR` policy (`services::style::supports_color_stderr()`), independent of stdout's TTY state. diff --git a/context/sce/setup-githooks-cli-ux.md b/context/sce/setup-githooks-cli-ux.md index 836fadd3..a9252fb5 100644 --- a/context/sce/setup-githooks-cli-ux.md +++ b/context/sce/setup-githooks-cli-ux.md @@ -25,7 +25,7 @@ Validation is deterministic and enforced during setup option resolution: - `--hooks` can be combined with exactly one target flag to run config install and required-hook install in one invocation - `--repo` may only be provided once and must include a value - `--repo` path is canonicalized and must resolve to an existing directory before hook setup runs -- repository-required hook flows fail before config or hook writes when the target directory is not a git repository, with actionable guidance to run `git init` and rerun `sce setup` +- repository-required hook flows fail before config or hook writes when the target directory is not a Git repository, rendering `This directory is not a Git repository. Run \`git init\`, then rerun \`sce setup\`.` from the closed runtime user-error catalog; the technical repository-resolution source is retained for observability - all `sce setup` modes (config-only, hooks-only, combined, and interactive) require the current directory to be inside a git repository before any setup writes begin; the `ensure_git_repository` preflight check in `cli/src/app.rs` enforces this gate consistently across all invocation shapes Target-install mode contract: