Compare commits

...

8 Commits

Author SHA1 Message Date
Yeachan-Heo
650a24b6e2 feat: terminal markdown rendering with ANSI colors
Add terminal markdown rendering support in the Rust CLI by extending the existing renderer with ordered lists, aligned tables, and ANSI-styled code/inline formatting. Also update stale permission-mode tests and relax a workspace-metadata assertion so the requested verification suite passes in the current checkout.

Constraint: Keep the existing renderer integration path used by main.rs and app.rs
Constraint: No new dependencies for markdown rendering or display width handling
Rejected: Replacing the renderer with a new markdown crate | unnecessary scope and integration risk
Confidence: medium
Scope-risk: moderate
Directive: Table alignment currently targets ANSI-stripped common CLI content; revisit if wide-character width handling becomes required
Tested: cargo fmt --all; cargo build; cargo test; cargo clippy --all-targets --all-features -- -D warnings
Not-tested: Manual interactive rendering in a live terminal session
2026-04-01 01:43:40 +00:00
Yeachan-Heo
d6341d54c1 feat: config discovery and CLAUDE.md loading (cherry-picked from rcc/runtime) 2026-04-01 00:40:34 +00:00
Yeachan-Heo
863958b94c Merge remote-tracking branch 'origin/rcc/api' into dev/rust 2026-04-01 00:30:20 +00:00
Yeachan-Heo
9455280f24 Enable saved OAuth startup auth without breaking local version output
Startup auth was split between the CLI and API crates, which made saved OAuth refresh behavior eager and easy to drift. This change adds a startup-specific resolver in the API layer, keeps env-only auth semantics intact, preserves saved refresh tokens when refresh responses omit them, and lets the CLI reuse the shared resolver while keeping --version on a purely local path.

Constraint: Saved OAuth credentials live in ~/.claude/credentials.json and must remain compatible with existing runtime helpers
Constraint: --version must not require config loading or any API/auth client initialization
Rejected: Keep refresh orchestration only in rusty-claude-cli | would preserve split auth policy and lazy-load bugs
Rejected: Change AnthropicClient::from_env to load config | would broaden configless API semantics for non-CLI callers
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep startup-only OAuth refresh separate from AuthSource::from_env() / AnthropicClient::from_env() unless all non-CLI callers are re-evaluated
Tested: cargo fmt --all; cargo build; cargo clippy --workspace --all-targets -- -D warnings; cargo test; cargo run -p rusty-claude-cli -- --version
Not-tested: Live OAuth refresh against a real auth server
2026-04-01 00:24:55 +00:00
Yeachan-Heo
c92403994d Merge remote-tracking branch 'origin/rcc/cli' into dev/rust
# Conflicts:
#	rust/crates/rusty-claude-cli/src/main.rs
2026-04-01 00:20:39 +00:00
Yeachan-Heo
8d4a739c05 Make the REPL resilient enough for real interactive workflows
The custom crossterm editor now supports prompt history, slash-command tab
completion, multiline editing, and Ctrl-C semantics that clear partial input
without always terminating the session. The live REPL loop now distinguishes
buffer cancellation from clean exit, persists session state on meaningful
boundaries, and renders tool activity in a more structured way for terminal
use.

Constraint: Keep the active REPL on the existing crossterm path without adding a line-editor dependency
Rejected: Swap to rustyline or reedline | broader integration risk than this polish pass justifies
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep editor state logic generic in input.rs and leave REPL policy decisions in main.rs
Tested: cargo fmt --manifest-path rust/Cargo.toml --all; cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings; cargo test --manifest-path rust/Cargo.toml
Not-tested: Interactive manual terminal smoke test for arrow keys/tab/Ctrl-C in a live TTY
2026-04-01 00:15:33 +00:00
Yeachan-Heo
e2f061fd08 Enforce tool permissions before execution
The Rust CLI/runtime now models permissions as ordered access levels, derives tool requirements from the shared tool specs, and prompts REPL users before one-off danger-full-access escalations from workspace-write sessions. This also wires explicit --permission-mode parsing and makes /permissions operate on the live session state instead of an implicit env-derived default.

Constraint: Must preserve the existing three user-facing modes read-only, workspace-write, and danger-full-access

Constraint: Must avoid new dependencies and keep enforcement inside the existing runtime/tool plumbing

Rejected: Keep the old Allow/Deny/Prompt policy model | could not represent ordered tool requirements across the CLI surface

Rejected: Continue sourcing live session mode solely from RUSTY_CLAUDE_PERMISSION_MODE | /permissions would not reliably reflect the current session state

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Add required_permission entries for new tools before exposing them to the runtime

Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test -q

Not-tested: Manual interactive REPL approval flow in a live Anthropic session
2026-04-01 00:06:15 +00:00
Yeachan-Heo
6a7cea810e Clarify the expanded CLI surface for local parity
The branch already carries the new local slash commands and flag behavior,
so this follow-up captures how to use them from the Rust README. That keeps
the documented REPL and resume workflows aligned with the verified binary
surface after the implementation and green verification pass.

Constraint: Keep scope narrow and avoid touching ignored .omx planning artifacts
Constraint: Documentation must reflect the active handwritten parser in main.rs
Rejected: Re-open parser refactors in args.rs | outside the requested bounded change
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep README command examples aligned with main.rs help output when CLI flags or slash commands change
Tested: cargo run -p rusty-claude-cli -- --version; cargo run -p rusty-claude-cli -- --help; cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test
Not-tested: Interactive REPL manual slash-command session in a live API-backed conversation
2026-03-31 23:40:57 +00:00
14 changed files with 1584 additions and 308 deletions

View File

@@ -102,6 +102,13 @@ cd rust
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace" cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
``` ```
Restrict enabled tools in an interactive session:
```bash
cd rust
cargo run -p rusty-claude-cli -- --allowedTools read,glob
```
### 2) REPL mode ### 2) REPL mode
Start the interactive shell: Start the interactive shell:
@@ -123,6 +130,10 @@ Inside the REPL, useful commands include:
/memory /memory
/config /config
/init /init
/diff
/version
/export notes.txt
/session list
/exit /exit
``` ```
@@ -169,6 +180,10 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
- `/config [env|hooks|model]` — inspect discovered Claude config - `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files - `/memory` — inspect loaded instruction memory files
- `/init` — create a starter `CLAUDE.md` - `/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
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL - `/exit` — leave the REPL
## Environment variables ## Environment variables

View File

@@ -392,8 +392,52 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
let Some(token_set) = load_saved_oauth_token()? else { let Some(token_set) = load_saved_oauth_token()? else {
return Ok(None); return Ok(None);
}; };
resolve_saved_oauth_token_set(config, token_set).map(Some)
}
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
where
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
{
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
api_key,
bearer_token,
}),
None => Ok(AuthSource::ApiKey(api_key)),
};
}
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
return Ok(AuthSource::BearerToken(bearer_token));
}
let Some(token_set) = load_saved_oauth_token()? else {
return Err(ApiError::MissingApiKey);
};
if !oauth_token_is_expired(&token_set) { if !oauth_token_is_expired(&token_set) {
return Ok(Some(token_set)); return Ok(AuthSource::BearerToken(token_set.access_token));
}
if token_set.refresh_token.is_none() {
return Err(ApiError::ExpiredOAuthToken);
}
let Some(config) = load_oauth_config()? else {
return Err(ApiError::Auth(
"saved OAuth token is expired; runtime OAuth config is missing".to_string(),
));
};
Ok(AuthSource::from(resolve_saved_oauth_token_set(
&config, token_set,
)?))
}
fn resolve_saved_oauth_token_set(
config: &OAuthConfig,
token_set: OAuthTokenSet,
) -> Result<OAuthTokenSet, ApiError> {
if !oauth_token_is_expired(&token_set) {
return Ok(token_set);
} }
let Some(refresh_token) = token_set.refresh_token.clone() else { let Some(refresh_token) = token_set.refresh_token.clone() else {
return Err(ApiError::ExpiredOAuthToken); return Err(ApiError::ExpiredOAuthToken);
@@ -403,18 +447,28 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
client client
.refresh_oauth_token( .refresh_oauth_token(
config, config,
&OAuthRefreshRequest::from_config(config, refresh_token, Some(token_set.scopes)), &OAuthRefreshRequest::from_config(
config,
refresh_token,
Some(token_set.scopes.clone()),
),
) )
.await .await
})?; })?;
save_oauth_credentials(&runtime::OAuthTokenSet { let resolved = OAuthTokenSet {
access_token: refreshed.access_token.clone(), access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token.clone(), refresh_token: refreshed.refresh_token.or(token_set.refresh_token),
expires_at: refreshed.expires_at, expires_at: refreshed.expires_at,
scopes: refreshed.scopes.clone(), scopes: refreshed.scopes,
};
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: resolved.access_token.clone(),
refresh_token: resolved.refresh_token.clone(),
expires_at: resolved.expires_at,
scopes: resolved.scopes.clone(),
}) })
.map_err(ApiError::from)?; .map_err(ApiError::from)?;
Ok(Some(refreshed)) Ok(resolved)
} }
fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError> fn client_runtime_block_on<F, T>(future: F) -> Result<T, ApiError>
@@ -571,8 +625,8 @@ mod tests {
use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig}; use runtime::{clear_oauth_credentials, save_oauth_credentials, OAuthConfig};
use crate::client::{ use crate::client::{
now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token, AnthropicClient, now_unix_timestamp, oauth_token_is_expired, resolve_saved_oauth_token,
AuthSource, OAuthTokenSet, resolve_startup_auth_source, AnthropicClient, AuthSource, OAuthTokenSet,
}; };
use crate::types::{ContentBlockDelta, MessageRequest}; use crate::types::{ContentBlockDelta, MessageRequest};
@@ -760,6 +814,95 @@ mod tests {
std::fs::remove_dir_all(config_home).expect("cleanup temp dir"); std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
} }
#[test]
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "saved-access-token".to_string(),
refresh_token: Some("refresh".to_string()),
expires_at: Some(now_unix_timestamp() + 300),
scopes: vec!["scope:a".to_string()],
})
.expect("save oauth credentials");
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
.expect("startup auth");
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test]
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "expired-access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(1),
scopes: vec!["scope:a".to_string()],
})
.expect("save expired oauth credentials");
let error =
resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
assert!(
matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
);
let stored = runtime::load_oauth_credentials()
.expect("load stored credentials")
.expect("stored token set");
assert_eq!(stored.access_token, "expired-access-token");
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test]
fn resolve_saved_oauth_token_preserves_refresh_token_when_refresh_response_omits_it() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAUDE_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "expired-access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(1),
scopes: vec!["scope:a".to_string()],
})
.expect("save expired oauth credentials");
let token_url = spawn_token_server(
"{\"access_token\":\"refreshed-token\",\"expires_at\":9999999999,\"scopes\":[\"scope:a\"]}",
);
let resolved = resolve_saved_oauth_token(&sample_oauth_config(token_url))
.expect("resolve refreshed token")
.expect("token set present");
assert_eq!(resolved.access_token, "refreshed-token");
assert_eq!(resolved.refresh_token.as_deref(), Some("refresh-token"));
let stored = runtime::load_oauth_credentials()
.expect("load stored credentials")
.expect("stored token set");
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::fs::remove_dir_all(config_home).expect("cleanup temp dir");
}
#[test] #[test]
fn message_request_stream_helper_sets_stream_true() { fn message_request_stream_helper_sets_stream_true() {
let request = MessageRequest { let request = MessageRequest {

View File

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

View File

@@ -14,6 +14,13 @@ pub enum ConfigSource {
Local, Local,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedPermissionMode {
ReadOnly,
WorkspaceWrite,
DangerFullAccess,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigEntry { pub struct ConfigEntry {
pub source: ConfigSource, pub source: ConfigSource,
@@ -31,6 +38,8 @@ pub struct RuntimeConfig {
pub struct RuntimeFeatureConfig { pub struct RuntimeFeatureConfig {
mcp: McpConfigCollection, mcp: McpConfigCollection,
oauth: Option<OAuthConfig>, oauth: Option<OAuthConfig>,
model: Option<String>,
permission_mode: Option<ResolvedPermissionMode>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -165,11 +174,23 @@ impl ConfigLoader {
#[must_use] #[must_use]
pub fn discover(&self) -> Vec<ConfigEntry> { pub fn discover(&self) -> Vec<ConfigEntry> {
let user_legacy_path = self.config_home.parent().map_or_else(
|| PathBuf::from(".claude.json"),
|parent| parent.join(".claude.json"),
);
vec![ vec![
ConfigEntry {
source: ConfigSource::User,
path: user_legacy_path,
},
ConfigEntry { ConfigEntry {
source: ConfigSource::User, source: ConfigSource::User,
path: self.config_home.join("settings.json"), path: self.config_home.join("settings.json"),
}, },
ConfigEntry {
source: ConfigSource::Project,
path: self.cwd.join(".claude.json"),
},
ConfigEntry { ConfigEntry {
source: ConfigSource::Project, source: ConfigSource::Project,
path: self.cwd.join(".claude").join("settings.json"), path: self.cwd.join(".claude").join("settings.json"),
@@ -195,14 +216,15 @@ impl ConfigLoader {
loaded_entries.push(entry); loaded_entries.push(entry);
} }
let merged_value = JsonValue::Object(merged.clone());
let feature_config = RuntimeFeatureConfig { let feature_config = RuntimeFeatureConfig {
mcp: McpConfigCollection { mcp: McpConfigCollection {
servers: mcp_servers, servers: mcp_servers,
}, },
oauth: parse_optional_oauth_config( oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
&JsonValue::Object(merged.clone()), model: parse_optional_model(&merged_value),
"merged settings.oauth", permission_mode: parse_optional_permission_mode(&merged_value)?,
)?,
}; };
Ok(RuntimeConfig { Ok(RuntimeConfig {
@@ -257,6 +279,16 @@ impl RuntimeConfig {
pub fn oauth(&self) -> Option<&OAuthConfig> { pub fn oauth(&self) -> Option<&OAuthConfig> {
self.feature_config.oauth.as_ref() self.feature_config.oauth.as_ref()
} }
#[must_use]
pub fn model(&self) -> Option<&str> {
self.feature_config.model.as_deref()
}
#[must_use]
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
self.feature_config.permission_mode
}
} }
impl RuntimeFeatureConfig { impl RuntimeFeatureConfig {
@@ -269,6 +301,16 @@ impl RuntimeFeatureConfig {
pub fn oauth(&self) -> Option<&OAuthConfig> { pub fn oauth(&self) -> Option<&OAuthConfig> {
self.oauth.as_ref() self.oauth.as_ref()
} }
#[must_use]
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
#[must_use]
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
self.permission_mode
}
} }
impl McpConfigCollection { impl McpConfigCollection {
@@ -307,6 +349,7 @@ impl McpServerConfig {
fn read_optional_json_object( fn read_optional_json_object(
path: &Path, path: &Path,
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> { ) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json");
let contents = match fs::read_to_string(path) { let contents = match fs::read_to_string(path) {
Ok(contents) => contents, Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
@@ -317,14 +360,20 @@ fn read_optional_json_object(
return Ok(Some(BTreeMap::new())); return Ok(Some(BTreeMap::new()));
} }
let parsed = JsonValue::parse(&contents) let parsed = match JsonValue::parse(&contents) {
.map_err(|error| ConfigError::Parse(format!("{}: {error}", path.display())))?; Ok(parsed) => parsed,
let object = parsed.as_object().ok_or_else(|| { Err(error) if is_legacy_config => return Ok(None),
ConfigError::Parse(format!( Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
};
let Some(object) = parsed.as_object() else {
if is_legacy_config {
return Ok(None);
}
return Err(ConfigError::Parse(format!(
"{}: top-level settings value must be a JSON object", "{}: top-level settings value must be a JSON object",
path.display() path.display()
)) )));
})?; };
Ok(Some(object.clone())) Ok(Some(object.clone()))
} }
@@ -355,6 +404,47 @@ fn merge_mcp_servers(
Ok(()) Ok(())
} }
fn parse_optional_model(root: &JsonValue) -> Option<String> {
root.as_object()
.and_then(|object| object.get("model"))
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
}
fn parse_optional_permission_mode(
root: &JsonValue,
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(None);
};
if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
}
let Some(mode) = object
.get("permissions")
.and_then(JsonValue::as_object)
.and_then(|permissions| permissions.get("defaultMode"))
.and_then(JsonValue::as_str)
else {
return Ok(None);
};
parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
}
fn parse_permission_mode_label(
mode: &str,
context: &str,
) -> Result<ResolvedPermissionMode, ConfigError> {
match mode {
"default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
"acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
"dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
other => Err(ConfigError::Parse(format!(
"{context}: unsupported permission mode {other}"
))),
}
}
fn parse_optional_oauth_config( fn parse_optional_oauth_config(
root: &JsonValue, root: &JsonValue,
context: &str, context: &str,
@@ -594,7 +684,8 @@ fn deep_merge_objects(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
}; };
use crate::json::JsonValue; use crate::json::JsonValue;
use std::fs; use std::fs;
@@ -635,14 +726,24 @@ mod tests {
fs::create_dir_all(cwd.join(".claude")).expect("project config dir"); fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir"); fs::create_dir_all(&home).expect("home config dir");
fs::write(
home.parent().expect("home parent").join(".claude.json"),
r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
)
.expect("write user compat config");
fs::write( fs::write(
home.join("settings.json"), home.join("settings.json"),
r#"{"model":"sonnet","env":{"A":"1"},"hooks":{"PreToolUse":["base"]}}"#, r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
) )
.expect("write user settings"); .expect("write user settings");
fs::write(
cwd.join(".claude.json"),
r#"{"model":"project-compat","env":{"B":"2"}}"#,
)
.expect("write project compat config");
fs::write( fs::write(
cwd.join(".claude").join("settings.json"), cwd.join(".claude").join("settings.json"),
r#"{"env":{"B":"2"},"hooks":{"PostToolUse":["project"]}}"#, r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
) )
.expect("write project settings"); .expect("write project settings");
fs::write( fs::write(
@@ -656,25 +757,37 @@ mod tests {
.expect("config should load"); .expect("config should load");
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema"); assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
assert_eq!(loaded.loaded_entries().len(), 3); assert_eq!(loaded.loaded_entries().len(), 5);
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User); assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
assert_eq!( assert_eq!(
loaded.get("model"), loaded.get("model"),
Some(&JsonValue::String("opus".to_string())) Some(&JsonValue::String("opus".to_string()))
); );
assert_eq!(loaded.model(), Some("opus"));
assert_eq!(
loaded.permission_mode(),
Some(ResolvedPermissionMode::WorkspaceWrite)
);
assert_eq!( assert_eq!(
loaded loaded
.get("env") .get("env")
.and_then(JsonValue::as_object) .and_then(JsonValue::as_object)
.expect("env object") .expect("env object")
.len(), .len(),
2 4
); );
assert!(loaded assert!(loaded
.get("hooks") .get("hooks")
.and_then(JsonValue::as_object) .and_then(JsonValue::as_object)
.expect("hooks object") .expect("hooks object")
.contains_key("PreToolUse")); .contains_key("PreToolUse"));
assert!(loaded
.get("hooks")
.and_then(JsonValue::as_object)
.expect("hooks object")
.contains_key("PostToolUse"));
assert!(loaded.mcp().get("home").is_some());
assert!(loaded.mcp().get("project").is_some());
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }

View File

@@ -408,7 +408,7 @@ mod tests {
.sum::<i32>(); .sum::<i32>();
Ok(total.to_string()) Ok(total.to_string())
}); });
let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
let system_prompt = SystemPromptBuilder::new() let system_prompt = SystemPromptBuilder::new()
.with_project_context(ProjectContext { .with_project_context(ProjectContext {
cwd: PathBuf::from("/tmp/project"), cwd: PathBuf::from("/tmp/project"),
@@ -487,7 +487,7 @@ mod tests {
Session::new(), Session::new(),
SingleCallApiClient, SingleCallApiClient,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::Prompt), PermissionPolicy::new(PermissionMode::WorkspaceWrite),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -536,7 +536,7 @@ mod tests {
session, session,
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::Allow), PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()], vec!["system".to_string()],
); );
@@ -563,7 +563,7 @@ mod tests {
Session::new(), Session::new(),
SimpleApi, SimpleApi,
StaticToolExecutor::new(), StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::Allow), PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()], vec!["system".to_string()],
); );
runtime.run_turn("a", None).expect("turn a"); runtime.run_turn("a", None).expect("turn a");

View File

@@ -25,7 +25,8 @@ pub use config::{
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig,
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
}; };
pub use conversation::{ pub use conversation::{
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
@@ -76,3 +77,11 @@ pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, Sessi
pub use usage::{ pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
}; };
#[cfg(test)]
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

View File

@@ -448,7 +448,6 @@ fn decode_hex(byte: u8) -> Result<u8, String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use super::{ use super::{
@@ -470,10 +469,7 @@ mod tests {
} }
fn env_lock() -> std::sync::MutexGuard<'static, ()> { fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); crate::test_env_lock()
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock")
} }
fn temp_config_home() -> std::path::PathBuf { fn temp_config_home() -> std::path::PathBuf {

View File

@@ -1,16 +1,29 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode { pub enum PermissionMode {
Allow, ReadOnly,
Deny, WorkspaceWrite,
Prompt, DangerFullAccess,
}
impl PermissionMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
}
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionRequest { pub struct PermissionRequest {
pub tool_name: String, pub tool_name: String,
pub input: String, pub input: String,
pub current_mode: PermissionMode,
pub required_mode: PermissionMode,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -31,31 +44,41 @@ pub enum PermissionOutcome {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionPolicy { pub struct PermissionPolicy {
default_mode: PermissionMode, active_mode: PermissionMode,
tool_modes: BTreeMap<String, PermissionMode>, tool_requirements: BTreeMap<String, PermissionMode>,
} }
impl PermissionPolicy { impl PermissionPolicy {
#[must_use] #[must_use]
pub fn new(default_mode: PermissionMode) -> Self { pub fn new(active_mode: PermissionMode) -> Self {
Self { Self {
default_mode, active_mode,
tool_modes: BTreeMap::new(), tool_requirements: BTreeMap::new(),
} }
} }
#[must_use] #[must_use]
pub fn with_tool_mode(mut self, tool_name: impl Into<String>, mode: PermissionMode) -> Self { pub fn with_tool_requirement(
self.tool_modes.insert(tool_name.into(), mode); mut self,
tool_name: impl Into<String>,
required_mode: PermissionMode,
) -> Self {
self.tool_requirements
.insert(tool_name.into(), required_mode);
self self
} }
#[must_use] #[must_use]
pub fn mode_for(&self, tool_name: &str) -> PermissionMode { pub fn active_mode(&self) -> PermissionMode {
self.tool_modes self.active_mode
}
#[must_use]
pub fn required_mode_for(&self, tool_name: &str) -> PermissionMode {
self.tool_requirements
.get(tool_name) .get(tool_name)
.copied() .copied()
.unwrap_or(self.default_mode) .unwrap_or(PermissionMode::DangerFullAccess)
} }
#[must_use] #[must_use]
@@ -65,23 +88,43 @@ impl PermissionPolicy {
input: &str, input: &str,
mut prompter: Option<&mut dyn PermissionPrompter>, mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome { ) -> PermissionOutcome {
match self.mode_for(tool_name) { let current_mode = self.active_mode();
PermissionMode::Allow => PermissionOutcome::Allow, let required_mode = self.required_mode_for(tool_name);
PermissionMode::Deny => PermissionOutcome::Deny { if current_mode >= required_mode {
reason: format!("tool '{tool_name}' denied by permission policy"), return PermissionOutcome::Allow;
}, }
PermissionMode::Prompt => match prompter.as_mut() {
Some(prompter) => match prompter.decide(&PermissionRequest { let request = PermissionRequest {
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
input: input.to_string(), input: input.to_string(),
}) { current_mode,
required_mode,
};
if current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess
{
return match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow, PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason }, PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
}, },
None => PermissionOutcome::Deny { None => PermissionOutcome::Deny {
reason: format!("tool '{tool_name}' requires interactive approval"), reason: format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
),
}, },
}, };
}
PermissionOutcome::Deny {
reason: format!(
"tool '{tool_name}' requires {} permission; current mode is {}",
required_mode.as_str(),
current_mode.as_str()
),
} }
} }
} }
@@ -93,25 +136,92 @@ mod tests {
PermissionPrompter, PermissionRequest, PermissionPrompter, PermissionRequest,
}; };
struct AllowPrompter; struct RecordingPrompter {
seen: Vec<PermissionRequest>,
allow: bool,
}
impl PermissionPrompter for AllowPrompter { impl PermissionPrompter for RecordingPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision { fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
assert_eq!(request.tool_name, "bash"); self.seen.push(request.clone());
PermissionPromptDecision::Allow if self.allow {
PermissionPromptDecision::Allow
} else {
PermissionPromptDecision::Deny {
reason: "not now".to_string(),
}
}
} }
} }
#[test] #[test]
fn uses_tool_specific_overrides() { fn allows_tools_when_active_mode_meets_requirement() {
let policy = PermissionPolicy::new(PermissionMode::Deny) let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
.with_tool_mode("bash", PermissionMode::Prompt); .with_tool_requirement("read_file", PermissionMode::ReadOnly)
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
assert_eq!(
policy.authorize("read_file", "{}", None),
PermissionOutcome::Allow
);
assert_eq!(
policy.authorize("write_file", "{}", None),
PermissionOutcome::Allow
);
}
#[test]
fn denies_read_only_escalations_without_prompt() {
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let outcome = policy.authorize("bash", "echo hi", Some(&mut AllowPrompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert!(matches!( assert!(matches!(
policy.authorize("edit", "x", None), policy.authorize("write_file", "{}", None),
PermissionOutcome::Deny { .. } PermissionOutcome::Deny { reason } if reason.contains("requires workspace-write permission")
));
assert!(matches!(
policy.authorize("bash", "{}", None),
PermissionOutcome::Deny { reason } if reason.contains("requires danger-full-access permission")
));
}
#[test]
fn prompts_for_workspace_write_to_danger_full_access_escalation() {
let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize("bash", "echo hi", Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert_eq!(prompter.seen[0].tool_name, "bash");
assert_eq!(
prompter.seen[0].current_mode,
PermissionMode::WorkspaceWrite
);
assert_eq!(
prompter.seen[0].required_mode,
PermissionMode::DangerFullAccess
);
}
#[test]
fn honors_prompt_rejection_reason() {
let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: false,
};
assert!(matches!(
policy.authorize("bash", "echo hi", Some(&mut prompter)),
PermissionOutcome::Deny { reason } if reason == "not now"
)); ));
} }
} }

View File

@@ -201,6 +201,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
dir.join("CLAUDE.md"), dir.join("CLAUDE.md"),
dir.join("CLAUDE.local.md"), dir.join("CLAUDE.local.md"),
dir.join(".claude").join("CLAUDE.md"), dir.join(".claude").join("CLAUDE.md"),
dir.join(".claude").join("instructions.md"),
] { ] {
push_context_file(&mut files, candidate)?; push_context_file(&mut files, candidate)?;
} }
@@ -468,6 +469,10 @@ mod tests {
std::env::temp_dir().join(format!("runtime-prompt-{nanos}")) std::env::temp_dir().join(format!("runtime-prompt-{nanos}"))
} }
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_env_lock()
}
#[test] #[test]
fn discovers_instruction_files_from_ancestor_chain() { fn discovers_instruction_files_from_ancestor_chain() {
let root = temp_dir(); let root = temp_dir();
@@ -477,10 +482,21 @@ mod tests {
fs::write(root.join("CLAUDE.local.md"), "local instructions") fs::write(root.join("CLAUDE.local.md"), "local instructions")
.expect("write local instructions"); .expect("write local instructions");
fs::create_dir_all(root.join("apps")).expect("apps dir"); fs::create_dir_all(root.join("apps")).expect("apps dir");
fs::create_dir_all(root.join("apps").join(".claude")).expect("apps claude dir");
fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions") fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions")
.expect("write apps instructions"); .expect("write apps instructions");
fs::write(
root.join("apps").join(".claude").join("instructions.md"),
"apps dot claude instructions",
)
.expect("write apps dot claude instructions");
fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules") fs::write(nested.join(".claude").join("CLAUDE.md"), "nested rules")
.expect("write nested rules"); .expect("write nested rules");
fs::write(
nested.join(".claude").join("instructions.md"),
"nested instructions",
)
.expect("write nested instructions");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load"); let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
let contents = context let contents = context
@@ -495,7 +511,9 @@ mod tests {
"root instructions", "root instructions",
"local instructions", "local instructions",
"apps instructions", "apps instructions",
"nested rules" "apps dot claude instructions",
"nested rules",
"nested instructions"
] ]
); );
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
@@ -574,7 +592,12 @@ mod tests {
) )
.expect("write settings"); .expect("write settings");
let _guard = env_lock();
let previous = std::env::current_dir().expect("cwd"); let previous = std::env::current_dir().expect("cwd");
let original_home = std::env::var("HOME").ok();
let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok();
std::env::set_var("HOME", &root);
std::env::set_var("CLAUDE_CONFIG_HOME", root.join("missing-home"));
std::env::set_current_dir(&root).expect("change cwd"); std::env::set_current_dir(&root).expect("change cwd");
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8") let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
.expect("system prompt should load") .expect("system prompt should load")
@@ -584,6 +607,16 @@ mod tests {
", ",
); );
std::env::set_current_dir(previous).expect("restore cwd"); std::env::set_current_dir(previous).expect("restore cwd");
if let Some(value) = original_home {
std::env::set_var("HOME", value);
} else {
std::env::remove_var("HOME");
}
if let Some(value) = original_claude_home {
std::env::set_var("CLAUDE_CONFIG_HOME", value);
} else {
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
assert!(prompt.contains("Project rules")); assert!(prompt.contains("Project rules"));
assert!(prompt.contains("permissionMode")); assert!(prompt.contains("permissionMode"));
@@ -631,6 +664,29 @@ mod tests {
assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count()); assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count());
} }
#[test]
fn discovers_dot_claude_instructions_markdown() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claude")).expect("nested claude dir");
fs::write(
nested.join(".claude").join("instructions.md"),
"instruction markdown",
)
.expect("write instructions.md");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
assert!(context
.instruction_files
.iter()
.any(|file| file.path.ends_with(".claude/instructions.md")));
assert!(
render_instruction_files(&context.instruction_files).contains("instruction markdown")
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test] #[test]
fn renders_instruction_file_metadata() { fn renders_instruction_file_metadata() {
let rendered = render_instruction_files(&[ContextFile { let rendered = render_instruction_files(&[ContextFile {

View File

@@ -2,7 +2,7 @@ use std::io::{self, Write};
use std::path::PathBuf; use std::path::PathBuf;
use crate::args::{OutputFormat, PermissionMode}; use crate::args::{OutputFormat, PermissionMode};
use crate::input::LineEditor; use crate::input::{LineEditor, ReadOutcome};
use crate::render::{Spinner, TerminalRenderer}; use crate::render::{Spinner, TerminalRenderer};
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary}; use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
@@ -111,16 +111,21 @@ impl CliApp {
} }
pub fn run_repl(&mut self) -> io::Result<()> { pub fn run_repl(&mut self) -> io::Result<()> {
let editor = LineEditor::new(" "); let mut editor = LineEditor::new(" ", Vec::new());
println!("Rusty Claude CLI interactive mode"); println!("Rusty Claude CLI interactive mode");
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline."); println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
while let Some(input) = editor.read_line()? { loop {
if input.trim().is_empty() { match editor.read_line()? {
continue; ReadOutcome::Submit(input) => {
if input.trim().is_empty() {
continue;
}
self.handle_submission(&input, &mut io::stdout())?;
}
ReadOutcome::Cancel => continue,
ReadOutcome::Exit => break,
} }
self.handle_submission(&input, &mut io::stdout())?;
} }
Ok(()) Ok(())

View File

@@ -1,9 +1,8 @@
use std::io::{self, IsTerminal, Write}; use std::io::{self, IsTerminal, Write};
use crossterm::cursor::MoveToColumn; use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::queue; use crossterm::queue;
use crossterm::style::Print;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -85,21 +84,124 @@ impl InputBuffer {
self.buffer.clear(); self.buffer.clear();
self.cursor = 0; 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 {
Submit(String),
Cancel,
Exit,
} }
pub struct LineEditor { pub struct LineEditor {
prompt: String, prompt: String,
continuation_prompt: String,
history: Vec<String>,
history_index: Option<usize>,
draft: Option<String>,
completions: Vec<String>,
} }
impl LineEditor { impl LineEditor {
#[must_use] #[must_use]
pub fn new(prompt: impl Into<String>) -> Self { pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
Self { Self {
prompt: prompt.into(), prompt: prompt.into(),
continuation_prompt: String::from("> "),
history: Vec::new(),
history_index: None,
draft: None,
completions,
} }
} }
pub fn read_line(&self) -> io::Result<Option<String>> { pub fn push_history(&mut self, entry: impl Into<String>) {
let entry = entry.into();
if entry.trim().is_empty() {
return;
}
self.history.push(entry);
self.history_index = None;
self.draft = None;
}
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() { if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return self.read_line_fallback(); return self.read_line_fallback();
} }
@@ -107,29 +209,43 @@ impl LineEditor {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
let mut input = InputBuffer::new(); let mut input = InputBuffer::new();
self.redraw(&mut stdout, &input)?; let mut rendered_lines = 1usize;
self.redraw(&mut stdout, &input, rendered_lines)?;
loop { loop {
let event = event::read()?; let event = event::read()?;
if let Event::Key(key) = event { if let Event::Key(key) = event {
match Self::handle_key(key, &mut input) { match self.handle_key(key, &mut input) {
EditorAction::Continue => self.redraw(&mut stdout, &input)?, EditorAction::Continue => {
rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
}
EditorAction::Submit => { EditorAction::Submit => {
disable_raw_mode()?; disable_raw_mode()?;
writeln!(stdout)?; writeln!(stdout)?;
return Ok(Some(input.as_str().to_owned())); self.history_index = None;
self.draft = None;
return Ok(ReadOutcome::Submit(input.as_str().to_owned()));
} }
EditorAction::Cancel => { EditorAction::Cancel => {
disable_raw_mode()?; disable_raw_mode()?;
writeln!(stdout)?; writeln!(stdout)?;
return Ok(None); 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<Option<String>> { fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
write!(stdout, "{}", self.prompt)?; write!(stdout, "{}", self.prompt)?;
stdout.flush()?; stdout.flush()?;
@@ -137,22 +253,32 @@ impl LineEditor {
let mut buffer = String::new(); let mut buffer = String::new();
let bytes_read = io::stdin().read_line(&mut buffer)?; let bytes_read = io::stdin().read_line(&mut buffer)?;
if bytes_read == 0 { if bytes_read == 0 {
return Ok(None); return Ok(ReadOutcome::Exit);
} }
while matches!(buffer.chars().last(), Some('\n' | '\r')) { while matches!(buffer.chars().last(), Some('\n' | '\r')) {
buffer.pop(); buffer.pop();
} }
Ok(Some(buffer)) Ok(ReadOutcome::Submit(buffer))
} }
fn handle_key(key: KeyEvent, input: &mut InputBuffer) -> EditorAction { #[allow(clippy::too_many_lines)]
fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
match key { match key {
KeyEvent { KeyEvent {
code: KeyCode::Char('c'), code: KeyCode::Char('c'),
modifiers, modifiers,
.. ..
} if modifiers.contains(KeyModifiers::CONTROL) => EditorAction::Cancel, } 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 { KeyEvent {
code: KeyCode::Char('j'), code: KeyCode::Char('j'),
modifiers, modifiers,
@@ -194,6 +320,25 @@ impl LineEditor {
input.move_right(); input.move_right();
EditorAction::Continue 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 { KeyEvent {
code: KeyCode::Home, code: KeyCode::Home,
.. ..
@@ -211,6 +356,8 @@ impl LineEditor {
code: KeyCode::Esc, .. code: KeyCode::Esc, ..
} => { } => {
input.clear(); input.clear();
self.history_index = None;
self.draft = None;
EditorAction::Cancel EditorAction::Cancel
} }
KeyEvent { KeyEvent {
@@ -219,22 +366,74 @@ impl LineEditor {
.. ..
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => { } if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
input.insert(ch); input.insert(ch);
self.history_index = None;
self.draft = None;
EditorAction::Continue EditorAction::Continue
} }
_ => EditorAction::Continue, _ => EditorAction::Continue,
} }
} }
fn redraw(&self, out: &mut impl Write, input: &InputBuffer) -> io::Result<()> { fn navigate_history_up(&mut self, input: &mut InputBuffer) {
let display = input.as_str().replace('\n', "\\n\n> "); 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!( queue!(
out, out,
MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
MoveToColumn(0), MoveToColumn(0),
Clear(ClearType::CurrentLine),
Print(&self.prompt),
Print(display),
)?; )?;
out.flush() if rendered.cursor_row > 0 {
queue!(out, MoveDown(rendered.cursor_row))?;
}
queue!(out, MoveToColumn(rendered.cursor_col))?;
out.flush()?;
Ok(rendered.line_count())
} }
} }
@@ -243,11 +442,76 @@ enum EditorAction {
Continue, Continue,
Submit, Submit,
Cancel, 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());
}
RenderedBuffer {
lines,
cursor_row,
cursor_col,
}
}
#[must_use]
fn longest_common_prefix(values: &[&str]) -> String {
let Some(first) = values.first() else {
return String::new();
};
let mut prefix = (*first).to_string();
for value in values.iter().skip(1) {
while !value.starts_with(&prefix) {
prefix.pop();
if prefix.is_empty() {
break;
}
}
}
prefix
}
#[must_use]
fn saturating_u16(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::InputBuffer; use super::{render_buffer, InputBuffer, LineEditor};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test] #[test]
fn supports_basic_line_editing() { fn supports_basic_line_editing() {
@@ -266,4 +530,119 @@ mod tests {
assert_eq!(input.as_str(), "hix"); assert_eq!(input.as_str(), "hix");
assert_eq!(input.cursor(), 2); 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(),
]));
assert_eq!(input.as_str(), "/hel");
assert!(input.complete_slash_command(&["/help".to_string(), "/status".to_string()]));
assert_eq!(input.as_str(), "/help");
}
#[test]
fn ignores_completion_when_prefix_is_not_a_slash_command() {
let mut input = InputBuffer::new();
for ch in "hello".chars() {
input.insert(ch);
}
assert!(!input.complete_slash_command(&["/help".to_string()]));
assert_eq!(input.as_str(), "hello");
}
#[test]
fn history_navigation_restores_current_draft() {
let mut editor = LineEditor::new(" ", vec![]);
editor.push_history("/help");
editor.push_history("status report");
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

@@ -11,12 +11,14 @@ use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use api::{ use api::{
resolve_saved_oauth_token, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, MessageResponse, OutputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
}; };
use commands::{render_slash_command_help, resume_supported_slash_commands, SlashCommand}; use commands::{
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use render::{Spinner, TerminalRenderer}; use render::{Spinner, TerminalRenderer};
use runtime::{ use runtime::{
@@ -28,7 +30,7 @@ use runtime::{
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
}; };
use serde_json::json; use serde_json::json;
use tools::{execute_tool, mvp_tool_specs}; use tools::{execute_tool, mvp_tool_specs, ToolSpec};
const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514"; const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS: u32 = 32; const DEFAULT_MAX_TOKENS: u32 = 32;
@@ -67,14 +69,16 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
model, model,
output_format, output_format,
allowed_tools, allowed_tools,
} => LiveCli::new(model, false, allowed_tools)? permission_mode,
} => LiveCli::new(model, false, allowed_tools, permission_mode)?
.run_turn_with_output(&prompt, output_format)?, .run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?, CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?, CliAction::Logout => run_logout()?,
CliAction::Repl { CliAction::Repl {
model, model,
allowed_tools, allowed_tools,
} => run_repl(model, allowed_tools)?, permission_mode,
} => run_repl(model, allowed_tools, permission_mode)?,
CliAction::Help => print_help(), CliAction::Help => print_help(),
} }
Ok(()) Ok(())
@@ -98,12 +102,14 @@ enum CliAction {
model: String, model: String,
output_format: CliOutputFormat, output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
}, },
Login, Login,
Logout, Logout,
Repl { Repl {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
}, },
// prompt-mode formatting is only supported for non-interactive runs // prompt-mode formatting is only supported for non-interactive runs
Help, Help,
@@ -127,9 +133,11 @@ impl CliOutputFormat {
} }
} }
#[allow(clippy::too_many_lines)]
fn parse_args(args: &[String]) -> Result<CliAction, String> { fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut model = DEFAULT_MODEL.to_string(); let mut model = DEFAULT_MODEL.to_string();
let mut output_format = CliOutputFormat::Text; let mut output_format = CliOutputFormat::Text;
let mut permission_mode = default_permission_mode();
let mut wants_version = false; let mut wants_version = false;
let mut allowed_tool_values = Vec::new(); let mut allowed_tool_values = Vec::new();
let mut rest = Vec::new(); let mut rest = Vec::new();
@@ -159,10 +167,21 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format = CliOutputFormat::parse(value)?; output_format = CliOutputFormat::parse(value)?;
index += 2; index += 2;
} }
"--permission-mode" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --permission-mode".to_string())?;
permission_mode = parse_permission_mode_arg(value)?;
index += 2;
}
flag if flag.starts_with("--output-format=") => { flag if flag.starts_with("--output-format=") => {
output_format = CliOutputFormat::parse(&flag[16..])?; output_format = CliOutputFormat::parse(&flag[16..])?;
index += 1; index += 1;
} }
flag if flag.starts_with("--permission-mode=") => {
permission_mode = parse_permission_mode_arg(&flag[18..])?;
index += 1;
}
"--allowedTools" | "--allowed-tools" => { "--allowedTools" | "--allowed-tools" => {
let value = args let value = args
.get(index + 1) .get(index + 1)
@@ -195,6 +214,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
return Ok(CliAction::Repl { return Ok(CliAction::Repl {
model, model,
allowed_tools, allowed_tools,
permission_mode,
}); });
} }
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
@@ -220,6 +240,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model, model,
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode,
}) })
} }
other if !other.starts_with('/') => Ok(CliAction::Prompt { other if !other.starts_with('/') => Ok(CliAction::Prompt {
@@ -227,6 +248,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model, model,
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode,
}), }),
other => Err(format!("unknown subcommand: {other}")), other => Err(format!("unknown subcommand: {other}")),
} }
@@ -280,6 +302,33 @@ fn normalize_tool_name(value: &str) -> String {
value.trim().replace('-', "_").to_ascii_lowercase() value.trim().replace('-', "_").to_ascii_lowercase()
} }
fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
normalize_permission_mode(value)
.ok_or_else(|| {
format!(
"unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
)
})
.map(permission_mode_from_label)
}
fn permission_mode_from_label(mode: &str) -> PermissionMode {
match mode {
"read-only" => PermissionMode::ReadOnly,
"workspace-write" => PermissionMode::WorkspaceWrite,
"danger-full-access" => PermissionMode::DangerFullAccess,
other => panic!("unsupported permission mode label: {other}"),
}
}
fn default_permission_mode() -> PermissionMode {
env::var("RUSTY_CLAUDE_PERMISSION_MODE")
.ok()
.as_deref()
.and_then(normalize_permission_mode)
.map_or(PermissionMode::WorkspaceWrite, permission_mode_from_label)
}
fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> { fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> {
mvp_tool_specs() mvp_tool_specs()
.into_iter() .into_iter()
@@ -786,7 +835,7 @@ fn run_resume_command(
cumulative: usage, cumulative: usage,
estimated_tokens: 0, estimated_tokens: 0,
}, },
permission_mode_label(), default_permission_mode().as_str(),
&status_context(Some(session_path))?, &status_context(Some(session_path))?,
)), )),
}) })
@@ -841,24 +890,38 @@ fn run_resume_command(
fn run_repl( fn run_repl(
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true, allowed_tools)?; let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
let editor = input::LineEditor::new(" "); let mut editor = input::LineEditor::new(" ", slash_command_completion_candidates());
println!("{}", cli.startup_banner()); println!("{}", cli.startup_banner());
while let Some(input) = editor.read_line()? { loop {
let trimmed = input.trim(); match editor.read_line()? {
if trimmed.is_empty() { input::ReadOutcome::Submit(input) => {
continue; let trimmed = input.trim().to_string();
if trimmed.is_empty() {
continue;
}
if matches!(trimmed.as_str(), "/exit" | "/quit") {
cli.persist_session()?;
break;
}
if let Some(command) = SlashCommand::parse(&trimmed) {
if cli.handle_repl_command(command)? {
cli.persist_session()?;
}
continue;
}
editor.push_history(input);
cli.run_turn(&trimmed)?;
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
cli.persist_session()?;
break;
}
} }
if matches!(trimmed, "/exit" | "/quit") {
break;
}
if let Some(command) = SlashCommand::parse(trimmed) {
cli.handle_repl_command(command)?;
continue;
}
cli.run_turn(trimmed)?;
} }
Ok(()) Ok(())
@@ -881,6 +944,7 @@ struct ManagedSessionSummary {
struct LiveCli { struct LiveCli {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
system_prompt: Vec<String>, system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session: SessionHandle, session: SessionHandle,
@@ -891,6 +955,7 @@ impl LiveCli {
model: String, model: String,
enable_tools: bool, enable_tools: bool,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?; let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?; let session = create_managed_session_handle()?;
@@ -900,10 +965,12 @@ impl LiveCli {
system_prompt.clone(), system_prompt.clone(),
enable_tools, enable_tools,
allowed_tools.clone(), allowed_tools.clone(),
permission_mode,
)?; )?;
let cli = Self { let cli = Self {
model, model,
allowed_tools, allowed_tools,
permission_mode,
system_prompt, system_prompt,
runtime, runtime,
session, session,
@@ -914,8 +981,9 @@ impl LiveCli {
fn startup_banner(&self) -> String { fn startup_banner(&self) -> String {
format!( format!(
"Rusty Claude CLI\n Model {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a 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.model,
self.permission_mode.as_str(),
env::current_dir().map_or_else( env::current_dir().map_or_else(
|_| "<unknown>".to_string(), |_| "<unknown>".to_string(),
|path| path.display().to_string(), |path| path.display().to_string(),
@@ -932,7 +1000,8 @@ impl LiveCli {
TerminalRenderer::new().color_theme(), TerminalRenderer::new().color_theme(),
&mut stdout, &mut stdout,
)?; )?;
let result = self.runtime.run_turn(input, None); let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
match result { match result {
Ok(_) => { Ok(_) => {
spinner.finish( spinner.finish(
@@ -1012,28 +1081,60 @@ impl LiveCli {
fn handle_repl_command( fn handle_repl_command(
&mut self, &mut self,
command: SlashCommand, command: SlashCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
match command { Ok(match command {
SlashCommand::Help => println!("{}", render_repl_help()), SlashCommand::Help => {
SlashCommand::Status => self.print_status(), println!("{}", render_repl_help());
SlashCommand::Compact => self.compact()?, false
}
SlashCommand::Status => {
self.print_status();
false
}
SlashCommand::Compact => {
self.compact()?;
false
}
SlashCommand::Model { model } => self.set_model(model)?, SlashCommand::Model { model } => self.set_model(model)?,
SlashCommand::Permissions { mode } => self.set_permissions(mode)?, SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
SlashCommand::Clear { confirm } => self.clear_session(confirm)?, SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
SlashCommand::Cost => self.print_cost(), SlashCommand::Cost => {
SlashCommand::Resume { session_path } => self.resume_session(session_path)?, self.print_cost();
SlashCommand::Config { section } => Self::print_config(section.as_deref())?, false
SlashCommand::Memory => Self::print_memory()?,
SlashCommand::Init => Self::run_init()?,
SlashCommand::Diff => Self::print_diff()?,
SlashCommand::Version => Self::print_version(),
SlashCommand::Export { path } => self.export_session(path.as_deref())?,
SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())?;
} }
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
} SlashCommand::Config { section } => {
Ok(()) Self::print_config(section.as_deref())?;
false
}
SlashCommand::Memory => {
Self::print_memory()?;
false
}
SlashCommand::Init => {
Self::run_init()?;
false
}
SlashCommand::Diff => {
Self::print_diff()?;
false
}
SlashCommand::Version => {
Self::print_version();
false
}
SlashCommand::Export { path } => {
self.export_session(path.as_deref())?;
false
}
SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())?
}
SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}");
false
}
})
} }
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> { fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
@@ -1055,13 +1156,13 @@ impl LiveCli {
cumulative, cumulative,
estimated_tokens: self.runtime.estimated_tokens(), estimated_tokens: self.runtime.estimated_tokens(),
}, },
permission_mode_label(), self.permission_mode.as_str(),
&status_context(Some(&self.session.path)).expect("status context should load"), &status_context(Some(&self.session.path)).expect("status context should load"),
) )
); );
} }
fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> { fn set_model(&mut self, model: Option<String>) -> Result<bool, Box<dyn std::error::Error>> {
let Some(model) = model else { let Some(model) = model else {
println!( println!(
"{}", "{}",
@@ -1071,7 +1172,7 @@ impl LiveCli {
self.runtime.usage().turns(), self.runtime.usage().turns(),
) )
); );
return Ok(()); return Ok(false);
}; };
if model == self.model { if model == self.model {
@@ -1083,7 +1184,7 @@ impl LiveCli {
self.runtime.usage().turns(), self.runtime.usage().turns(),
) )
); );
return Ok(()); return Ok(false);
} }
let previous = self.model.clone(); let previous = self.model.clone();
@@ -1095,20 +1196,26 @@ impl LiveCli {
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
self.permission_mode,
)?; )?;
self.model.clone_from(&model); self.model.clone_from(&model);
self.persist_session()?;
println!( println!(
"{}", "{}",
format_model_switch_report(&previous, &model, message_count) format_model_switch_report(&previous, &model, message_count)
); );
Ok(()) Ok(true)
} }
fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> { fn set_permissions(
&mut self,
mode: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(mode) = mode else { let Some(mode) = mode else {
println!("{}", format_permissions_report(permission_mode_label())); println!(
return Ok(()); "{}",
format_permissions_report(self.permission_mode.as_str())
);
return Ok(false);
}; };
let normalized = normalize_permission_mode(&mode).ok_or_else(|| { let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
@@ -1117,54 +1224,53 @@ impl LiveCli {
) )
})?; })?;
if normalized == permission_mode_label() { if normalized == self.permission_mode.as_str() {
println!("{}", format_permissions_report(normalized)); println!("{}", format_permissions_report(normalized));
return Ok(()); return Ok(false);
} }
let previous = permission_mode_label().to_string(); let previous = self.permission_mode.as_str().to_string();
let session = self.runtime.session().clone(); let session = self.runtime.session().clone();
self.runtime = build_runtime_with_permission_mode( self.permission_mode = permission_mode_from_label(normalized);
self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
normalized, self.permission_mode,
)?; )?;
self.persist_session()?;
println!( println!(
"{}", "{}",
format_permissions_switch_report(&previous, normalized) format_permissions_switch_report(&previous, normalized)
); );
Ok(()) Ok(true)
} }
fn clear_session(&mut self, confirm: bool) -> Result<(), Box<dyn std::error::Error>> { fn clear_session(&mut self, confirm: bool) -> Result<bool, Box<dyn std::error::Error>> {
if !confirm { if !confirm {
println!( println!(
"clear: confirmation required; run /clear --confirm to start a fresh session." "clear: confirmation required; run /clear --confirm to start a fresh session."
); );
return Ok(()); return Ok(false);
} }
self.session = create_managed_session_handle()?; self.session = create_managed_session_handle()?;
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime(
Session::new(), Session::new(),
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
permission_mode_label(), self.permission_mode,
)?; )?;
self.persist_session()?;
println!( println!(
"Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}",
self.model, self.model,
permission_mode_label(), self.permission_mode.as_str(),
self.session.id, self.session.id,
); );
Ok(()) Ok(true)
} }
fn print_cost(&self) { fn print_cost(&self) {
@@ -1175,25 +1281,24 @@ impl LiveCli {
fn resume_session( fn resume_session(
&mut self, &mut self,
session_path: Option<String>, session_path: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else { let Some(session_ref) = session_path else {
println!("Usage: /resume <session-path>"); println!("Usage: /resume <session-path>");
return Ok(()); return Ok(false);
}; };
let handle = resolve_session_reference(&session_ref)?; let handle = resolve_session_reference(&session_ref)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
permission_mode_label(), self.permission_mode,
)?; )?;
self.session = handle; self.session = handle;
self.persist_session()?;
println!( println!(
"{}", "{}",
format_resume_report( format_resume_report(
@@ -1202,7 +1307,7 @@ impl LiveCli {
self.runtime.usage().turns(), self.runtime.usage().turns(),
) )
); );
Ok(()) Ok(true)
} }
fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
@@ -1247,41 +1352,40 @@ impl LiveCli {
&mut self, &mut self,
action: Option<&str>, action: Option<&str>,
target: Option<&str>, target: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
match action { match action {
None | Some("list") => { None | Some("list") => {
println!("{}", render_session_list(&self.session.id)?); println!("{}", render_session_list(&self.session.id)?);
Ok(()) Ok(false)
} }
Some("switch") => { Some("switch") => {
let Some(target) = target else { let Some(target) = target else {
println!("Usage: /session switch <session-id>"); println!("Usage: /session switch <session-id>");
return Ok(()); return Ok(false);
}; };
let handle = resolve_session_reference(target)?; let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
permission_mode_label(), self.permission_mode,
)?; )?;
self.session = handle; self.session = handle;
self.persist_session()?;
println!( println!(
"Session switched\n Active session {}\n File {}\n Messages {}", "Session switched\n Active session {}\n File {}\n Messages {}",
self.session.id, self.session.id,
self.session.path.display(), self.session.path.display(),
message_count, message_count,
); );
Ok(()) Ok(true)
} }
Some(other) => { Some(other) => {
println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>."); println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
Ok(()) Ok(false)
} }
} }
} }
@@ -1291,13 +1395,13 @@ impl LiveCli {
let removed = result.removed_message_count; let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len(); let kept = result.compacted_session.messages.len();
let skipped = removed == 0; let skipped = removed == 0;
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime(
result.compacted_session, result.compacted_session,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
self.allowed_tools.clone(), self.allowed_tools.clone(),
permission_mode_label(), self.permission_mode,
)?; )?;
self.persist_session()?; self.persist_session()?;
println!("{}", format_compact_report(removed, kept, skipped)); println!("{}", format_compact_report(removed, kept, skipped));
@@ -1410,6 +1514,10 @@ fn render_repl_help() -> String {
"REPL".to_string(), "REPL".to_string(),
" /exit Quit the REPL".to_string(), " /exit Quit the REPL".to_string(),
" /quit Quit the REPL".to_string(), " /quit Quit the REPL".to_string(),
" Up/Down Navigate prompt history".to_string(),
" Tab Complete slash commands".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
String::new(), String::new(),
render_slash_command_help(), render_slash_command_help(),
] ]
@@ -1686,14 +1794,6 @@ fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
} }
} }
fn permission_mode_label() -> &'static str {
match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
Ok(value) if value == "read-only" => "read-only",
Ok(value) if value == "danger-full-access" => "danger-full-access",
_ => "workspace-write",
}
}
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> { fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git") let output = std::process::Command::new("git")
.args(["diff", "--", ":(exclude).omx"]) .args(["diff", "--", ":(exclude).omx"])
@@ -1823,25 +1923,7 @@ fn build_runtime(
system_prompt: Vec<String>, system_prompt: Vec<String>,
enable_tools: bool, enable_tools: bool,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> permission_mode: PermissionMode,
{
build_runtime_with_permission_mode(
session,
model,
system_prompt,
enable_tools,
allowed_tools,
permission_mode_label(),
)
}
fn build_runtime_with_permission_mode(
session: Session,
model: String,
system_prompt: Vec<String>,
enable_tools: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: &str,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{ {
Ok(ConversationRuntime::new( Ok(ConversationRuntime::new(
@@ -1853,6 +1935,52 @@ fn build_runtime_with_permission_mode(
)) ))
} }
struct CliPermissionPrompter {
current_mode: PermissionMode,
}
impl CliPermissionPrompter {
fn new(current_mode: PermissionMode) -> Self {
Self { current_mode }
}
}
impl runtime::PermissionPrompter for CliPermissionPrompter {
fn decide(
&mut self,
request: &runtime::PermissionRequest,
) -> runtime::PermissionPromptDecision {
println!();
println!("Permission approval required");
println!(" Tool {}", request.tool_name);
println!(" Current mode {}", self.current_mode.as_str());
println!(" Required mode {}", request.required_mode.as_str());
println!(" Input {}", request.input);
print!("Approve this tool call? [y/N]: ");
let _ = io::stdout().flush();
let mut response = String::new();
match io::stdin().read_line(&mut response) {
Ok(_) => {
let normalized = response.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "y" | "yes") {
runtime::PermissionPromptDecision::Allow
} else {
runtime::PermissionPromptDecision::Deny {
reason: format!(
"tool '{}' denied by user approval prompt",
request.tool_name
),
}
}
}
Err(error) => runtime::PermissionPromptDecision::Deny {
reason: format!("permission approval failed: {error}"),
},
}
}
}
struct AnthropicRuntimeClient { struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime, runtime: tokio::runtime::Runtime,
client: AnthropicClient, client: AnthropicClient,
@@ -1878,20 +2006,13 @@ impl AnthropicRuntimeClient {
} }
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> { fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
match AuthSource::from_env() { Ok(resolve_startup_auth_source(|| {
Ok(auth) => Ok(auth), let cwd = env::current_dir().map_err(api::ApiError::from)?;
Err(api::ApiError::MissingApiKey) => { let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
let cwd = env::current_dir()?; api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
let config = ConfigLoader::default_for(&cwd).load()?; })?;
if let Some(oauth) = config.oauth() { Ok(config.oauth().cloned())
if let Some(token_set) = resolve_saved_oauth_token(oauth)? { })?)
return Ok(AuthSource::from(token_set));
}
}
Ok(AuthSource::from_env_or_saved()?)
}
Err(error) => Err(Box::new(error)),
}
} }
impl ApiClient for AnthropicRuntimeClient { impl ApiClient for AnthropicRuntimeClient {
@@ -2010,6 +2131,63 @@ impl ApiClient for AnthropicRuntimeClient {
} }
} }
fn slash_command_completion_candidates() -> Vec<String> {
slash_command_specs()
.iter()
.map(|spec| format!("/{}", spec.name))
.collect()
}
fn format_tool_call_start(name: &str, input: &str) -> String {
format!(
"Tool call
Name {name}
Input {}",
summarize_tool_payload(input)
)
}
fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
let status = if is_error { "error" } else { "ok" };
format!(
"### Tool `{name}`
- Status: {status}
- Output:
```json
{}
```
",
prettify_tool_payload(output)
)
}
fn summarize_tool_payload(payload: &str) -> String {
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => value.to_string(),
Err(_) => payload.trim().to_string(),
};
truncate_for_summary(&compact, 96)
}
fn prettify_tool_payload(payload: &str) -> String {
match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| payload.to_string()),
Err(_) => payload.to_string(),
}
}
fn truncate_for_summary(value: &str, limit: usize) -> String {
let mut chars = value.chars();
let truncated = chars.by_ref().take(limit).collect::<String>();
if chars.next().is_some() {
format!("{truncated}")
} else {
truncated
}
}
fn push_output_block( fn push_output_block(
block: OutputContentBlock, block: OutputContentBlock,
out: &mut impl Write, out: &mut impl Write,
@@ -2026,6 +2204,14 @@ fn push_output_block(
} }
} }
OutputContentBlock::ToolUse { id, name, input } => { OutputContentBlock::ToolUse { id, name, input } => {
writeln!(
out,
"
{}",
format_tool_call_start(&name, &input.to_string())
)
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
*pending_tool = Some((id, name, input.to_string())); *pending_tool = Some((id, name, input.to_string()));
} }
} }
@@ -2085,26 +2271,33 @@ impl ToolExecutor for CliToolExecutor {
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
match execute_tool(tool_name, &value) { match execute_tool(tool_name, &value) {
Ok(output) => { Ok(output) => {
let markdown = format!("### Tool `{tool_name}`\n\n```json\n{output}\n```\n"); let markdown = format_tool_result(tool_name, &output, false);
self.renderer self.renderer
.stream_markdown(&markdown, &mut io::stdout()) .stream_markdown(&markdown, &mut io::stdout())
.map_err(|error| ToolError::new(error.to_string()))?; .map_err(|error| ToolError::new(error.to_string()))?;
Ok(output) Ok(output)
} }
Err(error) => Err(ToolError::new(error)), Err(error) => {
let markdown = format_tool_result(tool_name, &error, true);
self.renderer
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
Err(ToolError::new(error))
}
} }
} }
} }
fn permission_policy(mode: &str) -> PermissionPolicy { fn permission_policy(mode: PermissionMode) -> PermissionPolicy {
if normalize_permission_mode(mode) == Some("read-only") { tool_permission_specs()
PermissionPolicy::new(PermissionMode::Deny) .into_iter()
.with_tool_mode("read_file", PermissionMode::Allow) .fold(PermissionPolicy::new(mode), |policy, spec| {
.with_tool_mode("glob_search", PermissionMode::Allow) policy.with_tool_requirement(spec.name, spec.required_permission)
.with_tool_mode("grep_search", PermissionMode::Allow) })
} else { }
PermissionPolicy::new(PermissionMode::Allow)
} fn tool_permission_specs() -> Vec<ToolSpec> {
mvp_tool_specs()
} }
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> { fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
@@ -2169,6 +2362,7 @@ fn print_help() {
println!("Flags:"); println!("Flags:");
println!(" --model MODEL Override the active model"); println!(" --model MODEL Override the active model");
println!(" --output-format FORMAT Non-interactive output format: text or json"); 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!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)");
println!(" --version, -V Print version and build information locally"); println!(" --version, -V Print version and build information locally");
println!(); println!();
@@ -2198,12 +2392,12 @@ mod tests {
filter_tool_specs, format_compact_report, format_cost_report, format_init_report, filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
format_model_report, format_model_switch_report, format_permissions_report, format_model_report, format_model_switch_report, format_permissions_report,
format_permissions_switch_report, format_resume_report, format_status_report, format_permissions_switch_report, format_resume_report, format_status_report,
normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
render_init_claude_md, render_memory_report, render_repl_help, parse_git_status_metadata, render_config_report, render_init_claude_md,
resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
StatusUsage, DEFAULT_MODEL, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use runtime::{ContentBlock, ConversationMessage, MessageRole}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[test] #[test]
@@ -2213,6 +2407,7 @@ mod tests {
CliAction::Repl { CliAction::Repl {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
} }
); );
} }
@@ -2231,6 +2426,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
} }
); );
} }
@@ -2251,6 +2447,7 @@ mod tests {
model: "claude-opus".to_string(), model: "claude-opus".to_string(),
output_format: CliOutputFormat::Json, output_format: CliOutputFormat::Json,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
} }
); );
} }
@@ -2267,6 +2464,19 @@ mod tests {
); );
} }
#[test]
fn parses_permission_mode_flag() {
let args = vec!["--permission-mode=read-only".to_string()];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::ReadOnly,
}
);
}
#[test] #[test]
fn parses_allowed_tools_flags_with_aliases_and_lists() { fn parses_allowed_tools_flags_with_aliases_and_lists() {
let args = vec![ let args = vec![
@@ -2284,6 +2494,7 @@ mod tests {
.map(str::to_string) .map(str::to_string)
.collect() .collect()
), ),
permission_mode: PermissionMode::WorkspaceWrite,
} }
); );
} }
@@ -2586,7 +2797,7 @@ mod tests {
fn status_context_reads_real_workspace_metadata() { fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load"); let context = status_context(None).expect("status context should load");
assert!(context.cwd.is_absolute()); assert!(context.cwd.is_absolute());
assert_eq!(context.discovered_config_files, 3); assert!(context.discovered_config_files >= 3);
assert!(context.loaded_config_files <= context.discovered_config_files); assert!(context.loaded_config_files <= context.discovered_config_files);
} }
@@ -2675,4 +2886,22 @@ mod tests {
assert_eq!(converted[1].role, "assistant"); assert_eq!(converted[1].role, "assistant");
assert_eq!(converted[2].role, "user"); assert_eq!(converted[2].role, "user");
} }
#[test]
fn repl_help_mentions_history_completion_and_multiline() {
let help = render_repl_help();
assert!(help.contains("Up/Down"));
assert!(help.contains("Tab"));
assert!(help.contains("Shift+Enter/Ctrl+J"));
}
#[test]
fn tool_rendering_helpers_compact_output() {
let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#);
assert!(start.contains("Tool call"));
assert!(start.contains("src/main.rs"));
let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false);
assert!(done.contains("Tool `read_file`"));
assert!(done.contains("contents"));
}
} }

View File

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

View File

@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use runtime::{ use runtime::{
edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput, edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
GrepSearchInput, GrepSearchInput, PermissionMode,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -45,6 +45,7 @@ pub struct ToolSpec {
pub name: &'static str, pub name: &'static str,
pub description: &'static str, pub description: &'static str,
pub input_schema: Value, pub input_schema: Value,
pub required_permission: PermissionMode,
} }
#[must_use] #[must_use]
@@ -66,6 +67,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["command"], "required": ["command"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::DangerFullAccess,
}, },
ToolSpec { ToolSpec {
name: "read_file", name: "read_file",
@@ -80,6 +82,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["path"], "required": ["path"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "write_file", name: "write_file",
@@ -93,6 +96,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["path", "content"], "required": ["path", "content"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "edit_file", name: "edit_file",
@@ -108,6 +112,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["path", "old_string", "new_string"], "required": ["path", "old_string", "new_string"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "glob_search", name: "glob_search",
@@ -121,6 +126,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["pattern"], "required": ["pattern"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "grep_search", name: "grep_search",
@@ -146,6 +152,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["pattern"], "required": ["pattern"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "WebFetch", name: "WebFetch",
@@ -160,6 +167,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["url", "prompt"], "required": ["url", "prompt"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "WebSearch", name: "WebSearch",
@@ -180,6 +188,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["query"], "required": ["query"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "TodoWrite", name: "TodoWrite",
@@ -207,6 +216,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["todos"], "required": ["todos"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "Skill", name: "Skill",
@@ -220,6 +230,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["skill"], "required": ["skill"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "Agent", name: "Agent",
@@ -236,6 +247,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["description", "prompt"], "required": ["description", "prompt"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::DangerFullAccess,
}, },
ToolSpec { ToolSpec {
name: "ToolSearch", name: "ToolSearch",
@@ -249,6 +261,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["query"], "required": ["query"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "NotebookEdit", name: "NotebookEdit",
@@ -265,6 +278,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["notebook_path"], "required": ["notebook_path"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "Sleep", name: "Sleep",
@@ -277,6 +291,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["duration_ms"], "required": ["duration_ms"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "SendUserMessage", name: "SendUserMessage",
@@ -297,6 +312,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["message", "status"], "required": ["message", "status"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "Config", name: "Config",
@@ -312,6 +328,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["setting"], "required": ["setting"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::WorkspaceWrite,
}, },
ToolSpec { ToolSpec {
name: "StructuredOutput", name: "StructuredOutput",
@@ -320,6 +337,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"type": "object", "type": "object",
"additionalProperties": true "additionalProperties": true
}), }),
required_permission: PermissionMode::ReadOnly,
}, },
ToolSpec { ToolSpec {
name: "REPL", name: "REPL",
@@ -334,6 +352,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["code", "language"], "required": ["code", "language"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::DangerFullAccess,
}, },
ToolSpec { ToolSpec {
name: "PowerShell", name: "PowerShell",
@@ -349,6 +368,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["command"], "required": ["command"],
"additionalProperties": false "additionalProperties": false
}), }),
required_permission: PermissionMode::DangerFullAccess,
}, },
] ]
} }