1 Commits

Author SHA1 Message Date
Yeachan-Heo
2d09bf9961 Make sandbox isolation behavior explicit and inspectable
This adds a small runtime sandbox policy/status layer, threads
sandbox options through the bash tool, and exposes `/sandbox`
status reporting in the CLI. Linux namespace/network isolation
is best-effort and intentionally reported as requested vs active
so the feature does not overclaim guarantees on unsupported
hosts or nested container environments.

Constraint: No new dependencies for isolation support
Constraint: Must keep filesystem restriction claims honest unless hard mount isolation succeeds
Rejected: External sandbox/container wrapper | too heavy for this workspace and request
Rejected: Inline bash-only changes without shared status model | weaker testability and poorer CLI visibility
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Treat this as observable best-effort isolation, not a hard security boundary, unless stronger mount enforcement is added later
Tested: cargo fmt --all; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo test --workspace
Not-tested: Manual `/sandbox` REPL run on a real nested-container host
2026-04-01 01:14:38 +00:00
25 changed files with 922 additions and 1342 deletions

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"What is 2+2? Reply with just the number.","type":"text"}],"role":"user"},{"blocks":[{"text":"4","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":19,"output_tokens":5}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"Say hello in exactly 3 words","type":"text"}],"role":"user"},{"blocks":[{"text":"Hello there, friend!","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":14,"output_tokens":8}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"Say hi in one sentence","type":"text"}],"role":"user"},{"blocks":[{"text":"Hi! I'm Claude, ready to help you with any software engineering tasks or questions you have.","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":11,"output_tokens":23}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

139
rust/Cargo.lock generated
View File

@@ -98,15 +98,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]] [[package]]
name = "commands" name = "commands"
version = "0.1.0" version = "0.1.0"
@@ -151,7 +142,7 @@ dependencies = [
"crossterm_winapi", "crossterm_winapi",
"mio", "mio",
"parking_lot", "parking_lot",
"rustix 0.38.44", "rustix",
"signal-hook", "signal-hook",
"signal-hook-mio", "signal-hook-mio",
"winapi", "winapi",
@@ -206,12 +197,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -228,23 +213,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "fd-lock"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.1.4",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -383,15 +351,6 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -655,12 +614,6 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
@@ -716,27 +669,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "nibble_vec"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
dependencies = [
"smallvec",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.1"
@@ -956,16 +888,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radix_trie"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
dependencies = [
"endian-type",
"nibble_vec",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.9.2"
@@ -1115,23 +1037,10 @@ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.37" version = "0.23.37"
@@ -1183,35 +1092,12 @@ dependencies = [
"crossterm", "crossterm",
"pulldown-cmark", "pulldown-cmark",
"runtime", "runtime",
"rustyline",
"serde_json", "serde_json",
"syntect", "syntect",
"tokio", "tokio",
"tools", "tools",
] ]
[[package]]
name = "rustyline"
version = "15.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f"
dependencies = [
"bitflags",
"cfg-if",
"clipboard-win",
"fd-lock",
"home",
"libc",
"log",
"memchr",
"nix",
"radix_trie",
"unicode-segmentation",
"unicode-width",
"utf8parse",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@@ -1639,12 +1525,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.2" version = "0.2.2"
@@ -1675,12 +1555,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -1851,15 +1725,6 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"

View File

@@ -84,15 +84,6 @@ cargo run -p rusty-claude-cli -- logout
This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`. This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`.
### Self-update
```bash
cd rust
cargo run -p rusty-claude-cli -- self-update
```
The command checks the latest GitHub release for `instructkr/clawd-code`, compares it to the current binary version, downloads the matching binary asset plus checksum manifest, verifies SHA-256, replaces the current executable, and prints the release changelog. If no published release or matching asset exists, it exits safely with an explanatory message.
## Usage examples ## Usage examples
### 1) Prompt mode ### 1) Prompt mode
@@ -118,13 +109,6 @@ cd rust
cargo run -p rusty-claude-cli -- --allowedTools read,glob cargo run -p rusty-claude-cli -- --allowedTools read,glob
``` ```
Bootstrap Claude project files for the current repo:
```bash
cd rust
cargo run -p rusty-claude-cli -- init
```
### 2) REPL mode ### 2) REPL mode
Start the interactive shell: Start the interactive shell:
@@ -149,7 +133,6 @@ Inside the REPL, useful commands include:
/diff /diff
/version /version
/export notes.txt /export notes.txt
/sessions
/session list /session list
/exit /exit
``` ```
@@ -160,14 +143,14 @@ Inspect or maintain a saved session file without entering the REPL:
```bash ```bash
cd rust cd rust
cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost cargo run -p rusty-claude-cli -- --resume session.json /status /compact /cost
``` ```
You can also inspect memory/config state for a restored session: You can also inspect memory/config state for a restored session:
```bash ```bash
cd rust cd rust
cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config cargo run -p rusty-claude-cli -- --resume session.json /memory /config
``` ```
## Available commands ## Available commands
@@ -175,11 +158,10 @@ cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json
### Top-level CLI commands ### Top-level CLI commands
- `prompt <text...>` — run one prompt non-interactively - `prompt <text...>` — run one prompt non-interactively
- `--resume <session-id-or-path> [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/` - `--resume <session.json> [/commands...]` — inspect or maintain a saved session
- `dump-manifests` — print extracted upstream manifest counts - `dump-manifests` — print extracted upstream manifest counts
- `bootstrap-plan` — print the current bootstrap skeleton - `bootstrap-plan` — print the current bootstrap skeleton
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt - `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
- `self-update` — update the installed binary from the latest GitHub release when a matching asset is available
- `--help` / `-h` — show CLI help - `--help` / `-h` — show CLI help
- `--version` / `-V` — print the CLI version and build info locally (no API call) - `--version` / `-V` — print the CLI version and build info locally (no API call)
- `--output-format text|json` — choose non-interactive prompt output rendering - `--output-format text|json` — choose non-interactive prompt output rendering
@@ -194,14 +176,13 @@ cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions - `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
- `/clear [--confirm]` — clear the current local session - `/clear [--confirm]` — clear the current local session
- `/cost` — show token usage totals - `/cost` — show token usage totals
- `/resume <session-id-or-path>` — load a saved session into the REPL - `/resume <session-path>` — load a saved session into the REPL
- `/config [env|hooks|model]` — inspect discovered Claude config - `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files - `/memory` — inspect loaded instruction memory files
- `/init`bootstrap `.claude.json`, `.claude/`, `CLAUDE.md`, and local ignore rules - `/init`create a starter `CLAUDE.md`
- `/diff` — show the current git diff for the workspace - `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally - `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript - `/export [file]` — export the current conversation transcript
- `/sessions` — list recent managed local sessions from `~/.claude/sessions/`
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions - `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL - `/exit` — leave the REPL

View File

@@ -520,8 +520,7 @@ fn read_auth_token() -> Option<String> {
.and_then(std::convert::identity) .and_then(std::convert::identity)
} }
#[must_use] fn read_base_url() -> String {
pub fn read_base_url() -> String {
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
} }
@@ -907,7 +906,7 @@ mod tests {
#[test] #[test]
fn message_request_stream_helper_sets_stream_true() { fn message_request_stream_helper_sets_stream_true() {
let request = MessageRequest { let request = MessageRequest {
model: "claude-opus-4-6".to_string(), model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64, max_tokens: 64,
messages: vec![], messages: vec![],
system: None, system: None,

View File

@@ -4,8 +4,8 @@ mod sse;
mod types; mod types;
pub use client::{ pub use client::{
oauth_token_is_expired, read_base_url, resolve_saved_oauth_token, oauth_token_is_expired, resolve_saved_oauth_token, resolve_startup_auth_source,
resolve_startup_auth_source, AnthropicClient, AuthSource, MessageStream, OAuthTokenSet, AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
}; };
pub use error::ApiError; pub use error::ApiError;
pub use sse::{parse_frame, SseParser}; pub use sse::{parse_frame, SseParser};

View File

@@ -51,6 +51,12 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None, argument_hint: None,
resume_supported: true, resume_supported: true,
}, },
SlashCommandSpec {
name: "sandbox",
summary: "Show sandbox isolation status",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec { SlashCommandSpec {
name: "compact", name: "compact",
summary: "Compact local session history", summary: "Compact local session history",
@@ -135,6 +141,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
pub enum SlashCommand { pub enum SlashCommand {
Help, Help,
Status, Status,
Sandbox,
Compact, Compact,
Model { Model {
model: Option<String>, model: Option<String>,
@@ -179,6 +186,7 @@ impl SlashCommand {
Some(match command { Some(match command {
"help" => Self::Help, "help" => Self::Help,
"status" => Self::Status, "status" => Self::Status,
"sandbox" => Self::Sandbox,
"compact" => Self::Compact, "compact" => Self::Compact,
"model" => Self::Model { "model" => Self::Model {
model: parts.next().map(ToOwned::to_owned), model: parts.next().map(ToOwned::to_owned),
@@ -279,6 +287,7 @@ pub fn handle_slash_command(
session: session.clone(), session: session.clone(),
}), }),
SlashCommand::Status SlashCommand::Status
| SlashCommand::Sandbox
| SlashCommand::Model { .. } | SlashCommand::Model { .. }
| SlashCommand::Permissions { .. } | SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. } | SlashCommand::Clear { .. }
@@ -307,6 +316,7 @@ mod tests {
fn parses_supported_slash_commands() { fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
assert_eq!(SlashCommand::parse("/sandbox"), Some(SlashCommand::Sandbox));
assert_eq!( assert_eq!(
SlashCommand::parse("/model claude-opus"), SlashCommand::parse("/model claude-opus"),
Some(SlashCommand::Model { Some(SlashCommand::Model {
@@ -373,6 +383,7 @@ mod tests {
assert!(help.contains("works with --resume SESSION.json")); assert!(help.contains("works with --resume SESSION.json"));
assert!(help.contains("/help")); assert!(help.contains("/help"));
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/sandbox"));
assert!(help.contains("/compact")); assert!(help.contains("/compact"));
assert!(help.contains("/model [model]")); assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
@@ -386,8 +397,8 @@ mod tests {
assert!(help.contains("/version")); assert!(help.contains("/version"));
assert!(help.contains("/export [file]")); assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains("/session [list|switch <session-id>]"));
assert_eq!(slash_command_specs().len(), 15); assert_eq!(slash_command_specs().len(), 16);
assert_eq!(resume_supported_slash_commands().len(), 11); assert_eq!(resume_supported_slash_commands().len(), 12);
} }
#[test] #[test]
@@ -434,6 +445,7 @@ mod tests {
let session = Session::new(); let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
assert!( assert!(
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
); );

View File

@@ -414,7 +414,6 @@ mod tests {
cwd: PathBuf::from("/tmp/project"), cwd: PathBuf::from("/tmp/project"),
current_date: "2026-03-31".to_string(), current_date: "2026-03-31".to_string(),
git_status: None, git_status: None,
git_diff: None,
instruction_files: Vec::new(), instruction_files: Vec::new(),
}) })
.with_os("linux", "6.8") .with_os("linux", "6.8")

View File

@@ -12,7 +12,7 @@ mod oauth;
mod permissions; mod permissions;
mod prompt; mod prompt;
mod remote; mod remote;
pub mod sandbox; mod sandbox;
mod session; mod session;
mod usage; mod usage;
@@ -74,6 +74,12 @@ pub use remote::{
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL, RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
}; };
pub use sandbox::{
build_linux_sandbox_command, detect_container_environment, detect_container_environment_from,
resolve_sandbox_status, resolve_sandbox_status_for_request, ContainerEnvironment,
FilesystemIsolationMode, LinuxSandboxCommand, SandboxConfig, SandboxDetectionInputs,
SandboxRequest, SandboxStatus,
};
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError}; pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
pub use usage::{ pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,

View File

@@ -50,7 +50,6 @@ pub struct ProjectContext {
pub cwd: PathBuf, pub cwd: PathBuf,
pub current_date: String, pub current_date: String,
pub git_status: Option<String>, pub git_status: Option<String>,
pub git_diff: Option<String>,
pub instruction_files: Vec<ContextFile>, pub instruction_files: Vec<ContextFile>,
} }
@@ -65,7 +64,6 @@ impl ProjectContext {
cwd, cwd,
current_date: current_date.into(), current_date: current_date.into(),
git_status: None, git_status: None,
git_diff: None,
instruction_files, instruction_files,
}) })
} }
@@ -76,7 +74,6 @@ impl ProjectContext {
) -> std::io::Result<Self> { ) -> std::io::Result<Self> {
let mut context = Self::discover(cwd, current_date)?; let mut context = Self::discover(cwd, current_date)?;
context.git_status = read_git_status(&context.cwd); context.git_status = read_git_status(&context.cwd);
context.git_diff = read_git_diff(&context.cwd);
Ok(context) Ok(context)
} }
} }
@@ -242,38 +239,6 @@ fn read_git_status(cwd: &Path) -> Option<String> {
} }
} }
fn read_git_diff(cwd: &Path) -> Option<String> {
let mut sections = Vec::new();
let staged = read_git_output(cwd, &["diff", "--cached"])?;
if !staged.trim().is_empty() {
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
}
let unstaged = read_git_output(cwd, &["diff"])?;
if !unstaged.trim().is_empty() {
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
}
if sections.is_empty() {
None
} else {
Some(sections.join("\n\n"))
}
}
fn read_git_output(cwd: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn render_project_context(project_context: &ProjectContext) -> String { fn render_project_context(project_context: &ProjectContext) -> String {
let mut lines = vec!["# Project context".to_string()]; let mut lines = vec!["# Project context".to_string()];
let mut bullets = vec![ let mut bullets = vec![
@@ -292,11 +257,6 @@ fn render_project_context(project_context: &ProjectContext) -> String {
lines.push("Git status snapshot:".to_string()); lines.push("Git status snapshot:".to_string());
lines.push(status.clone()); lines.push(status.clone());
} }
if let Some(diff) = &project_context.git_diff {
lines.push(String::new());
lines.push("Git diff snapshot:".to_string());
lines.push(diff.clone());
}
lines.join("\n") lines.join("\n")
} }
@@ -617,49 +577,6 @@ mod tests {
assert!(status.contains("## No commits yet on") || status.contains("## ")); assert!(status.contains("## No commits yet on") || status.contains("## "));
assert!(status.contains("?? CLAUDE.md")); assert!(status.contains("?? CLAUDE.md"));
assert!(status.contains("?? tracked.txt")); assert!(status.contains("?? tracked.txt"));
assert!(context.git_diff.is_none());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discover_with_git_includes_diff_snapshot_for_tracked_changes() {
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
std::process::Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.expect("git init should run");
std::process::Command::new("git")
.args(["config", "user.email", "tests@example.com"])
.current_dir(&root)
.status()
.expect("git config email should run");
std::process::Command::new("git")
.args(["config", "user.name", "Runtime Prompt Tests"])
.current_dir(&root)
.status()
.expect("git config name should run");
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked file");
std::process::Command::new("git")
.args(["add", "tracked.txt"])
.current_dir(&root)
.status()
.expect("git add should run");
std::process::Command::new("git")
.args(["commit", "-m", "init", "--quiet"])
.current_dir(&root)
.status()
.expect("git commit should run");
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("rewrite tracked file");
let context =
ProjectContext::discover_with_git(&root, "2026-03-31").expect("context should load");
let diff = context.git_diff.expect("git diff should be present");
assert!(diff.contains("Unstaged changes:"));
assert!(diff.contains("tracked.txt"));
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }

View File

@@ -5,17 +5,12 @@ edition.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
[[bin]]
name = "claw"
path = "src/main.rs"
[dependencies] [dependencies]
api = { path = "../api" } api = { path = "../api" }
commands = { path = "../commands" } commands = { path = "../commands" }
compat-harness = { path = "../compat-harness" } compat-harness = { path = "../compat-harness" }
crossterm = "0.28" crossterm = "0.28"
pulldown-cmark = "0.13" pulldown-cmark = "0.13"
rustyline = "15"
runtime = { path = "../runtime" } runtime = { path = "../runtime" }
serde_json = "1" serde_json = "1"
syntect = "5" syntect = "5"

View File

@@ -9,7 +9,7 @@ use clap::{Parser, Subcommand, ValueEnum};
about = "Rust Claude CLI prototype" about = "Rust Claude CLI prototype"
)] )]
pub struct Cli { pub struct Cli {
#[arg(long, default_value = "claude-opus-4-6")] #[arg(long, default_value = "claude-3-7-sonnet")]
pub model: String, pub model: String,
#[arg(long, value_enum, default_value_t = PermissionMode::WorkspaceWrite)] #[arg(long, value_enum, default_value_t = PermissionMode::WorkspaceWrite)]

View File

@@ -1,433 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
const STARTER_CLAUDE_JSON: &str = concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
);
const GITIGNORE_COMMENT: &str = "# Claude Code local artifacts";
const GITIGNORE_ENTRIES: [&str; 2] = [".claude/settings.local.json", ".claude/sessions/"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InitStatus {
Created,
Updated,
Skipped,
}
impl InitStatus {
#[must_use]
pub(crate) fn label(self) -> &'static str {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Skipped => "skipped (already exists)",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InitArtifact {
pub(crate) name: &'static str,
pub(crate) status: InitStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InitReport {
pub(crate) project_root: PathBuf,
pub(crate) artifacts: Vec<InitArtifact>,
}
impl InitReport {
#[must_use]
pub(crate) fn render(&self) -> String {
let mut lines = vec![
"Init".to_string(),
format!(" Project {}", self.project_root.display()),
];
for artifact in &self.artifacts {
lines.push(format!(
" {:<16} {}",
artifact.name,
artifact.status.label()
));
}
lines.push(" Next step Review and tailor the generated guidance".to_string());
lines.join("\n")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
struct RepoDetection {
rust_workspace: bool,
rust_root: bool,
python: bool,
package_json: bool,
typescript: bool,
nextjs: bool,
react: bool,
vite: bool,
nest: bool,
src_dir: bool,
tests_dir: bool,
rust_dir: bool,
}
pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::error::Error>> {
let mut artifacts = Vec::new();
let claude_dir = cwd.join(".claude");
artifacts.push(InitArtifact {
name: ".claude/",
status: ensure_dir(&claude_dir)?,
});
let claude_json = cwd.join(".claude.json");
artifacts.push(InitArtifact {
name: ".claude.json",
status: write_file_if_missing(&claude_json, STARTER_CLAUDE_JSON)?,
});
let gitignore = cwd.join(".gitignore");
artifacts.push(InitArtifact {
name: ".gitignore",
status: ensure_gitignore_entries(&gitignore)?,
});
let claude_md = cwd.join("CLAUDE.md");
let content = render_init_claude_md(cwd);
artifacts.push(InitArtifact {
name: "CLAUDE.md",
status: write_file_if_missing(&claude_md, &content)?,
});
Ok(InitReport {
project_root: cwd.to_path_buf(),
artifacts,
})
}
fn ensure_dir(path: &Path) -> Result<InitStatus, std::io::Error> {
if path.is_dir() {
return Ok(InitStatus::Skipped);
}
fs::create_dir_all(path)?;
Ok(InitStatus::Created)
}
fn write_file_if_missing(path: &Path, content: &str) -> Result<InitStatus, std::io::Error> {
if path.exists() {
return Ok(InitStatus::Skipped);
}
fs::write(path, content)?;
Ok(InitStatus::Created)
}
fn ensure_gitignore_entries(path: &Path) -> Result<InitStatus, std::io::Error> {
if !path.exists() {
let mut lines = vec![GITIGNORE_COMMENT.to_string()];
lines.extend(GITIGNORE_ENTRIES.iter().map(|entry| (*entry).to_string()));
fs::write(path, format!("{}\n", lines.join("\n")))?;
return Ok(InitStatus::Created);
}
let existing = fs::read_to_string(path)?;
let mut lines = existing.lines().map(ToOwned::to_owned).collect::<Vec<_>>();
let mut changed = false;
if !lines.iter().any(|line| line == GITIGNORE_COMMENT) {
lines.push(GITIGNORE_COMMENT.to_string());
changed = true;
}
for entry in GITIGNORE_ENTRIES {
if !lines.iter().any(|line| line == entry) {
lines.push(entry.to_string());
changed = true;
}
}
if !changed {
return Ok(InitStatus::Skipped);
}
fs::write(path, format!("{}\n", lines.join("\n")))?;
Ok(InitStatus::Updated)
}
pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
let detection = detect_repo(cwd);
let mut lines = vec![
"# CLAUDE.md".to_string(),
String::new(),
"This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
String::new(),
];
let detected_languages = detected_languages(&detection);
let detected_frameworks = detected_frameworks(&detection);
lines.push("## Detected stack".to_string());
if detected_languages.is_empty() {
lines.push("- No specific language markers were detected yet; document the primary language and verification commands once the project structure settles.".to_string());
} else {
lines.push(format!("- Languages: {}.", detected_languages.join(", ")));
}
if detected_frameworks.is_empty() {
lines.push("- Frameworks: none detected from the supported starter markers.".to_string());
} else {
lines.push(format!(
"- Frameworks/tooling markers: {}.",
detected_frameworks.join(", ")
));
}
lines.push(String::new());
let verification_lines = verification_lines(cwd, &detection);
if !verification_lines.is_empty() {
lines.push("## Verification".to_string());
lines.extend(verification_lines);
lines.push(String::new());
}
let structure_lines = repository_shape_lines(&detection);
if !structure_lines.is_empty() {
lines.push("## Repository shape".to_string());
lines.extend(structure_lines);
lines.push(String::new());
}
let framework_lines = framework_notes(&detection);
if !framework_lines.is_empty() {
lines.push("## Framework notes".to_string());
lines.extend(framework_lines);
lines.push(String::new());
}
lines.push("## Working agreement".to_string());
lines.push("- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.".to_string());
lines.push("- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides.".to_string());
lines.push("- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.".to_string());
lines.push(String::new());
lines.join("\n")
}
fn detect_repo(cwd: &Path) -> RepoDetection {
let package_json_contents = fs::read_to_string(cwd.join("package.json"))
.unwrap_or_default()
.to_ascii_lowercase();
RepoDetection {
rust_workspace: cwd.join("rust").join("Cargo.toml").is_file(),
rust_root: cwd.join("Cargo.toml").is_file(),
python: cwd.join("pyproject.toml").is_file()
|| cwd.join("requirements.txt").is_file()
|| cwd.join("setup.py").is_file(),
package_json: cwd.join("package.json").is_file(),
typescript: cwd.join("tsconfig.json").is_file()
|| package_json_contents.contains("typescript"),
nextjs: package_json_contents.contains("\"next\""),
react: package_json_contents.contains("\"react\""),
vite: package_json_contents.contains("\"vite\""),
nest: package_json_contents.contains("@nestjs"),
src_dir: cwd.join("src").is_dir(),
tests_dir: cwd.join("tests").is_dir(),
rust_dir: cwd.join("rust").is_dir(),
}
}
fn detected_languages(detection: &RepoDetection) -> Vec<&'static str> {
let mut languages = Vec::new();
if detection.rust_workspace || detection.rust_root {
languages.push("Rust");
}
if detection.python {
languages.push("Python");
}
if detection.typescript {
languages.push("TypeScript");
} else if detection.package_json {
languages.push("JavaScript/Node.js");
}
languages
}
fn detected_frameworks(detection: &RepoDetection) -> Vec<&'static str> {
let mut frameworks = Vec::new();
if detection.nextjs {
frameworks.push("Next.js");
}
if detection.react {
frameworks.push("React");
}
if detection.vite {
frameworks.push("Vite");
}
if detection.nest {
frameworks.push("NestJS");
}
frameworks
}
fn verification_lines(cwd: &Path, detection: &RepoDetection) -> Vec<String> {
let mut lines = Vec::new();
if detection.rust_workspace {
lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
} else if detection.rust_root {
lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
}
if detection.python {
if cwd.join("pyproject.toml").is_file() {
lines.push("- Run the Python project checks declared in `pyproject.toml` (for example: `pytest`, `ruff check`, and `mypy` when configured).".to_string());
} else {
lines.push(
"- Run the repo's Python test/lint commands before shipping changes.".to_string(),
);
}
}
if detection.package_json {
lines.push("- Run the JavaScript/TypeScript checks from `package.json` before shipping changes (`npm test`, `npm run lint`, `npm run build`, or the repo equivalent).".to_string());
}
if detection.tests_dir && detection.src_dir {
lines.push("- `src/` and `tests/` are both present; update both surfaces together when behavior changes.".to_string());
}
lines
}
fn repository_shape_lines(detection: &RepoDetection) -> Vec<String> {
let mut lines = Vec::new();
if detection.rust_dir {
lines.push(
"- `rust/` contains the Rust workspace and active CLI/runtime implementation."
.to_string(),
);
}
if detection.src_dir {
lines.push("- `src/` contains source files that should stay consistent with generated guidance and tests.".to_string());
}
if detection.tests_dir {
lines.push("- `tests/` contains validation surfaces that should be reviewed alongside code changes.".to_string());
}
lines
}
fn framework_notes(detection: &RepoDetection) -> Vec<String> {
let mut lines = Vec::new();
if detection.nextjs {
lines.push("- Next.js detected: preserve routing/data-fetching conventions and verify production builds after changing app structure.".to_string());
}
if detection.react && !detection.nextjs {
lines.push("- React detected: keep component behavior covered with focused tests and avoid unnecessary prop/API churn.".to_string());
}
if detection.vite {
lines.push("- Vite detected: validate the production bundle after changing build-sensitive configuration or imports.".to_string());
}
if detection.nest {
lines.push("- NestJS detected: keep module/provider boundaries explicit and verify controller/service wiring after refactors.".to_string());
}
lines
}
#[cfg(test)]
mod tests {
use super::{initialize_repo, render_init_claude_md};
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("rusty-claude-init-{nanos}"))
}
#[test]
fn initialize_repo_creates_expected_files_and_gitignore_entries() {
let root = temp_dir();
fs::create_dir_all(root.join("rust")).expect("create rust dir");
fs::write(root.join("rust").join("Cargo.toml"), "[workspace]\n").expect("write cargo");
let report = initialize_repo(&root).expect("init should succeed");
let rendered = report.render();
assert!(rendered.contains(".claude/ created"));
assert!(rendered.contains(".claude.json created"));
assert!(rendered.contains(".gitignore created"));
assert!(rendered.contains("CLAUDE.md created"));
assert!(root.join(".claude").is_dir());
assert!(root.join(".claude.json").is_file());
assert!(root.join("CLAUDE.md").is_file());
assert_eq!(
fs::read_to_string(root.join(".claude.json")).expect("read claude json"),
concat!(
"{\n",
" \"permissions\": {\n",
" \"defaultMode\": \"acceptEdits\"\n",
" }\n",
"}\n",
)
);
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert!(gitignore.contains(".claude/settings.local.json"));
assert!(gitignore.contains(".claude/sessions/"));
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).expect("read claude md");
assert!(claude_md.contains("Languages: Rust."));
assert!(claude_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn initialize_repo_is_idempotent_and_preserves_existing_files() {
let root = temp_dir();
fs::create_dir_all(&root).expect("create root");
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
fs::write(root.join(".gitignore"), ".claude/settings.local.json\n")
.expect("write gitignore");
let first = initialize_repo(&root).expect("first init should succeed");
assert!(first
.render()
.contains("CLAUDE.md skipped (already exists)"));
let second = initialize_repo(&root).expect("second init should succeed");
let second_rendered = second.render();
assert!(second_rendered.contains(".claude/ skipped (already exists)"));
assert!(second_rendered.contains(".claude.json skipped (already exists)"));
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
assert!(second_rendered.contains("CLAUDE.md skipped (already exists)"));
assert_eq!(
fs::read_to_string(root.join("CLAUDE.md")).expect("read existing claude md"),
"custom guidance\n"
);
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert_eq!(gitignore.matches(".claude/settings.local.json").count(), 1);
assert_eq!(gitignore.matches(".claude/sessions/").count(), 1);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_init_template_mentions_detected_python_and_nextjs_markers() {
let root = temp_dir();
fs::create_dir_all(&root).expect("create root");
fs::write(root.join("pyproject.toml"), "[project]\nname = \"demo\"\n")
.expect("write pyproject");
fs::write(
root.join("package.json"),
r#"{"dependencies":{"next":"14.0.0","react":"18.0.0"},"devDependencies":{"typescript":"5.0.0"}}"#,
)
.expect("write package json");
let rendered = render_init_claude_md(Path::new(&root));
assert!(rendered.contains("Languages: Python, TypeScript."));
assert!(rendered.contains("Frameworks/tooling markers: Next.js, React."));
assert!(rendered.contains("pyproject.toml"));
assert!(rendered.contains("Next.js detected"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
}

View File

@@ -1,16 +1,166 @@
use std::borrow::Cow;
use std::cell::RefCell;
use std::io::{self, IsTerminal, Write}; use std::io::{self, IsTerminal, Write};
use rustyline::completion::{Completer, Pair}; use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp};
use rustyline::error::ReadlineError; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use rustyline::highlight::{CmdKind, Highlighter}; use crossterm::queue;
use rustyline::hint::Hinter; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
use rustyline::history::DefaultHistory;
use rustyline::validate::Validator; #[derive(Debug, Clone, PartialEq, Eq)]
use rustyline::{ pub struct InputBuffer {
Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers, buffer: String,
}; cursor: usize,
}
impl InputBuffer {
#[must_use]
pub fn new() -> Self {
Self {
buffer: String::new(),
cursor: 0,
}
}
pub fn insert(&mut self, ch: char) {
self.buffer.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
}
pub fn insert_newline(&mut self) {
self.insert('\n');
}
pub fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let previous = self.buffer[..self.cursor]
.char_indices()
.last()
.map_or(0, |(idx, _)| idx);
self.buffer.drain(previous..self.cursor);
self.cursor = previous;
}
pub fn move_left(&mut self) {
if self.cursor == 0 {
return;
}
self.cursor = self.buffer[..self.cursor]
.char_indices()
.last()
.map_or(0, |(idx, _)| idx);
}
pub fn move_right(&mut self) {
if self.cursor >= self.buffer.len() {
return;
}
if let Some(next) = self.buffer[self.cursor..].chars().next() {
self.cursor += next.len_utf8();
}
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.buffer.len();
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.buffer
}
#[cfg(test)]
#[must_use]
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn clear(&mut self) {
self.buffer.clear();
self.cursor = 0;
}
pub fn replace(&mut self, value: impl Into<String>) {
self.buffer = value.into();
self.cursor = self.buffer.len();
}
#[must_use]
fn current_command_prefix(&self) -> Option<&str> {
if self.cursor != self.buffer.len() {
return None;
}
let prefix = &self.buffer[..self.cursor];
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
return None;
}
Some(prefix)
}
pub fn complete_slash_command(&mut self, candidates: &[String]) -> bool {
let Some(prefix) = self.current_command_prefix() else {
return false;
};
let matches = candidates
.iter()
.filter(|candidate| candidate.starts_with(prefix))
.map(String::as_str)
.collect::<Vec<_>>();
if matches.is_empty() {
return false;
}
let replacement = longest_common_prefix(&matches);
if replacement == prefix {
return false;
}
self.replace(replacement);
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedBuffer {
lines: Vec<String>,
cursor_row: u16,
cursor_col: u16,
}
impl RenderedBuffer {
#[must_use]
pub fn line_count(&self) -> usize {
self.lines.len()
}
fn write(&self, out: &mut impl Write) -> io::Result<()> {
for (index, line) in self.lines.iter().enumerate() {
if index > 0 {
writeln!(out)?;
}
write!(out, "{line}")?;
}
Ok(())
}
#[cfg(test)]
#[must_use]
pub fn lines(&self) -> &[String] {
&self.lines
}
#[cfg(test)]
#[must_use]
pub fn cursor_position(&self) -> (u16, u16) {
(self.cursor_row, self.cursor_col)
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadOutcome { pub enum ReadOutcome {
@@ -19,101 +169,25 @@ pub enum ReadOutcome {
Exit, Exit,
} }
struct SlashCommandHelper {
completions: Vec<String>,
current_line: RefCell<String>,
}
impl SlashCommandHelper {
fn new(completions: Vec<String>) -> Self {
Self {
completions,
current_line: RefCell::new(String::new()),
}
}
fn reset_current_line(&self) {
self.current_line.borrow_mut().clear();
}
fn current_line(&self) -> String {
self.current_line.borrow().clone()
}
fn set_current_line(&self, line: &str) {
let mut current = self.current_line.borrow_mut();
current.clear();
current.push_str(line);
}
}
impl Completer for SlashCommandHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
let Some(prefix) = slash_command_prefix(line, pos) else {
return Ok((0, Vec::new()));
};
let matches = self
.completions
.iter()
.filter(|candidate| candidate.starts_with(prefix))
.map(|candidate| Pair {
display: candidate.clone(),
replacement: candidate.clone(),
})
.collect();
Ok((0, matches))
}
}
impl Hinter for SlashCommandHelper {
type Hint = String;
}
impl Highlighter for SlashCommandHelper {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
self.set_current_line(line);
Cow::Borrowed(line)
}
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
self.set_current_line(line);
false
}
}
impl Validator for SlashCommandHelper {}
impl Helper for SlashCommandHelper {}
pub struct LineEditor { pub struct LineEditor {
prompt: String, prompt: String,
editor: Editor<SlashCommandHelper, DefaultHistory>, continuation_prompt: String,
history: Vec<String>,
history_index: Option<usize>,
draft: Option<String>,
completions: Vec<String>,
} }
impl LineEditor { impl LineEditor {
#[must_use] #[must_use]
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self { pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
let config = Config::builder()
.completion_type(CompletionType::List)
.edit_mode(EditMode::Emacs)
.build();
let mut editor = Editor::<SlashCommandHelper, DefaultHistory>::with_config(config)
.expect("rustyline editor should initialize");
editor.set_helper(Some(SlashCommandHelper::new(completions)));
editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
Self { Self {
prompt: prompt.into(), prompt: prompt.into(),
editor, continuation_prompt: String::from("> "),
history: Vec::new(),
history_index: None,
draft: None,
completions,
} }
} }
@@ -122,8 +196,9 @@ impl LineEditor {
if entry.trim().is_empty() { if entry.trim().is_empty() {
return; return;
} }
self.history.push(entry);
let _ = self.editor.add_history_entry(entry); self.history_index = None;
self.draft = None;
} }
pub fn read_line(&mut self) -> io::Result<ReadOutcome> { pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
@@ -131,43 +206,45 @@ impl LineEditor {
return self.read_line_fallback(); return self.read_line_fallback();
} }
if let Some(helper) = self.editor.helper_mut() { enable_raw_mode()?;
helper.reset_current_line(); let mut stdout = io::stdout();
} let mut input = InputBuffer::new();
let mut rendered_lines = 1usize;
self.redraw(&mut stdout, &input, rendered_lines)?;
match self.editor.readline(&self.prompt) { loop {
Ok(line) => Ok(ReadOutcome::Submit(line)), let event = event::read()?;
Err(ReadlineError::Interrupted) => { if let Event::Key(key) = event {
let has_input = !self.current_line().is_empty(); match self.handle_key(key, &mut input) {
self.finish_interrupted_read()?; EditorAction::Continue => {
if has_input { rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
Ok(ReadOutcome::Cancel) }
} else { EditorAction::Submit => {
Ok(ReadOutcome::Exit) disable_raw_mode()?;
writeln!(stdout)?;
self.history_index = None;
self.draft = None;
return Ok(ReadOutcome::Submit(input.as_str().to_owned()));
}
EditorAction::Cancel => {
disable_raw_mode()?;
writeln!(stdout)?;
self.history_index = None;
self.draft = None;
return Ok(ReadOutcome::Cancel);
}
EditorAction::Exit => {
disable_raw_mode()?;
writeln!(stdout)?;
self.history_index = None;
self.draft = None;
return Ok(ReadOutcome::Exit);
}
} }
} }
Err(ReadlineError::Eof) => {
self.finish_interrupted_read()?;
Ok(ReadOutcome::Exit)
}
Err(error) => Err(io::Error::other(error)),
} }
} }
fn current_line(&self) -> String {
self.editor
.helper()
.map_or_else(String::new, SlashCommandHelper::current_line)
}
fn finish_interrupted_read(&mut self) -> io::Result<()> {
if let Some(helper) = self.editor.helper_mut() {
helper.reset_current_line();
}
let mut stdout = io::stdout();
writeln!(stdout)
}
fn read_line_fallback(&self) -> io::Result<ReadOutcome> { fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
write!(stdout, "{}", self.prompt)?; write!(stdout, "{}", self.prompt)?;
@@ -184,86 +261,388 @@ impl LineEditor {
} }
Ok(ReadOutcome::Submit(buffer)) Ok(ReadOutcome::Submit(buffer))
} }
#[allow(clippy::too_many_lines)]
fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
match key {
KeyEvent {
code: KeyCode::Char('c'),
modifiers,
..
} if modifiers.contains(KeyModifiers::CONTROL) => {
if input.as_str().is_empty() {
EditorAction::Exit
} else {
input.clear();
self.history_index = None;
self.draft = None;
EditorAction::Cancel
}
}
KeyEvent {
code: KeyCode::Char('j'),
modifiers,
..
} if modifiers.contains(KeyModifiers::CONTROL) => {
input.insert_newline();
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Enter,
modifiers,
..
} if modifiers.contains(KeyModifiers::SHIFT) => {
input.insert_newline();
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Enter,
..
} => EditorAction::Submit,
KeyEvent {
code: KeyCode::Backspace,
..
} => {
input.backspace();
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Left,
..
} => {
input.move_left();
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Right,
..
} => {
input.move_right();
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Up, ..
} => {
self.navigate_history_up(input);
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Down,
..
} => {
self.navigate_history_down(input);
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
input.complete_slash_command(&self.completions);
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Home,
..
} => {
input.move_home();
EditorAction::Continue
}
KeyEvent {
code: KeyCode::End, ..
} => {
input.move_end();
EditorAction::Continue
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
input.clear();
self.history_index = None;
self.draft = None;
EditorAction::Cancel
}
KeyEvent {
code: KeyCode::Char(ch),
modifiers,
..
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
input.insert(ch);
self.history_index = None;
self.draft = None;
EditorAction::Continue
}
_ => EditorAction::Continue,
}
}
fn navigate_history_up(&mut self, input: &mut InputBuffer) {
if self.history.is_empty() {
return;
}
match self.history_index {
Some(0) => {}
Some(index) => {
let next_index = index - 1;
input.replace(self.history[next_index].clone());
self.history_index = Some(next_index);
}
None => {
self.draft = Some(input.as_str().to_owned());
let next_index = self.history.len() - 1;
input.replace(self.history[next_index].clone());
self.history_index = Some(next_index);
}
}
}
fn navigate_history_down(&mut self, input: &mut InputBuffer) {
let Some(index) = self.history_index else {
return;
};
if index + 1 < self.history.len() {
let next_index = index + 1;
input.replace(self.history[next_index].clone());
self.history_index = Some(next_index);
return;
}
input.replace(self.draft.take().unwrap_or_default());
self.history_index = None;
}
fn redraw(
&self,
out: &mut impl Write,
input: &InputBuffer,
previous_line_count: usize,
) -> io::Result<usize> {
let rendered = render_buffer(&self.prompt, &self.continuation_prompt, input);
if previous_line_count > 1 {
queue!(out, MoveUp(saturating_u16(previous_line_count - 1)))?;
}
queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown),)?;
rendered.write(out)?;
queue!(
out,
MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
MoveToColumn(0),
)?;
if rendered.cursor_row > 0 {
queue!(out, MoveDown(rendered.cursor_row))?;
}
queue!(out, MoveToColumn(rendered.cursor_col))?;
out.flush()?;
Ok(rendered.line_count())
}
} }
fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> { #[derive(Debug, Clone, Copy, PartialEq, Eq)]
if pos != line.len() { enum EditorAction {
return None; Continue,
Submit,
Cancel,
Exit,
}
#[must_use]
pub fn render_buffer(
prompt: &str,
continuation_prompt: &str,
input: &InputBuffer,
) -> RenderedBuffer {
let before_cursor = &input.as_str()[..input.cursor];
let cursor_row = saturating_u16(before_cursor.chars().filter(|ch| *ch == '\n').count());
let cursor_line = before_cursor.rsplit('\n').next().unwrap_or_default();
let cursor_prompt = if cursor_row == 0 {
prompt
} else {
continuation_prompt
};
let cursor_col = saturating_u16(cursor_prompt.chars().count() + cursor_line.chars().count());
let mut lines = Vec::new();
for (index, line) in input.as_str().split('\n').enumerate() {
let prefix = if index == 0 {
prompt
} else {
continuation_prompt
};
lines.push(format!("{prefix}{line}"));
}
if lines.is_empty() {
lines.push(prompt.to_string());
} }
let prefix = &line[..pos]; RenderedBuffer {
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') { lines,
return None; cursor_row,
cursor_col,
} }
}
Some(prefix) #[must_use]
fn longest_common_prefix(values: &[&str]) -> String {
let Some(first) = values.first() else {
return String::new();
};
let mut prefix = (*first).to_string();
for value in values.iter().skip(1) {
while !value.starts_with(&prefix) {
prefix.pop();
if prefix.is_empty() {
break;
}
}
}
prefix
}
#[must_use]
fn saturating_u16(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{slash_command_prefix, LineEditor, SlashCommandHelper}; use super::{render_buffer, InputBuffer, LineEditor};
use rustyline::completion::Completer; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rustyline::highlight::Highlighter;
use rustyline::history::{DefaultHistory, History};
use rustyline::Context;
#[test] fn key(code: KeyCode) -> KeyEvent {
fn extracts_only_terminal_slash_command_prefixes() { KeyEvent::new(code, KeyModifiers::NONE)
assert_eq!(slash_command_prefix("/he", 3), Some("/he"));
assert_eq!(slash_command_prefix("/help me", 5), None);
assert_eq!(slash_command_prefix("hello", 5), None);
assert_eq!(slash_command_prefix("/help", 2), None);
} }
#[test] #[test]
fn completes_matching_slash_commands() { fn supports_basic_line_editing() {
let helper = SlashCommandHelper::new(vec![ let mut input = InputBuffer::new();
input.insert('h');
input.insert('i');
input.move_end();
input.insert_newline();
input.insert('x');
assert_eq!(input.as_str(), "hi\nx");
assert_eq!(input.cursor(), 4);
input.move_left();
input.backspace();
assert_eq!(input.as_str(), "hix");
assert_eq!(input.cursor(), 2);
}
#[test]
fn completes_unique_slash_command() {
let mut input = InputBuffer::new();
for ch in "/he".chars() {
input.insert(ch);
}
assert!(input.complete_slash_command(&[
"/help".to_string(), "/help".to_string(),
"/hello".to_string(), "/hello".to_string(),
"/status".to_string(), "/status".to_string(),
]); ]));
let history = DefaultHistory::new(); assert_eq!(input.as_str(), "/hel");
let ctx = Context::new(&history);
let (start, matches) = helper
.complete("/he", 3, &ctx)
.expect("completion should work");
assert_eq!(start, 0); assert!(input.complete_slash_command(&["/help".to_string(), "/status".to_string()]));
assert_eq!( assert_eq!(input.as_str(), "/help");
matches
.into_iter()
.map(|candidate| candidate.replacement)
.collect::<Vec<_>>(),
vec!["/help".to_string(), "/hello".to_string()]
);
} }
#[test] #[test]
fn ignores_non_slash_command_completion_requests() { fn ignores_completion_when_prefix_is_not_a_slash_command() {
let helper = SlashCommandHelper::new(vec!["/help".to_string()]); let mut input = InputBuffer::new();
let history = DefaultHistory::new(); for ch in "hello".chars() {
let ctx = Context::new(&history); input.insert(ch);
let (_, matches) = helper }
.complete("hello", 5, &ctx)
.expect("completion should work");
assert!(matches.is_empty()); assert!(!input.complete_slash_command(&["/help".to_string()]));
assert_eq!(input.as_str(), "hello");
} }
#[test] #[test]
fn tracks_current_buffer_through_highlighter() { fn history_navigation_restores_current_draft() {
let helper = SlashCommandHelper::new(Vec::new()); let mut editor = LineEditor::new(" ", vec![]);
let _ = helper.highlight("draft", 5);
assert_eq!(helper.current_line(), "draft");
}
#[test]
fn push_history_ignores_blank_entries() {
let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
editor.push_history(" ");
editor.push_history("/help"); editor.push_history("/help");
editor.push_history("status report");
assert_eq!(editor.editor.history().len(), 1); let mut input = InputBuffer::new();
for ch in "draft".chars() {
input.insert(ch);
}
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
assert_eq!(input.as_str(), "status report");
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
assert_eq!(input.as_str(), "/help");
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
assert_eq!(input.as_str(), "status report");
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
assert_eq!(input.as_str(), "draft");
}
#[test]
fn tab_key_completes_from_editor_candidates() {
let mut editor = LineEditor::new(
" ",
vec![
"/help".to_string(),
"/status".to_string(),
"/session".to_string(),
],
);
let mut input = InputBuffer::new();
for ch in "/st".chars() {
input.insert(ch);
}
let _ = editor.handle_key(key(KeyCode::Tab), &mut input);
assert_eq!(input.as_str(), "/status");
}
#[test]
fn renders_multiline_buffers_with_continuation_prompt() {
let mut input = InputBuffer::new();
for ch in "hello\nworld".chars() {
if ch == '\n' {
input.insert_newline();
} else {
input.insert(ch);
}
}
let rendered = render_buffer(" ", "> ", &input);
assert_eq!(
rendered.lines(),
&[" hello".to_string(), "> world".to_string()]
);
assert_eq!(rendered.cursor_position(), (1, 7));
}
#[test]
fn ctrl_c_exits_only_when_buffer_is_empty() {
let mut editor = LineEditor::new(" ", vec![]);
let mut empty = InputBuffer::new();
assert!(matches!(
editor.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut empty,
),
super::EditorAction::Exit
));
let mut filled = InputBuffer::new();
filled.insert('x');
assert!(matches!(
editor.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut filled,
),
super::EditorAction::Cancel
));
assert!(filled.as_str().is_empty());
} }
} }

