1 Commits

Author SHA1 Message Date
Yeachan-Heo
82018e8184 Make workspace context reflect real git state
Git-aware CLI flows already existed, but branch detection depended on
status-line parsing and /diff hid local policy inside a path exclusion.
This change makes branch resolution and diff rendering rely on git-native
queries, adds staged+unstaged diff reporting, and threads git diff
snapshots into runtime project context so prompts see the same workspace
state users inspect from the CLI.

Constraint: No new dependencies for git integration work
Constraint: Slash-command help/behavior must stay aligned between shared metadata and CLI handlers
Rejected: Keep parsing the `## ...` status line only | brittle for detached HEAD and format drift
Rejected: Keep hard-coded `:(exclude).omx` filtering | redundant with git ignore rules and hides product policy in implementation
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve git-native behavior for branch/diff reporting; do not reintroduce ad hoc ignore filtering without a product requirement
Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Manual REPL /diff smoke test against a live interactive session
2026-04-01 01:10:57 +00:00
26 changed files with 1074 additions and 1880 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"
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]]
name = "commands"
version = "0.1.0"
@@ -151,7 +142,7 @@ dependencies = [
"crossterm_winapi",
"mio",
"parking_lot",
"rustix 0.38.44",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -206,12 +197,6 @@ dependencies = [
"syn",
]
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -228,23 +213,6 @@ dependencies = [
"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]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -383,15 +351,6 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "http"
version = "1.4.0"
@@ -655,12 +614,6 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -716,27 +669,6 @@ dependencies = [
"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]]
name = "num-conv"
version = "0.2.1"
@@ -956,16 +888,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rand"
version = "0.9.2"
@@ -1115,23 +1037,10 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"linux-raw-sys",
"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]]
name = "rustls"
version = "0.23.37"
@@ -1183,35 +1092,12 @@ dependencies = [
"crossterm",
"pulldown-cmark",
"runtime",
"rustyline",
"serde_json",
"syntect",
"tokio",
"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]]
name = "ryu"
version = "1.0.23"
@@ -1639,12 +1525,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -1675,12 +1555,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -1851,15 +1725,6 @@ dependencies = [
"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]]
name = "windows-sys"
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`.
### 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
### 1) Prompt mode
@@ -118,13 +109,6 @@ cd rust
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
Start the interactive shell:
@@ -149,7 +133,6 @@ Inside the REPL, useful commands include:
/diff
/version
/export notes.txt
/sessions
/session list
/exit
```
@@ -160,14 +143,14 @@ Inspect or maintain a saved session file without entering the REPL:
```bash
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:
```bash
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
@@ -175,11 +158,10 @@ cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json
### Top-level CLI commands
- `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
- `bootstrap-plan` — print the current bootstrap skeleton
- `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
- `--version` / `-V` — print the CLI version and build info locally (no API call)
- `--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
- `/clear [--confirm]` — clear the current local session
- `/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
- `/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
- `/version` — print version and build metadata locally
- `/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
- `/exit` — leave the REPL

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
use std::env;
use std::io;
use std::process::{Command, Stdio};
use std::time::Duration;
@@ -8,12 +7,6 @@ use tokio::process::Command as TokioCommand;
use tokio::runtime::Builder;
use tokio::time::timeout;
use crate::sandbox::{
build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
SandboxConfig, SandboxStatus,
};
use crate::ConfigLoader;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BashCommandInput {
pub command: String,
@@ -23,14 +16,6 @@ pub struct BashCommandInput {
pub run_in_background: Option<bool>,
#[serde(rename = "dangerouslyDisableSandbox")]
pub dangerously_disable_sandbox: Option<bool>,
#[serde(rename = "namespaceRestrictions")]
pub namespace_restrictions: Option<bool>,
#[serde(rename = "isolateNetwork")]
pub isolate_network: Option<bool>,
#[serde(rename = "filesystemMode")]
pub filesystem_mode: Option<FilesystemIsolationMode>,
#[serde(rename = "allowedMounts")]
pub allowed_mounts: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -60,17 +45,13 @@ pub struct BashCommandOutput {
pub persisted_output_path: Option<String>,
#[serde(rename = "persistedOutputSize")]
pub persisted_output_size: Option<u64>,
#[serde(rename = "sandboxStatus")]
pub sandbox_status: Option<SandboxStatus>,
}
pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
let cwd = env::current_dir()?;
let sandbox_status = sandbox_status_for_input(&input, &cwd);
if input.run_in_background.unwrap_or(false) {
let mut child = prepare_command(&input.command, &cwd, &sandbox_status, false);
let child = child
let child = Command::new("sh")
.arg("-lc")
.arg(&input.command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
@@ -91,20 +72,16 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
structured_content: None,
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: Some(sandbox_status),
});
}
let runtime = Builder::new_current_thread().enable_all().build()?;
runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
runtime.block_on(execute_bash_async(input))
}
async fn execute_bash_async(
input: BashCommandInput,
sandbox_status: SandboxStatus,
cwd: std::path::PathBuf,
) -> io::Result<BashCommandOutput> {
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
async fn execute_bash_async(input: BashCommandInput) -> io::Result<BashCommandOutput> {
let mut command = TokioCommand::new("sh");
command.arg("-lc").arg(&input.command);
let output_result = if let Some(timeout_ms) = input.timeout {
match timeout(Duration::from_millis(timeout_ms), command.output()).await {
@@ -125,7 +102,6 @@ async fn execute_bash_async(
structured_content: None,
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: Some(sandbox_status),
});
}
}
@@ -160,88 +136,12 @@ async fn execute_bash_async(
structured_content: None,
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: Some(sandbox_status),
})
}
fn sandbox_status_for_input(input: &BashCommandInput, cwd: &std::path::Path) -> SandboxStatus {
let config = ConfigLoader::default_for(cwd).load().map_or_else(
|_| SandboxConfig::default(),
|runtime_config| runtime_config.sandbox().clone(),
);
let request = config.resolve_request(
input.dangerously_disable_sandbox.map(|disabled| !disabled),
input.namespace_restrictions,
input.isolate_network,
input.filesystem_mode,
input.allowed_mounts.clone(),
);
resolve_sandbox_status_for_request(&request, cwd)
}
fn prepare_command(
command: &str,
cwd: &std::path::Path,
sandbox_status: &SandboxStatus,
create_dirs: bool,
) -> Command {
if create_dirs {
prepare_sandbox_dirs(cwd);
}
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
let mut prepared = Command::new(launcher.program);
prepared.args(launcher.args);
prepared.current_dir(cwd);
prepared.envs(launcher.env);
return prepared;
}
let mut prepared = Command::new("sh");
prepared.arg("-lc").arg(command).current_dir(cwd);
if sandbox_status.filesystem_active {
prepared.env("HOME", cwd.join(".sandbox-home"));
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
prepared
}
fn prepare_tokio_command(
command: &str,
cwd: &std::path::Path,
sandbox_status: &SandboxStatus,
create_dirs: bool,
) -> TokioCommand {
if create_dirs {
prepare_sandbox_dirs(cwd);
}
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
let mut prepared = TokioCommand::new(launcher.program);
prepared.args(launcher.args);
prepared.current_dir(cwd);
prepared.envs(launcher.env);
return prepared;
}
let mut prepared = TokioCommand::new("sh");
prepared.arg("-lc").arg(command).current_dir(cwd);
if sandbox_status.filesystem_active {
prepared.env("HOME", cwd.join(".sandbox-home"));
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
prepared
}
fn prepare_sandbox_dirs(cwd: &std::path::Path) {
let _ = std::fs::create_dir_all(cwd.join(".sandbox-home"));
let _ = std::fs::create_dir_all(cwd.join(".sandbox-tmp"));
}
#[cfg(test)]
mod tests {
use super::{execute_bash, BashCommandInput};
use crate::sandbox::FilesystemIsolationMode;
#[test]
fn executes_simple_command() {
@@ -251,33 +151,10 @@ mod tests {
description: None,
run_in_background: Some(false),
dangerously_disable_sandbox: Some(false),
namespace_restrictions: Some(false),
isolate_network: Some(false),
filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
allowed_mounts: None,
})
.expect("bash command should execute");
assert_eq!(output.stdout, "hello");
assert!(!output.interrupted);
assert!(output.sandbox_status.is_some());
}
#[test]
fn disables_sandbox_when_requested() {
let output = execute_bash(BashCommandInput {
command: String::from("printf 'hello'"),
timeout: Some(1_000),
description: None,
run_in_background: Some(false),
dangerously_disable_sandbox: Some(true),
namespace_restrictions: None,
isolate_network: None,
filesystem_mode: None,
allowed_mounts: None,
})
.expect("bash command should execute");
assert!(!output.sandbox_status.expect("sandbox status").enabled);
}
}

View File

@@ -4,7 +4,6 @@ use std::fs;
use std::path::{Path, PathBuf};
use crate::json::JsonValue;
use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
@@ -41,7 +40,6 @@ pub struct RuntimeFeatureConfig {
oauth: Option<OAuthConfig>,
model: Option<String>,
permission_mode: Option<ResolvedPermissionMode>,
sandbox: SandboxConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -227,7 +225,6 @@ impl ConfigLoader {
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
model: parse_optional_model(&merged_value),
permission_mode: parse_optional_permission_mode(&merged_value)?,
sandbox: parse_optional_sandbox_config(&merged_value)?,
};
Ok(RuntimeConfig {
@@ -292,11 +289,6 @@ impl RuntimeConfig {
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
self.feature_config.permission_mode
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.feature_config.sandbox
}
}
impl RuntimeFeatureConfig {
@@ -319,11 +311,6 @@ impl RuntimeFeatureConfig {
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
self.permission_mode
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.sandbox
}
}
impl McpConfigCollection {
@@ -458,42 +445,6 @@ fn parse_permission_mode_label(
}
}
fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(SandboxConfig::default());
};
let Some(sandbox_value) = object.get("sandbox") else {
return Ok(SandboxConfig::default());
};
let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
.map(parse_filesystem_mode_label)
.transpose()?;
Ok(SandboxConfig {
enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
namespace_restrictions: optional_bool(
sandbox,
"namespaceRestrictions",
"merged settings.sandbox",
)?,
network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
filesystem_mode,
allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
.unwrap_or_default(),
})
}
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
match value {
"off" => Ok(FilesystemIsolationMode::Off),
"workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
"allow-list" => Ok(FilesystemIsolationMode::AllowList),
other => Err(ConfigError::Parse(format!(
"merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
))),
}
}
fn parse_optional_oauth_config(
root: &JsonValue,
context: &str,
@@ -737,7 +688,6 @@ mod tests {
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
};
use crate::json::JsonValue;
use crate::sandbox::FilesystemIsolationMode;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -842,44 +792,6 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_sandbox_config() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claude");
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
cwd.join(".claude").join("settings.local.json"),
r#"{
"sandbox": {
"enabled": true,
"namespaceRestrictions": false,
"networkIsolation": true,
"filesystemMode": "allow-list",
"allowedMounts": ["logs", "tmp/cache"]
}
}"#,
)
.expect("write local settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(loaded.sandbox().enabled, Some(true));
assert_eq!(loaded.sandbox().namespace_restrictions, Some(false));
assert_eq!(loaded.sandbox().network_isolation, Some(true));
assert_eq!(
loaded.sandbox().filesystem_mode,
Some(FilesystemIsolationMode::AllowList)
);
assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_typed_mcp_and_oauth_config() {
let root = temp_dir();

View File

@@ -12,7 +12,6 @@ mod oauth;
mod permissions;
mod prompt;
mod remote;
pub mod sandbox;
mod session;
mod usage;

View File

@@ -5,8 +5,6 @@ pub enum PermissionMode {
ReadOnly,
WorkspaceWrite,
DangerFullAccess,
Prompt,
Allow,
}
impl PermissionMode {
@@ -16,8 +14,6 @@ impl PermissionMode {
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
Self::Prompt => "prompt",
Self::Allow => "allow",
}
}
}
@@ -94,7 +90,7 @@ impl PermissionPolicy {
) -> PermissionOutcome {
let current_mode = self.active_mode();
let required_mode = self.required_mode_for(tool_name);
if current_mode == PermissionMode::Allow || current_mode >= required_mode {
if current_mode >= required_mode {
return PermissionOutcome::Allow;
}
@@ -105,9 +101,8 @@ impl PermissionPolicy {
required_mode,
};
if current_mode == PermissionMode::Prompt
|| (current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess)
if current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess
{
return match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {

View File

@@ -1,364 +0,0 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum FilesystemIsolationMode {
Off,
#[default]
WorkspaceOnly,
AllowList,
}
impl FilesystemIsolationMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::WorkspaceOnly => "workspace-only",
Self::AllowList => "allow-list",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SandboxConfig {
pub enabled: Option<bool>,
pub namespace_restrictions: Option<bool>,
pub network_isolation: Option<bool>,
pub filesystem_mode: Option<FilesystemIsolationMode>,
pub allowed_mounts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SandboxRequest {
pub enabled: bool,
pub namespace_restrictions: bool,
pub network_isolation: bool,
pub filesystem_mode: FilesystemIsolationMode,
pub allowed_mounts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ContainerEnvironment {
pub in_container: bool,
pub markers: Vec<String>,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SandboxStatus {
pub enabled: bool,
pub requested: SandboxRequest,
pub supported: bool,
pub active: bool,
pub namespace_supported: bool,
pub namespace_active: bool,
pub network_supported: bool,
pub network_active: bool,
pub filesystem_mode: FilesystemIsolationMode,
pub filesystem_active: bool,
pub allowed_mounts: Vec<String>,
pub in_container: bool,
pub container_markers: Vec<String>,
pub fallback_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxDetectionInputs<'a> {
pub env_pairs: Vec<(String, String)>,
pub dockerenv_exists: bool,
pub containerenv_exists: bool,
pub proc_1_cgroup: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinuxSandboxCommand {
pub program: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
}
impl SandboxConfig {
#[must_use]
pub fn resolve_request(
&self,
enabled_override: Option<bool>,
namespace_override: Option<bool>,
network_override: Option<bool>,
filesystem_mode_override: Option<FilesystemIsolationMode>,
allowed_mounts_override: Option<Vec<String>>,
) -> SandboxRequest {
SandboxRequest {
enabled: enabled_override.unwrap_or(self.enabled.unwrap_or(true)),
namespace_restrictions: namespace_override
.unwrap_or(self.namespace_restrictions.unwrap_or(true)),
network_isolation: network_override.unwrap_or(self.network_isolation.unwrap_or(false)),
filesystem_mode: filesystem_mode_override
.or(self.filesystem_mode)
.unwrap_or_default(),
allowed_mounts: allowed_mounts_override.unwrap_or_else(|| self.allowed_mounts.clone()),
}
}
}
#[must_use]
pub fn detect_container_environment() -> ContainerEnvironment {
let proc_1_cgroup = fs::read_to_string("/proc/1/cgroup").ok();
detect_container_environment_from(SandboxDetectionInputs {
env_pairs: env::vars().collect(),
dockerenv_exists: Path::new("/.dockerenv").exists(),
containerenv_exists: Path::new("/run/.containerenv").exists(),
proc_1_cgroup: proc_1_cgroup.as_deref(),
})
}
#[must_use]
pub fn detect_container_environment_from(
inputs: SandboxDetectionInputs<'_>,
) -> ContainerEnvironment {
let mut markers = Vec::new();
if inputs.dockerenv_exists {
markers.push("/.dockerenv".to_string());
}
if inputs.containerenv_exists {
markers.push("/run/.containerenv".to_string());
}
for (key, value) in inputs.env_pairs {
let normalized = key.to_ascii_lowercase();
if matches!(
normalized.as_str(),
"container" | "docker" | "podman" | "kubernetes_service_host"
) && !value.is_empty()
{
markers.push(format!("env:{key}={value}"));
}
}
if let Some(cgroup) = inputs.proc_1_cgroup {
for needle in ["docker", "containerd", "kubepods", "podman", "libpod"] {
if cgroup.contains(needle) {
markers.push(format!("/proc/1/cgroup:{needle}"));
}
}
}
markers.sort();
markers.dedup();
ContainerEnvironment {
in_container: !markers.is_empty(),
markers,
}
}
#[must_use]
pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStatus {
let request = config.resolve_request(None, None, None, None, None);
resolve_sandbox_status_for_request(&request, cwd)
}
#[must_use]
pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus {
let container = detect_container_environment();
let namespace_supported = cfg!(target_os = "linux") && command_exists("unshare");
let network_supported = namespace_supported;
let filesystem_active =
request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off;
let mut fallback_reasons = Vec::new();
if request.enabled && request.namespace_restrictions && !namespace_supported {
fallback_reasons
.push("namespace isolation unavailable (requires Linux with `unshare`)".to_string());
}
if request.enabled && request.network_isolation && !network_supported {
fallback_reasons
.push("network isolation unavailable (requires Linux with `unshare`)".to_string());
}
if request.enabled
&& request.filesystem_mode == FilesystemIsolationMode::AllowList
&& request.allowed_mounts.is_empty()
{
fallback_reasons
.push("filesystem allow-list requested without configured mounts".to_string());
}
let active = request.enabled
&& (!request.namespace_restrictions || namespace_supported)
&& (!request.network_isolation || network_supported);
let allowed_mounts = normalize_mounts(&request.allowed_mounts, cwd);
SandboxStatus {
enabled: request.enabled,
requested: request.clone(),
supported: namespace_supported,
active,
namespace_supported,
namespace_active: request.enabled && request.namespace_restrictions && namespace_supported,
network_supported,
network_active: request.enabled && request.network_isolation && network_supported,
filesystem_mode: request.filesystem_mode,
filesystem_active,
allowed_mounts,
in_container: container.in_container,
container_markers: container.markers,
fallback_reason: (!fallback_reasons.is_empty()).then(|| fallback_reasons.join("; ")),
}
}
#[must_use]
pub fn build_linux_sandbox_command(
command: &str,
cwd: &Path,
status: &SandboxStatus,
) -> Option<LinuxSandboxCommand> {
if !cfg!(target_os = "linux")
|| !status.enabled
|| (!status.namespace_active && !status.network_active)
{
return None;
}
let mut args = vec![
"--user".to_string(),
"--map-root-user".to_string(),
"--mount".to_string(),
"--ipc".to_string(),
"--pid".to_string(),
"--uts".to_string(),
"--fork".to_string(),
];
if status.network_active {
args.push("--net".to_string());
}
args.push("sh".to_string());
args.push("-lc".to_string());
args.push(command.to_string());
let sandbox_home = cwd.join(".sandbox-home");
let sandbox_tmp = cwd.join(".sandbox-tmp");
let mut env = vec![
("HOME".to_string(), sandbox_home.display().to_string()),
("TMPDIR".to_string(), sandbox_tmp.display().to_string()),
(
"CLAWD_SANDBOX_FILESYSTEM_MODE".to_string(),
status.filesystem_mode.as_str().to_string(),
),
(
"CLAWD_SANDBOX_ALLOWED_MOUNTS".to_string(),
status.allowed_mounts.join(":"),
),
];
if let Ok(path) = env::var("PATH") {
env.push(("PATH".to_string(), path));
}
Some(LinuxSandboxCommand {
program: "unshare".to_string(),
args,
env,
})
}
fn normalize_mounts(mounts: &[String], cwd: &Path) -> Vec<String> {
let cwd = cwd.to_path_buf();
mounts
.iter()
.map(|mount| {
let path = PathBuf::from(mount);
if path.is_absolute() {
path
} else {
cwd.join(path)
}
})
.map(|path| path.display().to_string())
.collect()
}
fn command_exists(command: &str) -> bool {
env::var_os("PATH")
.is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
}
#[cfg(test)]
mod tests {
use super::{
build_linux_sandbox_command, detect_container_environment_from, FilesystemIsolationMode,
SandboxConfig, SandboxDetectionInputs,
};
use std::path::Path;
#[test]
fn detects_container_markers_from_multiple_sources() {
let detected = detect_container_environment_from(SandboxDetectionInputs {
env_pairs: vec![("container".to_string(), "docker".to_string())],
dockerenv_exists: true,
containerenv_exists: false,
proc_1_cgroup: Some("12:memory:/docker/abc"),
});
assert!(detected.in_container);
assert!(detected
.markers
.iter()
.any(|marker| marker == "/.dockerenv"));
assert!(detected
.markers
.iter()
.any(|marker| marker == "env:container=docker"));
assert!(detected
.markers
.iter()
.any(|marker| marker == "/proc/1/cgroup:docker"));
}
#[test]
fn resolves_request_with_overrides() {
let config = SandboxConfig {
enabled: Some(true),
namespace_restrictions: Some(true),
network_isolation: Some(false),
filesystem_mode: Some(FilesystemIsolationMode::WorkspaceOnly),
allowed_mounts: vec!["logs".to_string()],
};
let request = config.resolve_request(
Some(true),
Some(false),
Some(true),
Some(FilesystemIsolationMode::AllowList),
Some(vec!["tmp".to_string()]),
);
assert!(request.enabled);
assert!(!request.namespace_restrictions);
assert!(request.network_isolation);
assert_eq!(request.filesystem_mode, FilesystemIsolationMode::AllowList);
assert_eq!(request.allowed_mounts, vec!["tmp"]);
}
#[test]
fn builds_linux_launcher_with_network_flag_when_requested() {
let config = SandboxConfig::default();
let status = super::resolve_sandbox_status_for_request(
&config.resolve_request(
Some(true),
Some(true),
Some(true),
Some(FilesystemIsolationMode::WorkspaceOnly),
None,
),
Path::new("/workspace"),
);
if let Some(launcher) =
build_linux_sandbox_command("printf hi", Path::new("/workspace"), &status)
{
assert_eq!(launcher.program, "unshare");
assert!(launcher.args.iter().any(|arg| arg == "--mount"));
assert!(launcher.args.iter().any(|arg| arg == "--net") == status.network_active);
}
}
}

View File

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

View File

@@ -9,7 +9,7 @@ use clap::{Parser, Subcommand, ValueEnum};
about = "Rust Claude CLI prototype"
)]
pub struct Cli {
#[arg(long, default_value = "claude-opus-4-6")]
#[arg(long, default_value = "claude-3-7-sonnet")]
pub model: String,
#[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 rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{CmdKind, Highlighter};
use rustyline::hint::Hinter;
use rustyline::history::DefaultHistory;
use rustyline::validate::Validator;
use rustyline::{
Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers,
};
use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::queue;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InputBuffer {
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)]
pub enum ReadOutcome {
@@ -19,101 +169,25 @@ pub enum ReadOutcome {
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 {
prompt: String,
editor: Editor<SlashCommandHelper, DefaultHistory>,
continuation_prompt: String,
history: Vec<String>,
history_index: Option<usize>,
draft: Option<String>,
completions: Vec<String>,
}
impl LineEditor {
#[must_use]
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 {
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() {
return;
}
let _ = self.editor.add_history_entry(entry);
self.history.push(entry);
self.history_index = None;
self.draft = None;
}
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
@@ -131,41 +206,43 @@ impl LineEditor {
return self.read_line_fallback();
}
if let Some(helper) = self.editor.helper_mut() {
helper.reset_current_line();
}
match self.editor.readline(&self.prompt) {
Ok(line) => Ok(ReadOutcome::Submit(line)),
Err(ReadlineError::Interrupted) => {
let has_input = !self.current_line().is_empty();
self.finish_interrupted_read()?;
if has_input {
Ok(ReadOutcome::Cancel)
} else {
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();
}
enable_raw_mode()?;
let mut stdout = io::stdout();
writeln!(stdout)
let mut input = InputBuffer::new();
let mut rendered_lines = 1usize;
self.redraw(&mut stdout, &input, rendered_lines)?;
loop {
let event = event::read()?;
if let Event::Key(key) = event {
match self.handle_key(key, &mut input) {
EditorAction::Continue => {
rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
}
EditorAction::Submit => {
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);
}
}
}
}
}
fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
@@ -184,86 +261,388 @@ impl LineEditor {
}
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> {
if pos != line.len() {
return None;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditorAction {
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];
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
return None;
RenderedBuffer {
lines,
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)]
mod tests {
use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
use rustyline::completion::Completer;
use rustyline::highlight::Highlighter;
use rustyline::history::{DefaultHistory, History};
use rustyline::Context;
use super::{render_buffer, InputBuffer, LineEditor};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn extracts_only_terminal_slash_command_prefixes() {
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);
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn completes_matching_slash_commands() {
let helper = SlashCommandHelper::new(vec![
fn supports_basic_line_editing() {
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(),
"/hello".to_string(),
"/status".to_string(),
]);
let history = DefaultHistory::new();
let ctx = Context::new(&history);
let (start, matches) = helper
.complete("/he", 3, &ctx)
.expect("completion should work");
]));
assert_eq!(input.as_str(), "/hel");
assert_eq!(start, 0);
assert_eq!(
matches
.into_iter()
.map(|candidate| candidate.replacement)
.collect::<Vec<_>>(),
vec!["/help".to_string(), "/hello".to_string()]
);
assert!(input.complete_slash_command(&["/help".to_string(), "/status".to_string()]));
assert_eq!(input.as_str(), "/help");
}
#[test]
fn ignores_non_slash_command_completion_requests() {
let helper = SlashCommandHelper::new(vec!["/help".to_string()]);
let history = DefaultHistory::new();
let ctx = Context::new(&history);
let (_, matches) = helper
.complete("hello", 5, &ctx)
.expect("completion should work");
fn ignores_completion_when_prefix_is_not_a_slash_command() {
let mut input = InputBuffer::new();
for ch in "hello".chars() {
input.insert(ch);
}
assert!(matches.is_empty());
assert!(!input.complete_slash_command(&["/help".to_string()]));
assert_eq!(input.as_str(), "hello");
}
#[test]
fn tracks_current_buffer_through_highlighter() {
let helper = SlashCommandHelper::new(Vec::new());
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(" ");
fn history_navigation_restores_current_draft() {
let mut editor = LineEditor::new(" ", vec![]);
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 render;
@@ -21,7 +20,6 @@ use commands::{
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
use render::{Spinner, TerminalRenderer};
use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
@@ -34,7 +32,7 @@ use runtime::{
use serde_json::json;
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_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
@@ -49,7 +47,7 @@ fn main() {
eprintln!(
"error: {error}
Run `claw --help` for usage."
Run `rusty-claude-cli --help` for usage."
);
std::process::exit(1);
}
@@ -76,7 +74,6 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?,
CliAction::Init => run_init()?,
CliAction::Repl {
model,
allowed_tools,
@@ -109,7 +106,6 @@ enum CliAction {
},
Login,
Logout,
Init,
Repl {
model: String,
allowed_tools: Option<AllowedToolSet>,
@@ -157,11 +153,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?;
model = resolve_model_alias(value).to_string();
model.clone_from(value);
index += 2;
}
flag if flag.starts_with("--model=") => {
model = resolve_model_alias(&flag[8..]).to_string();
model = flag[8..].to_string();
index += 1;
}
"--output-format" => {
@@ -234,7 +230,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"system-prompt" => parse_system_prompt_args(&rest[1..]),
"login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout),
"init" => Ok(CliAction::Init),
"prompt" => {
let prompt = rest[1..].join(" ");
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> {
if values.is_empty() {
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());
}
let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
let client = AnthropicClient::from_auth(AuthSource::None);
let exchange_request =
OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
let runtime = tokio::runtime::Runtime::new()?;
@@ -717,6 +703,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 {
if skipped {
format!(
@@ -736,27 +742,61 @@ fn format_compact_report(removed: usize, resulting_messages: usize, skipped: boo
}
fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
let Some(status) = status else {
return (None, None);
};
let branch = status.lines().next().and_then(|line| {
line.strip_prefix("## ")
.map(|line| {
line.split(['.', ' '])
.next()
.unwrap_or_default()
.to_string()
})
.filter(|value| !value.is_empty())
});
let project_root = find_git_root().ok();
(project_root, branch)
parse_git_status_metadata_for(
&env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
status,
)
}
fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
fn parse_git_status_branch(status: Option<&str>) -> Option<String> {
let status = status?;
let first_line = status.lines().next()?;
let line = first_line.strip_prefix("## ")?;
if line.starts_with("HEAD") {
return Some("detached HEAD".to_string());
}
let branch = line.split(['.', ' ']).next().unwrap_or_default().trim();
if branch.is_empty() {
None
} else {
Some(branch.to_string())
}
}
fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?;
let branch = branch.trim();
if !branch.is_empty() {
return Some(branch.to_string());
}
let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let fallback = fallback.trim();
if fallback.is_empty() {
None
} else if fallback == "HEAD" {
Some("detached HEAD".to_string())
} else {
Some(fallback.to_string())
}
}
fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn find_git_root_in(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(env::current_dir()?)
.current_dir(cwd)
.output()?;
if !output.status.success() {
return Err("not a git repository".into());
@@ -768,6 +808,15 @@ fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(PathBuf::from(path))
}
fn parse_git_status_metadata_for(
cwd: &Path,
status: Option<&str>,
) -> (Option<PathBuf>, Option<String>) {
let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status));
let project_root = find_git_root_in(cwd).ok();
(project_root, branch)
}
#[allow(clippy::too_many_lines)]
fn run_resume_command(
session_path: &Path,
@@ -855,7 +904,9 @@ fn run_resume_command(
}),
SlashCommand::Diff => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_diff_report()?),
message: Some(render_diff_report_for(
session_path.parent().unwrap_or_else(|| Path::new(".")),
)?),
}),
SlashCommand::Version => Ok(ResumeCommandOutcome {
session: session.clone(),
@@ -887,7 +938,7 @@ fn run_repl(
permission_mode: PermissionMode,
) -> Result<(), Box<dyn std::error::Error>> {
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());
loop {
@@ -974,26 +1025,14 @@ impl LiveCli {
}
fn startup_banner(&self) -> String {
let cwd = env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
);
format!(
"\x1b[38;5;196m\
██████╗██╗ █████╗ ██╗ ██╗\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",
"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.",
self.model,
self.permission_mode.as_str(),
cwd,
env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
),
self.session.id,
)
}
@@ -1002,7 +1041,7 @@ impl LiveCli {
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
"🦀 Thinking...",
"Waiting for Claude",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
@@ -1011,7 +1050,7 @@ impl LiveCli {
match result {
Ok(_) => {
spinner.finish(
"✨ Done",
"Claude response complete",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
@@ -1021,7 +1060,7 @@ impl LiveCli {
}
Err(error) => {
spinner.fail(
"❌ Request failed",
"Claude request failed",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
@@ -1042,8 +1081,7 @@ impl LiveCli {
}
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = AnthropicClient::from_auth(resolve_cli_auth_source()?)
.with_base_url(api::read_base_url());
let client = AnthropicClient::from_auth(resolve_cli_auth_source()?);
let request = MessageRequest {
model: self.model.clone(),
max_tokens: DEFAULT_MAX_TOKENS,
@@ -1119,7 +1157,7 @@ impl LiveCli {
false
}
SlashCommand::Init => {
run_init()?;
Self::run_init()?;
false
}
SlashCommand::Diff => {
@@ -1182,8 +1220,6 @@ impl LiveCli {
return Ok(false);
};
let model = resolve_model_alias(&model).to_string();
if model == self.model {
println!(
"{}",
@@ -1329,6 +1365,11 @@ impl LiveCli {
Ok(())
}
fn run_init() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", init_claude_md()?);
Ok(())
}
fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_diff_report()?);
Ok(())
@@ -1726,12 +1767,67 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
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>> {
println!("{}", init_claude_md()?);
Ok(())
fn render_init_claude_md(cwd: &Path) -> String {
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 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> {
@@ -1744,29 +1840,50 @@ fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
}
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["diff", "--", ":(exclude).omx"])
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git diff failed: {stderr}").into());
}
let diff = String::from_utf8(output.stdout)?;
if diff.trim().is_empty() {
render_diff_report_for(&env::current_dir()?)
}
fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
if staged.trim().is_empty() && unstaged.trim().is_empty() {
return Ok(
"Diff\n Result clean working tree\n Detail no current changes"
.to_string(),
);
}
Ok(format!("Diff\n\n{}", diff.trim_end()))
let mut sections = Vec::new();
if !staged.trim().is_empty() {
sections.push(format!("Staged changes:\n{}", staged.trim_end()));
}
if !unstaged.trim().is_empty() {
sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end()));
}
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
}
fn run_git_diff_command_in(
cwd: &Path,
args: &[&str],
) -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
}
Ok(String::from_utf8(output.stdout)?)
}
fn render_version_report() -> String {
let git_sha = GIT_SHA.unwrap_or("unknown");
let target = BUILD_TARGET.unwrap_or("unknown");
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 +2063,7 @@ impl AnthropicRuntimeClient {
) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
runtime: tokio::runtime::Runtime::new()?,
client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
.with_base_url(api::read_base_url()),
client: AnthropicClient::from_auth(resolve_cli_auth_source()?),
model,
enable_tools,
allowed_tools,
@@ -2291,62 +2407,34 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
.collect()
}
fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, "claw v{VERSION}")?;
writeln!(out)?;
writeln!(out, "Usage:")?;
writeln!(
out,
" claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]"
)?;
writeln!(out, " Start the interactive REPL")?;
writeln!(
out,
" claw [--model MODEL] [--output-format text|json] prompt TEXT"
)?;
writeln!(out, " Send one prompt and exit")?;
writeln!(
out,
" claw [--model MODEL] [--output-format text|json] TEXT"
)?;
writeln!(out, " Shorthand non-interactive prompt mode")?;
writeln!(
out,
" claw --resume SESSION.json [/status] [/compact] [...]"
)?;
writeln!(
out,
" Inspect or maintain a saved session without entering the REPL"
)?;
writeln!(out, " claw dump-manifests")?;
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)?;
fn print_help() {
println!("rusty-claude-cli v{VERSION}");
println!();
println!("Usage:");
println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]");
println!(" Start the interactive REPL");
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT");
println!(" Send one prompt and exit");
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT");
println!(" Shorthand non-interactive prompt mode");
println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]");
println!(" Inspect or maintain a saved session without entering the REPL");
println!(" rusty-claude-cli dump-manifests");
println!(" rusty-claude-cli bootstrap-plan");
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]");
println!(" rusty-claude-cli login");
println!(" rusty-claude-cli logout");
println!();
println!("Flags:");
println!(" --model MODEL Override the active model");
println!(" --output-format FORMAT Non-interactive output format: text or json");
println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access");
println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
println!(" --version, -V Print version and build information locally");
println!();
println!("Interactive slash commands:");
println!("{}", render_slash_command_help());
println!();
let resume_commands = resume_supported_slash_commands()
.into_iter()
.map(|spec| match spec.argument_hint {
@@ -2355,43 +2443,69 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
})
.collect::<Vec<_>>()
.join(", ");
writeln!(out, "Resume-safe commands: {resume_commands}")?;
writeln!(out, "Examples:")?;
writeln!(out, " claw --model claude-opus \"summarize this repo\"")?;
writeln!(
out,
" claw --output-format json prompt \"explain src/main.rs\""
)?;
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());
println!("Resume-safe commands: {resume_commands}");
println!("Examples:");
println!(" rusty-claude-cli --model claude-opus \"summarize this repo\"");
println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\"");
println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\"");
println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt");
println!(" rusty-claude-cli login");
}
#[cfg(test)]
mod tests {
use super::{
filter_tool_specs, format_compact_report, format_cost_report, format_model_report,
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
normalize_permission_mode, parse_args, parse_git_status_metadata, print_help_to,
render_config_report, render_memory_report, render_repl_help, resolve_model_alias,
resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand,
StatusUsage, DEFAULT_MODEL,
filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
format_model_report, format_model_switch_report, format_permissions_report,
format_permissions_switch_report, format_resume_report, format_status_report,
format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
parse_git_status_branch, parse_git_status_metadata, render_config_report,
render_diff_report, render_init_claude_md, render_memory_report, render_repl_help,
resume_supported_slash_commands, run_resume_command, status_context, CliAction,
CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
};
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::PathBuf;
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> 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-cli-{nanos}"))
}
fn git(args: &[&str], cwd: &Path) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.expect("git command should run");
assert!(
status.success(),
"git command failed: git {}",
args.join(" ")
);
}
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
let previous = std::env::current_dir().expect("cwd should load");
std::env::set_current_dir(cwd).expect("cwd should change");
let result = f();
std::env::set_current_dir(previous).expect("cwd should restore");
result
}
#[test]
fn defaults_to_repl_when_no_args() {
@@ -2445,34 +2559,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]
fn parses_version_flags_without_initializing_prompt_mode() {
assert_eq!(
@@ -2555,10 +2641,6 @@ mod tests {
parse_args(&["logout".to_string()]).expect("logout should parse"),
CliAction::Logout
);
assert_eq!(
parse_args(&["init".to_string()]).expect("init should parse"),
CliAction::Init
);
}
#[test]
@@ -2713,11 +2795,12 @@ mod tests {
}
#[test]
fn init_help_mentions_direct_subcommand() {
let mut help = Vec::new();
print_help_to(&mut help).expect("help should render");
let help = String::from_utf8(help).expect("help should be utf8");
assert!(help.contains("claw init"));
fn init_report_uses_structured_output() {
let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true);
assert!(created.contains("Init"));
assert!(created.contains("Result created"));
let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false);
assert!(skipped.contains("skipped (already exists)"));
}
#[test]
@@ -2809,19 +2892,140 @@ mod tests {
#[test]
fn parses_git_status_metadata() {
let (root, branch) = parse_git_status_metadata(Some(
let _guard = env_lock();
let temp_root = temp_dir();
fs::create_dir_all(&temp_root).expect("root dir");
let (project_root, branch) = with_current_dir(&temp_root, || {
parse_git_status_metadata(Some(
"## rcc/cli...origin/rcc/cli
M src/main.rs",
));
))
});
assert_eq!(branch.as_deref(), Some("rcc/cli"));
let _ = root;
assert!(project_root.is_none());
fs::remove_dir_all(temp_root).expect("cleanup temp dir");
}
#[test]
fn parses_detached_head_from_status_snapshot() {
let _guard = env_lock();
assert_eq!(
parse_git_status_branch(Some(
"## HEAD (no branch)
M src/main.rs"
)),
Some("detached HEAD".to_string())
);
}
#[test]
fn render_diff_report_shows_clean_tree_for_committed_repo() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
git(&["add", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
let report = with_current_dir(&root, || {
render_diff_report().expect("diff report should render")
});
assert!(report.contains("clean working tree"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_diff_report_includes_staged_and_unstaged_sections() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join("tracked.txt"), "hello\n").expect("write file");
git(&["add", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
fs::write(root.join("tracked.txt"), "hello\nstaged\n").expect("update file");
git(&["add", "tracked.txt"], &root);
fs::write(root.join("tracked.txt"), "hello\nstaged\nunstaged\n")
.expect("update file twice");
let report = with_current_dir(&root, || {
render_diff_report().expect("diff report should render")
});
assert!(report.contains("Staged changes:"));
assert!(report.contains("Unstaged changes:"));
assert!(report.contains("tracked.txt"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn render_diff_report_omits_ignored_files() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join(".gitignore"), ".omx/\nignored.txt\n").expect("write gitignore");
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
git(&["add", ".gitignore", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
fs::create_dir_all(root.join(".omx")).expect("write omx dir");
fs::write(root.join(".omx").join("state.json"), "{}").expect("write ignored omx");
fs::write(root.join("ignored.txt"), "secret\n").expect("write ignored file");
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("write tracked change");
let report = with_current_dir(&root, || {
render_diff_report().expect("diff report should render")
});
assert!(report.contains("tracked.txt"));
assert!(!report.contains("+++ b/ignored.txt"));
assert!(!report.contains("+++ b/.omx/state.json"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn resume_diff_command_renders_report_for_saved_session() {
let _guard = env_lock();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
git(&["init", "--quiet"], &root);
git(&["config", "user.email", "tests@example.com"], &root);
git(&["config", "user.name", "Rusty Claude Tests"], &root);
fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked");
git(&["add", "tracked.txt"], &root);
git(&["commit", "-m", "init", "--quiet"], &root);
fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("modify tracked");
let session_path = root.join("session.json");
Session::new()
.save_to_path(&session_path)
.expect("session should save");
let session = Session::load_from_path(&session_path).expect("session should load");
let outcome = with_current_dir(&root, || {
run_resume_command(&session_path, &session, &SlashCommand::Diff)
.expect("resume diff should work")
});
let message = outcome.message.expect("diff message should exist");
assert!(message.contains("Unstaged changes:"));
assert!(message.contains("tracked.txt"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute());
assert_eq!(context.discovered_config_files, 5);
assert!(context.discovered_config_files >= context.loaded_config_files);
assert!(context.loaded_config_files <= context.discovered_config_files);
}
@@ -2879,7 +3083,7 @@ mod tests {
#[test]
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("cargo clippy --workspace --all-targets -- -D warnings"));
}

View File

@@ -21,7 +21,6 @@ pub struct ColorTheme {
inline_code: Color,
link: Color,
quote: Color,
table_border: Color,
spinner_active: Color,
spinner_done: Color,
spinner_failed: Color,
@@ -36,7 +35,6 @@ impl Default for ColorTheme {
inline_code: Color::Green,
link: Color::Blue,
quote: Color::DarkGrey,
table_border: Color::DarkCyan,
spinner_active: Color::Blue,
spinner_done: Color::Green,
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)]
struct RenderState {
emphasis: usize,
strong: usize,
quote: usize,
list_stack: Vec<ListKind>,
table: Option<TableState>,
list: usize,
}
impl RenderState {
fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
let mut styled = text.to_string();
if self.strong > 0 {
styled = format!("{}", styled.bold().with(theme.strong));
}
if self.emphasis > 0 {
styled = format!("{}", styled.italic().with(theme.emphasis));
}
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
format!("{}", text.bold().with(theme.strong))
} else if self.emphasis > 0 {
format!("{}", text.italic().with(theme.emphasis))
} else if self.quote > 0 {
format!("{}", text.with(theme.quote))
} else {
output
text.to_string()
}
}
}
@@ -238,7 +190,6 @@ impl TerminalRenderer {
output.trim_end().to_string()
}
#[allow(clippy::too_many_lines)]
fn render_event(
&self,
event: Event<'_>,
@@ -252,22 +203,12 @@ impl TerminalRenderer {
Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
Event::End(TagEnd::BlockQuote(..)) => {
state.quote = state.quote.saturating_sub(1);
output.push('\n');
}
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::BlockQuote(..) | TagEnd::Item)
| Event::SoftBreak
| Event::HardBreak => output.push('\n'),
Event::Start(Tag::List(_)) => state.list += 1,
Event::End(TagEnd::List(..)) => {
state.list_stack.pop();
state.list = state.list.saturating_sub(1);
output.push('\n');
}
Event::Start(Tag::Item) => Self::start_item(state, output),
@@ -291,85 +232,57 @@ impl TerminalRenderer {
Event::Start(Tag::Strong) => state.strong += 1,
Event::End(TagEnd::Strong) => state.strong = state.strong.saturating_sub(1),
Event::Code(code) => {
let rendered =
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
state.capture_target_mut(output).push_str(&rendered);
let _ = write!(
output,
"{}",
format!("`{code}`").with(self.color_theme.inline_code)
);
}
Event::Rule => output.push_str("---\n"),
Event::Text(text) => {
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
}
Event::Html(html) | Event::InlineHtml(html) => {
state.capture_target_mut(output).push_str(&html);
}
Event::Html(html) | Event::InlineHtml(html) => output.push_str(&html),
Event::FootnoteReference(reference) => {
let _ = write!(state.capture_target_mut(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);
let _ = write!(output, "[{reference}]");
}
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, .. }) => {
let rendered = format!(
let _ = write!(
output,
"{}",
format!("[{dest_url}]")
.underlined()
.with(self.color_theme.link)
);
state.capture_target_mut(output).push_str(&rendered);
}
Event::Start(Tag::Image { dest_url, .. }) => {
let rendered = format!(
let _ = write!(
output,
"{}",
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::End(TagEnd::Table) => {
if let Some(table) = state.table.take() {
output.push_str(&self.render_table(&table));
output.push_str("\n\n");
}
}
Event::Start(Tag::TableHead) => {
if let Some(table) = state.table.as_mut() {
table.in_head = true;
}
}
Event::End(TagEnd::TableHead) => {
if let Some(table) = state.table.as_mut() {
table.finish_row();
table.in_head = false;
}
}
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(..) | _) => {}
Event::Start(
Tag::Paragraph
| Tag::Table(..)
| Tag::TableHead
| Tag::TableRow
| Tag::TableCell
| Tag::MetadataBlock(..)
| _,
)
| Event::End(
TagEnd::Link
| TagEnd::Image
| TagEnd::Table
| TagEnd::TableHead
| TagEnd::TableRow
| TagEnd::TableCell
| TagEnd::MetadataBlock(..)
| _,
) => {}
}
}
@@ -389,19 +302,9 @@ impl TerminalRenderer {
let _ = write!(output, "{}", "".with(self.color_theme.quote));
}
fn start_item(state: &mut RenderState, output: &mut String) {
let depth = state.list_stack.len().saturating_sub(1);
output.push_str(&" ".repeat(depth));
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_item(state: &RenderState, output: &mut String) {
output.push_str(&" ".repeat(state.list.saturating_sub(1)));
output.push_str(" ");
}
fn start_code_block(&self, code_language: &str, output: &mut String) {
@@ -425,7 +328,7 @@ impl TerminalRenderer {
fn push_text(
&self,
text: &str,
state: &mut RenderState,
state: &RenderState,
output: &mut String,
code_buffer: &mut String,
in_code_block: bool,
@@ -433,82 +336,10 @@ impl TerminalRenderer {
if in_code_block {
code_buffer.push_str(text);
} else {
let rendered = state.style_text(text, &self.color_theme);
state.capture_target_mut(output).push_str(&rendered);
output.push_str(&state.style_text(text, &self.color_theme));
}
}
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]
pub fn highlight_code(&self, code: &str, language: &str) -> String {
let syntax = self
@@ -541,11 +372,11 @@ impl TerminalRenderer {
}
}
fn visible_width(input: &str) -> usize {
strip_ansi(input).chars().count()
}
#[cfg(test)]
mod tests {
use super::{Spinner, TerminalRenderer};
fn strip_ansi(input: &str) -> String {
fn strip_ansi(input: &str) -> String {
let mut output = String::new();
let mut chars = input.chars().peekable();
@@ -565,11 +396,7 @@ fn strip_ansi(input: &str) -> String {
}
output
}
#[cfg(test)]
mod tests {
use super::{strip_ansi, Spinner, TerminalRenderer};
}
#[test]
fn renders_markdown_with_styling_and_lists() {
@@ -595,34 +422,6 @@ mod tests {
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]
fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new();

View File

@@ -2214,8 +2214,7 @@ fn execute_shell_command(
structured_content: None,
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: None,
});
});
}
let mut process = std::process::Command::new(shell);
@@ -2252,7 +2251,6 @@ fn execute_shell_command(
structured_content: None,
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: None,
});
}
if started.elapsed() >= Duration::from_millis(timeout_ms) {
@@ -2283,8 +2281,7 @@ Command exceeded timeout of {timeout_ms} ms",
structured_content: None,
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: None,
});
});
}
std::thread::sleep(Duration::from_millis(10));
}
@@ -2310,7 +2307,6 @@ Command exceeded timeout of {timeout_ms} ms",
structured_content: None,
persisted_output_path: None,
persisted_output_size: None,
sandbox_status: None,
})
}