Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/cli-ux/cli-ux-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Cage CLI baseline contract

Status: pre-alpha. This document describes the command surface introduced by Issue #5. Unless an
owning feature issue says otherwise, parsing a command ends with an explicit not-implemented error;
no command is a silent no-op.

## Global behavior

| Item | Contract |
|---|---|
| Prefix | Cage-owned messages begin with `[cage]`. |
| Color | Disabled by `--no-color`, `NO_COLOR`, or non-TTY output. |
| Verbose output | `-v` / `--verbose` is accepted globally; feature owners define added diagnostics. |
| Help and version | `--help` and `--version` print through clap and exit 0. |
| Invalid arguments | clap diagnostic, exit 1. |
| Unimplemented command | `[cage]` error with a cause and next action, exit 1. |

Stable feature exit codes are 0 for success, 1 for general or argument errors, 2 for security
errors, 3 for container errors, 4 for authentication errors, and 130 for user interruption.

## Command tree

```text
cage <COMMAND>
run [AGENT_OR_PROFILE] [OPTIONS] -- [AGENT_ARGS]...
sync [OPTIONS]
diff [OPTIONS]
team <up|down|status>
config <get|set|list|edit|validate>
images <list|pull|remove|prune>
update [--check]
```

Agent arguments are accepted only after `--`, so agent-owned flags cannot be interpreted as Cage
flags. `run`, `sync`, and `diff` expose the reserved inputs shown by their `--help` output. The
owning feature issues must replace the common not-implemented dispatch error when they add real
behavior; they must not leave parse-only flags or successful no-ops.

## Message styles

| Kind | Plain-text form |
|---|---|
| Information | `[cage] message` |
| Success | `[cage] ✓ message` |
| Warning | `[cage] ⚠ message` |
| Error | `[cage] ✗ message` |
| Action | `[cage] → message` |

User-facing errors include a summary followed by `cause:` and `next:` lines.
99 changes: 99 additions & 0 deletions src/cli/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! User-facing CLI errors and stable process exit codes.

use std::fmt;

/// Stable process exit codes documented by the Cage CLI contract.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(i32)]
pub enum ExitCode {
Success = 0,
General = 1,
Security = 2,
Container = 3,
Auth = 4,
Interrupted = 130,
}

impl ExitCode {
#[must_use]
pub const fn as_i32(self) -> i32 {
self as i32
}
}

/// An error that carries both an explanation and an actionable next step.
#[derive(Debug)]
pub struct CageError {
summary: String,
cause: String,
next: String,
exit_code: ExitCode,
}

impl CageError {
#[must_use]
pub fn new(
summary: impl Into<String>,
cause: impl Into<String>,
next: impl Into<String>,
exit_code: ExitCode,
) -> Self {
Self {
summary: summary.into(),
cause: cause.into(),
next: next.into(),
exit_code,
}
}

#[must_use]
pub fn not_implemented(command: &str) -> Self {
Self::new(
format!("the `{command}` command is not implemented yet"),
"this command surface is reserved for its owning implementation issue",
format!("follow the owning issue before relying on `cage {command}`"),
ExitCode::General,
)
}

#[must_use]
pub const fn exit_code(&self) -> ExitCode {
self.exit_code
}
}

impl fmt::Display for CageError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{}\n\n cause: {}\n next: {}",
self.summary, self.cause, self.next
)
}
}

impl std::error::Error for CageError {}

#[cfg(test)]
mod tests {
use super::{CageError, ExitCode};

#[test]
fn exit_codes_remain_stable() {
assert_eq!(ExitCode::Success.as_i32(), 0);
assert_eq!(ExitCode::General.as_i32(), 1);
assert_eq!(ExitCode::Security.as_i32(), 2);
assert_eq!(ExitCode::Container.as_i32(), 3);
assert_eq!(ExitCode::Auth.as_i32(), 4);
assert_eq!(ExitCode::Interrupted.as_i32(), 130);
}

#[test]
fn display_includes_cause_and_next_action() {
let error = CageError::new("failed", "bad input", "fix the input", ExitCode::General);
let rendered = error.to_string();

assert!(rendered.contains("cause: bad input"));
assert!(rendered.contains("next: fix the input"));
}
}
Loading