View File

@@ -1,4 +1,3 @@
mod init;
mod input; mod input;
mod render; mod render;
@@ -21,12 +20,11 @@ use commands::{
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
}; };
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
use render::{Spinner, TerminalRenderer}; use render::{Spinner, TerminalRenderer};
use runtime::{ use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials, ApiClient,
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
@@ -34,7 +32,7 @@ use runtime::{
use serde_json::json; use serde_json::json;
use tools::{execute_tool, mvp_tool_specs, ToolSpec}; use tools::{execute_tool, mvp_tool_specs, ToolSpec};
const DEFAULT_MODEL: &str = "claude-opus-4-6"; const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS: u32 = 32; const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
@@ -49,7 +47,7 @@ fn main() {
eprintln!( eprintln!(
"error: {error} "error: {error}
Run `claw --help` for usage." Run `rusty-claude-cli --help` for usage."
); );
std::process::exit(1); std::process::exit(1);
} }
@@ -76,7 +74,6 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.run_turn_with_output(&prompt, output_format)?, .run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?, CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?, CliAction::Logout => run_logout()?,
CliAction::Init => run_init()?,
CliAction::Repl { CliAction::Repl {
model, model,
allowed_tools, allowed_tools,
@@ -109,7 +106,6 @@ enum CliAction {
}, },
Login, Login,
Logout, Logout,
Init,
Repl { Repl {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
@@ -157,11 +153,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let value = args let value = args
.get(index + 1) .get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?; .ok_or_else(|| "missing value for --model".to_string())?;
model = resolve_model_alias(value).to_string(); model.clone_from(value);
index += 2; index += 2;
} }
flag if flag.starts_with("--model=") => { flag if flag.starts_with("--model=") => {
model = resolve_model_alias(&flag[8..]).to_string(); model = flag[8..].to_string();
index += 1; index += 1;
} }
"--output-format" => { "--output-format" => {
@@ -234,7 +230,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"system-prompt" => parse_system_prompt_args(&rest[1..]), "system-prompt" => parse_system_prompt_args(&rest[1..]),
"login" => Ok(CliAction::Login), "login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout), "logout" => Ok(CliAction::Logout),
"init" => Ok(CliAction::Init),
"prompt" => { "prompt" => {
let prompt = rest[1..].join(" "); let prompt = rest[1..].join(" ");
if prompt.trim().is_empty() { if prompt.trim().is_empty() {
@@ -259,15 +254,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
} }
} }
fn resolve_model_alias(model: &str) -> &str {
match model {
"opus" => "claude-opus-4-6",
"sonnet" => "claude-sonnet-4-6",
"haiku" => "claude-haiku-3-5-20241022",
_ => model,
}
}
fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> { fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
if values.is_empty() { if values.is_empty() {
return Ok(None); return Ok(None);
@@ -459,7 +445,7 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into()); return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
} }
let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url()); let client = AnthropicClient::from_auth(AuthSource::None);
let exchange_request = let exchange_request =
OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri); OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
let runtime = tokio::runtime::Runtime::new()?; let runtime = tokio::runtime::Runtime::new()?;
@@ -605,6 +591,7 @@ struct StatusContext {
memory_file_count: usize, memory_file_count: usize,
project_root: Option<PathBuf>, project_root: Option<PathBuf>,
git_branch: Option<String>, git_branch: Option<String>,
sandbox_status: runtime::SandboxStatus,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -717,6 +704,26 @@ fn format_resume_report(session_path: &str, message_count: usize, turns: u32) ->
) )
} }
fn format_init_report(path: &Path, created: bool) -> String {
if created {
format!(
"Init
CLAUDE.md {}
Result created
Next step Review and tailor the generated guidance",
path.display()
)
} else {
format!(
"Init
CLAUDE.md {}
Result skipped (already exists)
Next step Edit the existing file intentionally if workflows changed",
path.display()
)
}
}
fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String { fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
if skipped { if skipped {
format!( format!(
@@ -834,6 +841,18 @@ fn run_resume_command(
)), )),
}) })
} }
SlashCommand::Sandbox => {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_sandbox_report(&resolve_sandbox_status(
runtime_config.sandbox(),
&cwd,
))),
})
}
SlashCommand::Cost => { SlashCommand::Cost => {
let usage = UsageTracker::from_session(session).cumulative_usage(); let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
@@ -887,7 +906,7 @@ fn run_repl(
permission_mode: PermissionMode, permission_mode: PermissionMode,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates()); let mut editor = input::LineEditor::new(" ", slash_command_completion_candidates());
println!("{}", cli.startup_banner()); println!("{}", cli.startup_banner());
loop { loop {
@@ -974,26 +993,14 @@ impl LiveCli {
} }
fn startup_banner(&self) -> String { fn startup_banner(&self) -> String {
let cwd = env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
);
format!( format!(
"\x1b[38;5;196m\ "Rusty Claude CLI\n Model {}\n Permission mode {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.",
██████╗██╗ █████╗ ██╗ ██╗\n\
██╔════╝██║ ██╔══██╗██║ ██║\n\
██║ ██║ ███████║██║ █╗ ██║\n\
██║ ██║ ██╔══██║██║███╗██║\n\
╚██████╗███████╗██║ ██║╚███╔███╔╝\n\
╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\
\x1b[2mModel\x1b[0m {}\n\
\x1b[2mPermissions\x1b[0m {}\n\
\x1b[2mDirectory\x1b[0m {}\n\
\x1b[2mSession\x1b[0m {}\n\n\
Type \x1b[1m/help\x1b[0m for commands · \x1b[2mShift+Enter\x1b[0m for newline",
self.model, self.model,
self.permission_mode.as_str(), self.permission_mode.as_str(),
cwd, env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
),
self.session.id, self.session.id,
) )
} }
@@ -1002,7 +1009,7 @@ impl LiveCli {
let mut spinner = Spinner::new(); let mut spinner = Spinner::new();
let mut stdout = io::stdout(); let mut stdout = io::stdout();
spinner.tick( spinner.tick(
"🦀 Thinking...", "Waiting for Claude",
TerminalRenderer::new().color_theme(), TerminalRenderer::new().color_theme(),
&mut stdout, &mut stdout,
)?; )?;
@@ -1011,7 +1018,7 @@ impl LiveCli {
match result { match result {
Ok(_) => { Ok(_) => {
spinner.finish( spinner.finish(
"✨ Done", "Claude response complete",
TerminalRenderer::new().color_theme(), TerminalRenderer::new().color_theme(),
&mut stdout, &mut stdout,
)?; )?;
@@ -1021,7 +1028,7 @@ impl LiveCli {
} }
Err(error) => { Err(error) => {
spinner.fail( spinner.fail(
"❌ Request failed", "Claude request failed",
TerminalRenderer::new().color_theme(), TerminalRenderer::new().color_theme(),
&mut stdout, &mut stdout,
)?; )?;
@@ -1042,8 +1049,7 @@ impl LiveCli {
} }
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> { fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::from_auth(resolve_cli_auth_source()?) let client = AnthropicClient::from_auth(resolve_cli_auth_source()?);
.with_base_url(api::read_base_url());
let request = MessageRequest { let request = MessageRequest {
model: self.model.clone(), model: self.model.clone(),
max_tokens: DEFAULT_MAX_TOKENS, max_tokens: DEFAULT_MAX_TOKENS,
@@ -1098,6 +1104,10 @@ impl LiveCli {
self.print_status(); self.print_status();
false false
} }
SlashCommand::Sandbox => {
Self::print_sandbox_status();
false
}
SlashCommand::Compact => { SlashCommand::Compact => {
self.compact()?; self.compact()?;
false false
@@ -1119,7 +1129,7 @@ impl LiveCli {
false false
} }
SlashCommand::Init => { SlashCommand::Init => {
run_init()?; Self::run_init()?;
false false
} }
SlashCommand::Diff => { SlashCommand::Diff => {
@@ -1169,6 +1179,18 @@ impl LiveCli {
); );
} }
fn print_sandbox_status() {
let cwd = env::current_dir().expect("current dir");
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader
.load()
.unwrap_or_else(|_| runtime::RuntimeConfig::empty());
println!(
"{}",
format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd))
);
}
fn set_model(&mut self, model: Option<String>) -> Result<bool, Box<dyn std::error::Error>> { fn set_model(&mut self, model: Option<String>) -> Result<bool, Box<dyn std::error::Error>> {
let Some(model) = model else { let Some(model) = model else {
println!( println!(
@@ -1182,8 +1204,6 @@ impl LiveCli {
return Ok(false); return Ok(false);
}; };
let model = resolve_model_alias(&model).to_string();
if model == self.model { if model == self.model {
println!( println!(
"{}", "{}",
@@ -1329,6 +1349,11 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn run_init() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", init_claude_md()?);
Ok(())
}
fn print_diff() -> Result<(), Box<dyn std::error::Error>> { fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_diff_report()?); println!("{}", render_diff_report()?);
Ok(()) Ok(())
@@ -1541,6 +1566,7 @@ fn status_context(
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) = let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref()); parse_git_status_metadata(project_context.git_status.as_deref());
let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
Ok(StatusContext { Ok(StatusContext {
cwd, cwd,
session_path: session_path.map(Path::to_path_buf), session_path: session_path.map(Path::to_path_buf),
@@ -1549,6 +1575,7 @@ fn status_context(
memory_file_count: project_context.instruction_files.len(), memory_file_count: project_context.instruction_files.len(),
project_root, project_root,
git_branch, git_branch,
sandbox_status,
}) })
} }
@@ -1601,6 +1628,7 @@ fn format_status_report(
context.discovered_config_files, context.discovered_config_files,
context.memory_file_count, context.memory_file_count,
), ),
format_sandbox_report(&context.sandbox_status),
] ]
.join( .join(
" "
@@ -1609,6 +1637,49 @@ fn format_status_report(
) )
} }
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
format!(
"Sandbox
Enabled {}
Active {}
Supported {}
In container {}
Requested ns {}
Active ns {}
Requested net {}
Active net {}
Filesystem mode {}
Filesystem active {}
Allowed mounts {}
Markers {}
Fallback reason {}",
status.enabled,
status.active,
status.supported,
status.in_container,
status.requested.namespace_restrictions,
status.namespace_active,
status.requested.network_isolation,
status.network_active,
status.filesystem_mode.as_str(),
status.filesystem_active,
if status.allowed_mounts.is_empty() {
"<none>".to_string()
} else {
status.allowed_mounts.join(", ")
},
if status.container_markers.is_empty() {
"<none>".to_string()
} else {
status.container_markers.join(", ")
},
status
.fallback_reason
.clone()
.unwrap_or_else(|| "<none>".to_string()),
)
}
fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> { fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd); let loader = ConfigLoader::default_for(&cwd);
@@ -1726,12 +1797,67 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> { fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
Ok(initialize_repo(&cwd)?.render()) let claude_md = cwd.join("CLAUDE.md");
if claude_md.exists() {
return Ok(format_init_report(&claude_md, false));
}
let content = render_init_claude_md(&cwd);
fs::write(&claude_md, content)?;
Ok(format_init_report(&claude_md, true))
} }
fn run_init() -> Result<(), Box<dyn std::error::Error>> { fn render_init_claude_md(cwd: &Path) -> String {
println!("{}", init_claude_md()?); let mut lines = vec![
Ok(()) "# CLAUDE.md".to_string(),
String::new(),
"This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
String::new(),
];
let mut command_lines = Vec::new();
if cwd.join("rust").join("Cargo.toml").is_file() {
command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
} else if cwd.join("Cargo.toml").is_file() {
command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
}
if cwd.join("tests").is_dir() && cwd.join("src").is_dir() {
command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string());
}
if !command_lines.is_empty() {
lines.push("## Verification".to_string());
lines.extend(command_lines);
lines.push(String::new());
}
let mut structure_lines = Vec::new();
if cwd.join("rust").is_dir() {
structure_lines.push(
"- `rust/` contains the Rust workspace and the active CLI/runtime implementation."
.to_string(),
);
}
if cwd.join("src").is_dir() {
structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string());
}
if cwd.join("tests").is_dir() {
structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string());
}
if !structure_lines.is_empty() {
lines.push("## Repository shape".to_string());
lines.extend(structure_lines);
lines.push(String::new());
}
lines.push("## Working agreement".to_string());
lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string());
lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string());
lines.push(String::new());
lines.join(
"
",
)
} }
fn normalize_permission_mode(mode: &str) -> Option<&'static str> { fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
@@ -1766,7 +1892,7 @@ fn render_version_report() -> String {
let git_sha = GIT_SHA.unwrap_or("unknown"); let git_sha = GIT_SHA.unwrap_or("unknown");
let target = BUILD_TARGET.unwrap_or("unknown"); let target = BUILD_TARGET.unwrap_or("unknown");
format!( format!(
"Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" "Version\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
) )
} }
@@ -1946,8 +2072,7 @@ impl AnthropicRuntimeClient {
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self { Ok(Self {
runtime: tokio::runtime::Runtime::new()?, runtime: tokio::runtime::Runtime::new()?,
client: AnthropicClient::from_auth(resolve_cli_auth_source()?) client: AnthropicClient::from_auth(resolve_cli_auth_source()?),
.with_base_url(api::read_base_url()),
model, model,
enable_tools, enable_tools,
allowed_tools, allowed_tools,
@@ -2291,62 +2416,34 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
.collect() .collect()
} }
fn print_help_to(out: &mut impl Write) -> io::Result<()> { fn print_help() {
writeln!(out, "claw v{VERSION}")?; println!("rusty-claude-cli v{VERSION}");
writeln!(out)?; println!();
writeln!(out, "Usage:")?; println!("Usage:");
writeln!( println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]");
out, println!(" Start the interactive REPL");
" claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
)?; println!(" Send one prompt and exit");
writeln!(out, " Start the interactive REPL")?; println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT");
writeln!( println!(" Shorthand non-interactive prompt mode");
out, println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
" claw [--model MODEL] [--output-format text|json] prompt TEXT" println!(" Inspect or maintain a saved session without entering the REPL");
)?; println!(" rusty-claude-cli dump-manifests");
writeln!(out, " Send one prompt and exit")?; println!(" rusty-claude-cli bootstrap-plan");
writeln!( println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
out, println!(" rusty-claude-cli login");
" claw [--model MODEL] [--output-format text|json] TEXT" println!(" rusty-claude-cli logout");
)?; println!();
writeln!(out, " Shorthand non-interactive prompt mode")?; println!("Flags:");
writeln!( println!(" --model MODEL Override the active model");
out, println!(" --output-format FORMAT Non-interactive output format: text or json");
" claw --resume SESSION.json [/status] [/compact] [...]" println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access");
)?; println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
writeln!( println!(" --version, -V Print version and build information locally");
out, println!();
" Inspect or maintain a saved session without entering the REPL" println!("Interactive slash commands:");
)?; println!("{}", render_slash_command_help());
writeln!(out, " claw dump-manifests")?; println!();
writeln!(out, " claw bootstrap-plan")?;
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
writeln!(out, " claw login")?;
writeln!(out, " claw logout")?;
writeln!(out, " claw init")?;
writeln!(out)?;
writeln!(out, "Flags:")?;
writeln!(
out,
" --model MODEL Override the active model"
)?;
writeln!(
out,
" --output-format FORMAT Non-interactive output format: text or json"
)?;
writeln!(
out,
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
)?;
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
writeln!(
out,
" --version, -V Print version and build information locally"
)?;
writeln!(out)?;
writeln!(out, "Interactive slash commands:")?;
writeln!(out, "{}", render_slash_command_help())?;
writeln!(out)?;
let resume_commands = resume_supported_slash_commands() let resume_commands = resume_supported_slash_commands()
.into_iter() .into_iter()
.map(|spec| match spec.argument_hint { .map(|spec| match spec.argument_hint {
@@ -2355,43 +2452,28 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
writeln!(out, "Resume-safe commands: {resume_commands}")?; println!("Resume-safe commands: {resume_commands}");
writeln!(out, "Examples:")?; println!("Examples:");
writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; println!(" rusty-claude-cli --model claude-opus \"summarize this repo\"");
writeln!( println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\"");
out, println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\"");
" claw --output-format json prompt \"explain src/main.rs\"" println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
)?; println!(" rusty-claude-cli login");
writeln!(
out,
" claw --allowedTools read,glob \"summarize Cargo.toml\""
)?;
writeln!(
out,
" claw --resume session.json /status /diff /export notes.txt"
)?;
writeln!(out, " claw login")?;
writeln!(out, " claw init")?;
Ok(())
}
fn print_help() {
let _ = print_help_to(&mut io::stdout());
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
filter_tool_specs, format_compact_report, format_cost_report, format_model_report, filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
format_model_switch_report, format_permissions_report, format_permissions_switch_report, format_model_report, format_model_switch_report, format_permissions_report,
format_resume_report, format_status_report, format_tool_call_start, format_tool_result, format_permissions_switch_report, format_resume_report, format_status_report,
normalize_permission_mode, parse_args, parse_git_status_metadata, print_help_to, format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
render_config_report, render_memory_report, render_repl_help, resolve_model_alias, parse_git_status_metadata, render_config_report, render_init_claude_md,
resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
StatusUsage, DEFAULT_MODEL, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::PathBuf; use std::path::{Path, PathBuf};
#[test] #[test]
fn defaults_to_repl_when_no_args() { fn defaults_to_repl_when_no_args() {
@@ -2445,34 +2527,6 @@ mod tests {
); );
} }
#[test]
fn resolves_model_aliases_in_args() {
let args = vec![
"--model".to_string(),
"opus".to_string(),
"explain".to_string(),
"this".to_string(),
];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "explain this".to_string(),
model: "claude-opus-4-6".to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
}
);
}
#[test]
fn resolves_known_model_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6");
assert_eq!(resolve_model_alias("haiku"), "claude-haiku-3-5-20241022");
assert_eq!(resolve_model_alias("claude-opus"), "claude-opus");
}
#[test] #[test]
fn parses_version_flags_without_initializing_prompt_mode() { fn parses_version_flags_without_initializing_prompt_mode() {
assert_eq!( assert_eq!(
@@ -2555,10 +2609,6 @@ mod tests {
parse_args(&["logout".to_string()]).expect("logout should parse"), parse_args(&["logout".to_string()]).expect("logout should parse"),
CliAction::Logout CliAction::Logout
); );
assert_eq!(
parse_args(&["init".to_string()]).expect("init should parse"),
CliAction::Init
);
} }
#[test] #[test]
@@ -2626,6 +2676,7 @@ mod tests {
assert!(help.contains("REPL")); assert!(help.contains("REPL"));
assert!(help.contains("/help")); assert!(help.contains("/help"));
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/sandbox"));
assert!(help.contains("/model [model]")); assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
@@ -2650,8 +2701,8 @@ mod tests {
assert_eq!( assert_eq!(
names, names,
vec![ vec![
"help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff", "help", "status", "sandbox", "compact", "clear", "cost", "config", "memory",
"version", "export", "init", "diff", "version", "export",
] ]
); );
} }
@@ -2713,11 +2764,12 @@ mod tests {
} }
#[test] #[test]
fn init_help_mentions_direct_subcommand() { fn init_report_uses_structured_output() {
let mut help = Vec::new(); let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true);
print_help_to(&mut help).expect("help should render"); assert!(created.contains("Init"));
let help = String::from_utf8(help).expect("help should be utf8"); assert!(created.contains("Result created"));
assert!(help.contains("claw init")); let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false);
assert!(skipped.contains("skipped (already exists)"));
} }
#[test] #[test]
@@ -2768,6 +2820,7 @@ mod tests {
memory_file_count: 4, memory_file_count: 4,
project_root: Some(PathBuf::from("/tmp")), project_root: Some(PathBuf::from("/tmp")),
git_branch: Some("main".to_string()), git_branch: Some("main".to_string()),
sandbox_status: runtime::SandboxStatus::default(),
}, },
); );
assert!(status.contains("Status")); assert!(status.contains("Status"));
@@ -2879,7 +2932,7 @@ mod tests {
#[test] #[test]
fn init_template_mentions_detected_rust_workspace() { fn init_template_mentions_detected_rust_workspace() {
let rendered = crate::init::render_init_claude_md(std::path::Path::new(".")); let rendered = render_init_claude_md(Path::new("."));
assert!(rendered.contains("# CLAUDE.md")); assert!(rendered.contains("# CLAUDE.md"));
assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings")); assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
} }
@@ -2929,3 +2982,17 @@ mod tests {
assert!(done.contains("contents")); assert!(done.contains("contents"));
} }
} }
#[cfg(test)]
mod sandbox_report_tests {
use super::format_sandbox_report;
#[test]
fn sandbox_report_renders_expected_fields() {
let report = format_sandbox_report(&runtime::SandboxStatus::default());
assert!(report.contains("Sandbox"));
assert!(report.contains("Enabled"));
assert!(report.contains("Filesystem mode"));
assert!(report.contains("Fallback reason"));
}
}

View File

@@ -21,7 +21,6 @@ pub struct ColorTheme {
inline_code: Color, inline_code: Color,
link: Color, link: Color,
quote: Color, quote: Color,
table_border: Color,
spinner_active: Color, spinner_active: Color,
spinner_done: Color, spinner_done: Color,
spinner_failed: Color, spinner_failed: Color,
@@ -36,7 +35,6 @@ impl Default for ColorTheme {
inline_code: Color::Green, inline_code: Color::Green,
link: Color::Blue, link: Color::Blue,
quote: Color::DarkGrey, quote: Color::DarkGrey,
table_border: Color::DarkCyan,
spinner_active: Color::Blue, spinner_active: Color::Blue,
spinner_done: Color::Green, spinner_done: Color::Green,
spinner_failed: Color::Red, spinner_failed: Color::Red,
@@ -115,70 +113,24 @@ impl Spinner {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
enum ListKind {
Unordered,
Ordered { next_index: u64 },
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct TableState {
headers: Vec<String>,
rows: Vec<Vec<String>>,
current_row: Vec<String>,
current_cell: String,
in_head: bool,
}
impl TableState {
fn push_cell(&mut self) {
let cell = self.current_cell.trim().to_string();
self.current_row.push(cell);
self.current_cell.clear();
}
fn finish_row(&mut self) {
if self.current_row.is_empty() {
return;
}
let row = std::mem::take(&mut self.current_row);
if self.in_head {
self.headers = row;
} else {
self.rows.push(row);
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
struct RenderState { struct RenderState {
emphasis: usize, emphasis: usize,
strong: usize, strong: usize,
quote: usize, quote: usize,
list_stack: Vec<ListKind>, list: usize,
table: Option<TableState>,
} }
impl RenderState { impl RenderState {
fn style_text(&self, text: &str, theme: &ColorTheme) -> String { fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
let mut styled = text.to_string();
if self.strong > 0 { if self.strong > 0 {
styled = format!("{}", styled.bold().with(theme.strong)); format!("{}", text.bold().with(theme.strong))
} } else if self.emphasis > 0 {
if self.emphasis > 0 { format!("{}", text.italic().with(theme.emphasis))
styled = format!("{}", styled.italic().with(theme.emphasis)); } else if self.quote > 0 {
} format!("{}", text.with(theme.quote))
if self.quote > 0 {
styled = format!("{}", styled.with(theme.quote));
}
styled
}
fn capture_target_mut<'a>(&'a mut self, output: &'a mut String) -> &'a mut String {
if let Some(table) = self.table.as_mut() {
&mut table.current_cell
} else { } else {
output text.to_string()
} }
} }
} }
@@ -238,7 +190,6 @@ impl TerminalRenderer {
output.trim_end().to_string() output.trim_end().to_string()
} }
#[allow(clippy::too_many_lines)]
fn render_event( fn render_event(
&self, &self,
event: Event<'_>, event: Event<'_>,
@@ -252,22 +203,12 @@ impl TerminalRenderer {
Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output), Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"), Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output), Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
Event::End(TagEnd::BlockQuote(..)) => { Event::End(TagEnd::BlockQuote(..) | TagEnd::Item)
state.quote = state.quote.saturating_sub(1); | Event::SoftBreak
output.push('\n'); | Event::HardBreak => output.push('\n'),
} Event::Start(Tag::List(_)) => state.list += 1,
Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
state.capture_target_mut(output).push('\n');
}
Event::Start(Tag::List(first_item)) => {
let kind = match first_item {
Some(index) => ListKind::Ordered { next_index: index },
None => ListKind::Unordered,
};
state.list_stack.push(kind);
}
Event::End(TagEnd::List(..)) => { Event::End(TagEnd::List(..)) => {
state.list_stack.pop(); state.list = state.list.saturating_sub(1);
output.push('\n'); output.push('\n');
} }
Event::Start(Tag::Item) => Self::start_item(state, output), Event::Start(Tag::Item) => Self::start_item(state, output),
@@ -291,85 +232,57 @@ impl TerminalRenderer {
Event::Start(Tag::Strong) => state.strong += 1, Event::Start(Tag::Strong) => state.strong += 1,
Event::End(TagEnd::Strong) => state.strong = state.strong.saturating_sub(1), Event::End(TagEnd::Strong) => state.strong = state.strong.saturating_sub(1),
Event::Code(code) => { Event::Code(code) => {
let rendered = let _ = write!(
format!("{}", format!("`{code}`").with(self.color_theme.inline_code)); output,
state.capture_target_mut(output).push_str(&rendered); "{}",
format!("`{code}`").with(self.color_theme.inline_code)
);
} }
Event::Rule => output.push_str("---\n"), Event::Rule => output.push_str("---\n"),
Event::Text(text) => { Event::Text(text) => {
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block); self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
} }
Event::Html(html) | Event::InlineHtml(html) => { Event::Html(html) | Event::InlineHtml(html) => output.push_str(&html),
state.capture_target_mut(output).push_str(&html);
}
Event::FootnoteReference(reference) => { Event::FootnoteReference(reference) => {
let _ = write!(state.capture_target_mut(output), "[{reference}]"); let _ = write!(output, "[{reference}]");
}
Event::TaskListMarker(done) => {
state
.capture_target_mut(output)
.push_str(if done { "[x] " } else { "[ ] " });
}
Event::InlineMath(math) | Event::DisplayMath(math) => {
state.capture_target_mut(output).push_str(&math);
} }
Event::TaskListMarker(done) => output.push_str(if done { "[x] " } else { "[ ] " }),
Event::InlineMath(math) | Event::DisplayMath(math) => output.push_str(&math),
Event::Start(Tag::Link { dest_url, .. }) => { Event::Start(Tag::Link { dest_url, .. }) => {
let rendered = format!( let _ = write!(
output,
"{}", "{}",
format!("[{dest_url}]") format!("[{dest_url}]")
.underlined() .underlined()
.with(self.color_theme.link) .with(self.color_theme.link)
); );
state.capture_target_mut(output).push_str(&rendered);
} }
Event::Start(Tag::Image { dest_url, .. }) => { Event::Start(Tag::Image { dest_url, .. }) => {
let rendered = format!( let _ = write!(
output,
"{}", "{}",
format!("[image:{dest_url}]").with(self.color_theme.link) format!("[image:{dest_url}]").with(self.color_theme.link)
); );
state.capture_target_mut(output).push_str(&rendered);
} }
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()), Event::Start(
Event::End(TagEnd::Table) => { Tag::Paragraph
if let Some(table) = state.table.take() { | Tag::Table(..)
output.push_str(&self.render_table(&table)); | Tag::TableHead
output.push_str("\n\n"); | Tag::TableRow
} | Tag::TableCell
} | Tag::MetadataBlock(..)
Event::Start(Tag::TableHead) => { | _,
if let Some(table) = state.table.as_mut() { )
table.in_head = true; | Event::End(
} TagEnd::Link
} | TagEnd::Image
Event::End(TagEnd::TableHead) => { | TagEnd::Table
if let Some(table) = state.table.as_mut() { | TagEnd::TableHead
table.finish_row(); | TagEnd::TableRow
table.in_head = false; | TagEnd::TableCell
} | TagEnd::MetadataBlock(..)
} | _,
Event::Start(Tag::TableRow) => { ) => {}
if let Some(table) = state.table.as_mut() {
table.current_row.clear();
table.current_cell.clear();
}
}
Event::End(TagEnd::TableRow) => {
if let Some(table) = state.table.as_mut() {
table.finish_row();
}
}
Event::Start(Tag::TableCell) => {
if let Some(table) = state.table.as_mut() {
table.current_cell.clear();
}
}
Event::End(TagEnd::TableCell) => {
if let Some(table) = state.table.as_mut() {
table.push_cell();
}
}
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
| Event::End(TagEnd::Link | TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
} }
} }
@@ -389,19 +302,9 @@ impl TerminalRenderer {
let _ = write!(output, "{}", "".with(self.color_theme.quote)); let _ = write!(output, "{}", "".with(self.color_theme.quote));
} }
fn start_item(state: &mut RenderState, output: &mut String) { fn start_item(state: &RenderState, output: &mut String) {
let depth = state.list_stack.len().saturating_sub(1); output.push_str(&" ".repeat(state.list.saturating_sub(1)));
output.push_str(&" ".repeat(depth)); output.push_str(" ");
let marker = match state.list_stack.last_mut() {
Some(ListKind::Ordered { next_index }) => {
let value = *next_index;
*next_index += 1;
format!("{value}. ")
}
_ => "".to_string(),
};
output.push_str(&marker);
} }
fn start_code_block(&self, code_language: &str, output: &mut String) { fn start_code_block(&self, code_language: &str, output: &mut String) {
@@ -425,7 +328,7 @@ impl TerminalRenderer {
fn push_text( fn push_text(
&self, &self,
text: &str, text: &str,
state: &mut RenderState, state: &RenderState,
output: &mut String, output: &mut String,
code_buffer: &mut String, code_buffer: &mut String,
in_code_block: bool, in_code_block: bool,
@@ -433,82 +336,10 @@ impl TerminalRenderer {
if in_code_block { if in_code_block {
code_buffer.push_str(text); code_buffer.push_str(text);
} else { } else {
let rendered = state.style_text(text, &self.color_theme); output.push_str(&state.style_text(text, &self.color_theme));
state.capture_target_mut(output).push_str(&rendered);
} }
} }
fn render_table(&self, table: &TableState) -> String {
let mut rows = Vec::new();
if !table.headers.is_empty() {
rows.push(table.headers.clone());
}
rows.extend(table.rows.iter().cloned());
if rows.is_empty() {
return String::new();
}
let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
let widths = (0..column_count)
.map(|column| {
rows.iter()
.filter_map(|row| row.get(column))
.map(|cell| visible_width(cell))
.max()
.unwrap_or(0)
})
.collect::<Vec<_>>();
let border = format!("{}", "".with(self.color_theme.table_border));
let separator = widths
.iter()
.map(|width| "".repeat(*width + 2))
.collect::<Vec<_>>()
.join(&format!("{}", "".with(self.color_theme.table_border)));
let separator = format!("{border}{separator}{border}");
let mut output = String::new();
if !table.headers.is_empty() {
output.push_str(&self.render_table_row(&table.headers, &widths, true));
output.push('\n');
output.push_str(&separator);
if !table.rows.is_empty() {
output.push('\n');
}
}
for (index, row) in table.rows.iter().enumerate() {
output.push_str(&self.render_table_row(row, &widths, false));
if index + 1 < table.rows.len() {
output.push('\n');
}
}
output
}
fn render_table_row(&self, row: &[String], widths: &[usize], is_header: bool) -> String {
let border = format!("{}", "".with(self.color_theme.table_border));
let mut line = String::new();
line.push_str(&border);
for (index, width) in widths.iter().enumerate() {
let cell = row.get(index).map_or("", String::as_str);
line.push(' ');
if is_header {
let _ = write!(line, "{}", cell.bold().with(self.color_theme.heading));
} else {
line.push_str(cell);
}
let padding = width.saturating_sub(visible_width(cell));
line.push_str(&" ".repeat(padding + 1));
line.push_str(&border);
}
line
}
#[must_use] #[must_use]
pub fn highlight_code(&self, code: &str, language: &str) -> String { pub fn highlight_code(&self, code: &str, language: &str) -> String {
let syntax = self let syntax = self
@@ -541,35 +372,31 @@ impl TerminalRenderer {
} }
} }
fn visible_width(input: &str) -> usize {
strip_ansi(input).chars().count()
}
fn strip_ansi(input: &str) -> String {
let mut output = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
}
} else {
output.push(ch);
}
}
output
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{strip_ansi, Spinner, TerminalRenderer}; use super::{Spinner, TerminalRenderer};
fn strip_ansi(input: &str) -> String {
let mut output = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
}
} else {
output.push(ch);
}
}
output
}
#[test] #[test]
fn renders_markdown_with_styling_and_lists() { fn renders_markdown_with_styling_and_lists() {
@@ -595,34 +422,6 @@ mod tests {
assert!(markdown_output.contains('\u{1b}')); assert!(markdown_output.contains('\u{1b}'));
} }
#[test]
fn renders_ordered_and_nested_lists() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output =
terminal_renderer.render_markdown("1. first\n2. second\n - nested\n - child");
let plain_text = strip_ansi(&markdown_output);
assert!(plain_text.contains("1. first"));
assert!(plain_text.contains("2. second"));
assert!(plain_text.contains(" • nested"));
assert!(plain_text.contains(" • child"));
}
#[test]
fn renders_tables_with_alignment() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output = terminal_renderer
.render_markdown("| Name | Value |\n| ---- | ----- |\n| alpha | 1 |\n| beta | 22 |");
let plain_text = strip_ansi(&markdown_output);
let lines = plain_text.lines().collect::<Vec<_>>();
assert_eq!(lines[0], "│ Name │ Value │");
assert_eq!(lines[1], "│───────┼───────│");
assert_eq!(lines[2], "│ alpha │ 1 │");
assert_eq!(lines[3], "│ beta │ 22 │");
assert!(markdown_output.contains('\u{1b}'));
}
#[test] #[test]
fn spinner_advances_frames() { fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new(); let terminal_renderer = TerminalRenderer::new();

View File

@@ -62,7 +62,11 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"timeout": { "type": "integer", "minimum": 1 }, "timeout": { "type": "integer", "minimum": 1 },
"description": { "type": "string" }, "description": { "type": "string" },
"run_in_background": { "type": "boolean" }, "run_in_background": { "type": "boolean" },
"dangerouslyDisableSandbox": { "type": "boolean" } "dangerouslyDisableSandbox": { "type": "boolean" },
"namespaceRestrictions": { "type": "boolean" },
"isolateNetwork": { "type": "boolean" },
"filesystemMode": { "type": "string", "enum": ["off", "workspace-only", "allow-list"] },
"allowedMounts": { "type": "array", "items": { "type": "string" } }
}, },
"required": ["command"], "required": ["command"],
"additionalProperties": false "additionalProperties": false
@@ -2215,7 +2219,7 @@ fn execute_shell_command(
persisted_output_path: None, persisted_output_path: None,
persisted_output_size: None, persisted_output_size: None,
sandbox_status: None, sandbox_status: None,
}); });
} }
let mut process = std::process::Command::new(shell); let mut process = std::process::Command::new(shell);
@@ -2284,7 +2288,7 @@ Command exceeded timeout of {timeout_ms} ms",
persisted_output_path: None, persisted_output_path: None,
persisted_output_size: None, persisted_output_size: None,
sandbox_status: None, sandbox_status: None,
}); });
} }
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }