10 Commits

Author SHA1 Message Date
Yeachan-Heo
99b78d6ea4 Polish Agent defaults and ignore crate-local agent artifacts
Move the default Agent artifact store out of rust/crates/tools so repeated Agent runs stop generating noisy crate-local files, normalize explicit Agent names through the existing slug path, and ignore any crate-local .clawd-agents residue defensively. Keep the slice limited to the tools crate and preserve the existing manifest-writing behavior.

Constraint: Must not touch unrelated dirty api files in this worktree
Constraint: Keep the change limited to rust/crates/tools
Rejected: Add a broader agent runtime or execution model | outside the final cleanup slice
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Agent persistence defaults outside package directories so generated artifacts do not pollute crate working trees
Tested: cargo test -p tools
Not-tested: concurrent multi-process Agent writes to the default fallback store
2026-03-31 20:46:06 +00:00
Yeachan-Heo
6e378185e9 Accept $skill invocation form in Skill tool
Teach Skill path resolution to accept the common $skill invocation form in addition to bare names and /skill prefixes. Keep the behavior narrow and add regression coverage using the existing help skill fixture.

Constraint: Must not touch unrelated dirty api files in this worktree
Constraint: Keep the change limited to rust/crates/tools
Rejected: Canonicalize the returned skill field to the resolved name | would change caller-visible output semantics unnecessarily
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep invocation-prefix normalization aligned with how prompt and skill references are written elsewhere in the CLI
Tested: cargo test -p tools
Not-tested: CODEX_HOME layouts with unusual symlink arrangements
2026-03-31 20:28:50 +00:00
Yeachan-Heo
019e9900ed Relax WebSearch domain filter inputs for parity
Accept case-insensitive domain filters and URL-style allow/block list entries so WebSearch behaves more forgivingly for caller-provided domain constraints. Keep the change small and limited to host matching logic plus regression coverage.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Add full public suffix or hostname normalization logic | too broad for this parity slice\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Preserve simple host matching semantics unless upstream parity proves a more exact domain model is required\nTested: cargo test -p tools\nNot-tested: internationalized domain names and punycode edge cases
2026-03-31 20:27:09 +00:00
Yeachan-Heo
67423d005a Improve WebFetch title prompts for HTML pages
Make title-focused WebFetch prompts prefer the real HTML <title> value when present instead of always falling back to the first rendered text line. Keep the behavior narrow and preserve the existing summary path for non-title prompts.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Broader HTML parsing dependency | not needed for this small parity slice\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Preserve lightweight HTML handling unless parity requires a materially more robust parser\nTested: cargo test -p tools\nNot-tested: malformed HTML with mixed-case or nested title edge cases
2026-03-31 20:26:06 +00:00
Yeachan-Heo
4db21e9595 Make PowerShell tool report backgrounding and missing shells clearly
Tighten the PowerShell tool to surface a clear not-found error when neither pwsh nor powershell exists, and mark explicit background execution as user-requested in the returned metadata. Harden the PowerShell tests against PATH mutation races while keeping the change confined to the tools crate.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Broader shell abstraction cleanup | not needed for this parity slice\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep PowerShell output metadata aligned with bash semantics when adding future shell parity improvements\nTested: cargo test -p tools\nNot-tested: real powershell.exe behavior on Windows hosts
2026-03-31 20:23:55 +00:00
Yeachan-Heo
0346b7dd3a Tighten tool parity for agent handoffs and notebook edits
Normalize Agent subagent aliases to Claude Code style built-in names, expose richer handoff metadata, teach ToolSearch to match canonical tool aliases, and polish NotebookEdit so delete does not require source and insert without a target appends cleanly. These are small parity-oriented behavior fixes confined to the tools crate.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Rework Agent into a real scheduler | outside this slice and not a small parity polish\nRejected: Add broad new tool surface area | request calls for small real parity improvements only\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep Agent built-in type normalization aligned with upstream naming aliases before expanding execution semantics\nTested: cargo test -p tools\nNot-tested: integration against a real upstream Claude Code runtime
2026-03-31 20:20:22 +00:00
Yeachan-Heo
14757e0780 feat(tools): add notebook, sleep, and powershell tools
Extend the Rust tools crate with NotebookEdit, Sleep, and PowerShell support. NotebookEdit now performs real ipynb cell replacement, insertion, and deletion; Sleep provides a non-shell wait primitive; and PowerShell executes commands with timeout/background support through a detected shell. Tests cover notebook mutation, sleep timing, and PowerShell execution via a stub shell while preserving the existing tool slices.\n\nConstraint: Keep the work confined to crates/tools/src/lib.rs and avoid staging unrelated workspace edits\nConstraint: Expose Claude Code-aligned names and close JSON-schema shapes for the new tools\nRejected: Stub-only notebook or sleep registrations | not materially useful beyond discovery\nRejected: PowerShell implemented as bash aliasing only | would not honor the distinct tool contract\nConfidence: medium\nScope-risk: moderate\nReversibility: clean\nDirective: Preserve the NotebookEdit field names and PowerShell output shape so later runtime extraction can move implementation without changing the contract\nTested: cargo fmt; cargo test -p tools\nNot-tested: cargo clippy; full workspace cargo test
2026-03-31 19:59:28 +00:00
Yeachan-Heo
2d1cade31b feat(tools): add Agent and ToolSearch support
Extend the Rust tools crate with concrete Agent and ToolSearch implementations. Agent now persists agent-handoff metadata and prompt payloads to a local store with Claude Code-style fields, while ToolSearch supports exact selection and keyword search over the deferred tool surface. Tests cover agent persistence and tool lookup behavior alongside the existing web, todo, and skill coverage.\n\nConstraint: Keep the implementation tools-only without relying on full agent orchestration runtime\nConstraint: Preserve exposed tool names and close schema parity with Claude Code\nRejected: No-op Agent stubs | would not provide material handoff value\nRejected: ToolSearch limited to exact matches only | too weak for discovery workflows\nConfidence: medium\nScope-risk: narrow\nReversibility: clean\nDirective: Keep Agent output contract stable so later execution wiring can reuse persisted metadata without renaming fields\nTested: cargo fmt; cargo test -p tools\nNot-tested: cargo clippy; full workspace cargo test
2026-03-31 19:43:10 +00:00
Yeachan-Heo
619ae71866 feat(tools): add TodoWrite and Skill tool support
Extend the Rust tools crate with concrete TodoWrite and Skill implementations. TodoWrite now validates and persists structured session todos with Claude Code-aligned item shapes, while Skill resolves local skill definitions and returns their prompt payload for execution handoff. Tests cover persistence and local skill loading without disturbing the previously added web tools.\n\nConstraint: Stay within tools-only scope and avoid depending on broader agent/runtime rewrites\nConstraint: Keep exposed tool names and schemas close to Claude Code contracts\nRejected: In-memory-only TodoWrite state | would not survive across tool calls\nRejected: Stub Skill metadata without loading prompt content | not materially useful to callers\nConfidence: medium\nScope-risk: narrow\nReversibility: clean\nDirective: Preserve TodoWrite item-field parity and keep Skill focused on local skill discovery until agent execution wiring lands\nTested: cargo fmt; cargo test -p tools\nNot-tested: cargo clippy; full workspace cargo test
2026-03-31 19:17:52 +00:00
Yeachan-Heo
5b106b840d feat(tools): add WebFetch and WebSearch parity primitives
Implement the first web-oriented Claude Code parity slice in the Rust tools crate. This adds concrete WebFetch and WebSearch tool specs, execution paths, lightweight HTML/search-result extraction, domain filtering, and local HTTP-backed tests while leaving the existing core file and shell tools intact.\n\nConstraint: Keep the change scoped to tools-only Rust workspace code\nConstraint: Match Claude Code tool names and JSON schemas closely enough for parity work\nRejected: Stub-only tool registrations | would not materially expand beyond MVP\nRejected: Full browser/search service integration | too large for this first logical slice\nConfidence: medium\nScope-risk: moderate\nReversibility: clean\nDirective: Treat these web helpers as a parity foundation; refine result quality without renaming the exposed tool contracts\nTested: cargo fmt; cargo test -p tools\nNot-tested: cargo clippy; full workspace cargo test
2026-03-31 19:15:05 +00:00
9 changed files with 2234 additions and 1205 deletions

19
rust/Cargo.lock generated
View File

@@ -212,6 +212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -220,6 +221,18 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.32" version = "0.3.32"
@@ -233,7 +246,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
] ]
@@ -898,7 +914,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -1352,6 +1370,7 @@ dependencies = [
name = "tools" name = "tools"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"reqwest",
"runtime", "runtime",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -158,10 +158,7 @@ impl AnthropicClient {
.header("anthropic-version", ANTHROPIC_VERSION) .header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json"); .header("content-type", "application/json");
let auth_header = self let auth_header = self.auth_token.as_ref().map(|_| "Bearer [REDACTED]").unwrap_or("<absent>");
.auth_token
.as_ref()
.map_or("<absent>", |_| "Bearer [REDACTED]");
eprintln!("[anthropic-client] headers x-api-key=[REDACTED] authorization={auth_header} anthropic-version={ANTHROPIC_VERSION} content-type=application/json"); eprintln!("[anthropic-client] headers x-api-key=[REDACTED] authorization={auth_header} anthropic-version={ANTHROPIC_VERSION} content-type=application/json");
if let Some(auth_token) = &self.auth_token { if let Some(auth_token) = &self.auth_token {
@@ -195,7 +192,8 @@ fn read_api_key() -> Result<String, ApiError> {
Ok(_) => Err(ApiError::MissingApiKey), Ok(_) => Err(ApiError::MissingApiKey),
Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_AUTH_TOKEN") { Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_AUTH_TOKEN") {
Ok(api_key) if !api_key.is_empty() => Ok(api_key), Ok(api_key) if !api_key.is_empty() => Ok(api_key),
Ok(_) | Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey), Ok(_) => Err(ApiError::MissingApiKey),
Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey),
Err(error) => Err(ApiError::from(error)), Err(error) => Err(ApiError::from(error)),
}, },
Err(error) => Err(ApiError::from(error)), Err(error) => Err(ApiError::from(error)),
@@ -305,22 +303,12 @@ struct AnthropicErrorBody {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER}; use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
use std::sync::{Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use crate::types::{ContentBlockDelta, MessageRequest}; use crate::types::{ContentBlockDelta, MessageRequest};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not be poisoned")
}
#[test] #[test]
fn read_api_key_requires_presence() { fn read_api_key_requires_presence() {
let _guard = env_lock();
std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY"); std::env::remove_var("ANTHROPIC_API_KEY");
let error = super::read_api_key().expect_err("missing key should error"); let error = super::read_api_key().expect_err("missing key should error");
@@ -329,7 +317,6 @@ mod tests {
#[test] #[test]
fn read_api_key_requires_non_empty_value() { fn read_api_key_requires_non_empty_value() {
let _guard = env_lock();
std::env::set_var("ANTHROPIC_AUTH_TOKEN", ""); std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
std::env::remove_var("ANTHROPIC_API_KEY"); std::env::remove_var("ANTHROPIC_API_KEY");
let error = super::read_api_key().expect_err("empty key should error"); let error = super::read_api_key().expect_err("empty key should error");
@@ -338,7 +325,6 @@ mod tests {
#[test] #[test]
fn read_api_key_prefers_api_key_env() { fn read_api_key_prefers_api_key_env() {
let _guard = env_lock();
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token"); std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
std::env::set_var("ANTHROPIC_API_KEY", "legacy-key"); std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
assert_eq!( assert_eq!(
@@ -351,7 +337,6 @@ mod tests {
#[test] #[test]
fn read_auth_token_reads_auth_token_env() { fn read_auth_token_reads_auth_token_env() {
let _guard = env_lock();
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token"); std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
assert_eq!(super::read_auth_token().as_deref(), Some("auth-token")); assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); std::env::remove_var("ANTHROPIC_AUTH_TOKEN");

View File

@@ -30,168 +30,6 @@ impl CommandRegistry {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SlashCommandSpec {
pub name: &'static str,
pub summary: &'static str,
pub argument_hint: Option<&'static str>,
pub resume_supported: bool,
}
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec {
name: "help",
summary: "Show available slash commands",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "status",
summary: "Show current session status",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "compact",
summary: "Compact local session history",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "model",
summary: "Show or switch the active model",
argument_hint: Some("[model]"),
resume_supported: false,
},
SlashCommandSpec {
name: "permissions",
summary: "Show or switch the active permission mode",
argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
resume_supported: false,
},
SlashCommandSpec {
name: "clear",
summary: "Start a fresh local session",
argument_hint: Some("[--confirm]"),
resume_supported: true,
},
SlashCommandSpec {
name: "cost",
summary: "Show cumulative token usage for this session",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "resume",
summary: "Load a saved session into the REPL",
argument_hint: Some("<session-path>"),
resume_supported: false,
},
SlashCommandSpec {
name: "config",
summary: "Inspect discovered Claude config files",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "memory",
summary: "Inspect loaded Claude instruction memory files",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "init",
summary: "Create a starter CLAUDE.md for this repo",
argument_hint: None,
resume_supported: true,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlashCommand {
Help,
Status,
Compact,
Model { model: Option<String> },
Permissions { mode: Option<String> },
Clear { confirm: bool },
Cost,
Resume { session_path: Option<String> },
Config,
Memory,
Init,
Unknown(String),
}
impl SlashCommand {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return None;
}
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
let command = parts.next().unwrap_or_default();
Some(match command {
"help" => Self::Help,
"status" => Self::Status,
"compact" => Self::Compact,
"model" => Self::Model {
model: parts.next().map(ToOwned::to_owned),
},
"permissions" => Self::Permissions {
mode: parts.next().map(ToOwned::to_owned),
},
"clear" => Self::Clear {
confirm: parts.next() == Some("--confirm"),
},
"cost" => Self::Cost,
"resume" => Self::Resume {
session_path: parts.next().map(ToOwned::to_owned),
},
"config" => Self::Config,
"memory" => Self::Memory,
"init" => Self::Init,
other => Self::Unknown(other.to_string()),
})
}
}
#[must_use]
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
SLASH_COMMAND_SPECS
}
#[must_use]
pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
slash_command_specs()
.iter()
.filter(|spec| spec.resume_supported)
.collect()
}
#[must_use]
pub fn render_slash_command_help() -> String {
let mut lines = vec![
"Available commands:".to_string(),
" (resume-safe commands are marked with [resume])".to_string(),
];
for spec in slash_command_specs() {
let name = match spec.argument_hint {
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
None => format!("/{}", spec.name),
};
let resume = if spec.resume_supported {
" [resume]"
} else {
""
};
lines.push(format!(" {name:<20} {}{}", spec.summary, resume));
}
lines.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlashCommandResult { pub struct SlashCommandResult {
pub message: String, pub message: String,
@@ -204,8 +42,13 @@ pub fn handle_slash_command(
session: &Session, session: &Session,
compaction: CompactionConfig, compaction: CompactionConfig,
) -> Option<SlashCommandResult> { ) -> Option<SlashCommandResult> {
match SlashCommand::parse(input)? { let trimmed = input.trim();
SlashCommand::Compact => { if !trimmed.starts_with('/') {
return None;
}
match trimmed.split_whitespace().next() {
Some("/compact") => {
let result = compact_session(session, compaction); let result = compact_session(session, compaction);
let message = if result.removed_message_count == 0 { let message = if result.removed_message_count == 0 {
"Compaction skipped: session is below the compaction threshold.".to_string() "Compaction skipped: session is below the compaction threshold.".to_string()
@@ -220,90 +63,15 @@ pub fn handle_slash_command(
session: result.compacted_session, session: result.compacted_session,
}) })
} }
SlashCommand::Help => Some(SlashCommandResult { _ => None,
message: render_slash_command_help(),
session: session.clone(),
}),
SlashCommand::Status
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. }
| SlashCommand::Cost
| SlashCommand::Resume { .. }
| SlashCommand::Config
| SlashCommand::Memory
| SlashCommand::Init
| SlashCommand::Unknown(_) => None,
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::handle_slash_command;
handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
slash_command_specs, SlashCommand,
};
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
#[test]
fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
assert_eq!(
SlashCommand::parse("/model claude-opus"),
Some(SlashCommand::Model {
model: Some("claude-opus".to_string()),
})
);
assert_eq!(
SlashCommand::parse("/model"),
Some(SlashCommand::Model { model: None })
);
assert_eq!(
SlashCommand::parse("/permissions read-only"),
Some(SlashCommand::Permissions {
mode: Some("read-only".to_string()),
})
);
assert_eq!(
SlashCommand::parse("/clear"),
Some(SlashCommand::Clear { confirm: false })
);
assert_eq!(
SlashCommand::parse("/clear --confirm"),
Some(SlashCommand::Clear { confirm: true })
);
assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
assert_eq!(
SlashCommand::parse("/resume session.json"),
Some(SlashCommand::Resume {
session_path: Some("session.json".to_string()),
})
);
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
}
#[test]
fn renders_help_from_shared_specs() {
let help = render_slash_command_help();
assert!(help.contains("resume-safe commands"));
assert!(help.contains("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/compact"));
assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config"));
assert!(help.contains("/memory"));
assert!(help.contains("/init"));
assert_eq!(slash_command_specs().len(), 11);
assert_eq!(resume_supported_slash_commands().len(), 8);
}
#[test] #[test]
fn compacts_sessions_via_slash_command() { fn compacts_sessions_via_slash_command() {
let session = Session { let session = Session {
@@ -335,40 +103,8 @@ mod tests {
} }
#[test] #[test]
fn help_command_is_non_mutating() { fn ignores_unknown_slash_commands() {
let session = Session::new();
let result = handle_slash_command("/help", &session, CompactionConfig::default())
.expect("help command should be handled");
assert_eq!(result.session, session);
assert!(result.message.contains("Available commands:"));
}
#[test]
fn ignores_unknown_or_runtime_bound_slash_commands() {
let session = Session::new(); let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
);
assert!(handle_slash_command(
"/permissions read-only",
&session,
CompactionConfig::default()
)
.is_none());
assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
.is_none()
);
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command(
"/resume session.json",
&session,
CompactionConfig::default()
)
.is_none());
assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
} }
} }

View File

@@ -24,10 +24,9 @@ impl UpstreamPaths {
.as_ref() .as_ref()
.canonicalize() .canonicalize()
.unwrap_or_else(|_| workspace_dir.as_ref().to_path_buf()); .unwrap_or_else(|_| workspace_dir.as_ref().to_path_buf());
let primary_repo_root = workspace_dir let repo_root = workspace_dir
.parent() .parent()
.map_or_else(|| PathBuf::from(".."), Path::to_path_buf); .map_or_else(|| PathBuf::from(".."), Path::to_path_buf);
let repo_root = resolve_upstream_repo_root(&primary_repo_root);
Self { repo_root } Self { repo_root }
} }
@@ -54,42 +53,6 @@ pub struct ExtractedManifest {
pub bootstrap: BootstrapPlan, pub bootstrap: BootstrapPlan,
} }
fn resolve_upstream_repo_root(primary_repo_root: &Path) -> PathBuf {
let candidates = upstream_repo_candidates(primary_repo_root);
candidates
.into_iter()
.find(|candidate| candidate.join("src/commands.ts").is_file())
.unwrap_or_else(|| primary_repo_root.to_path_buf())
}
fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec<PathBuf> {
let mut candidates = vec![primary_repo_root.to_path_buf()];
if let Some(explicit) = std::env::var_os("CLAUDE_CODE_UPSTREAM") {
candidates.push(PathBuf::from(explicit));
}
for ancestor in primary_repo_root.ancestors().take(4) {
candidates.push(ancestor.join("claude-code"));
candidates.push(ancestor.join("clawd-code"));
}
candidates.push(
primary_repo_root
.join("reference-source")
.join("claude-code"),
);
candidates.push(primary_repo_root.join("vendor").join("claude-code"));
let mut deduped = Vec::new();
for candidate in candidates {
if !deduped.iter().any(|seen: &PathBuf| seen == &candidate) {
deduped.push(candidate);
}
}
deduped
}
pub fn extract_manifest(paths: &UpstreamPaths) -> std::io::Result<ExtractedManifest> { pub fn extract_manifest(paths: &UpstreamPaths) -> std::io::Result<ExtractedManifest> {
let commands_source = fs::read_to_string(paths.commands_path())?; let commands_source = fs::read_to_string(paths.commands_path())?;
let tools_source = fs::read_to_string(paths.tools_path())?; let tools_source = fs::read_to_string(paths.tools_path())?;

View File

@@ -138,9 +138,9 @@ pub fn read_file(
let content = fs::read_to_string(&absolute_path)?; let content = fs::read_to_string(&absolute_path)?;
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
let start_index = offset.unwrap_or(0).min(lines.len()); let start_index = offset.unwrap_or(0).min(lines.len());
let end_index = limit.map_or(lines.len(), |limit| { let end_index = limit
start_index.saturating_add(limit).min(lines.len()) .map(|limit| start_index.saturating_add(limit).min(lines.len()))
}); .unwrap_or(lines.len());
let selected = lines[start_index..end_index].join("\n"); let selected = lines[start_index..end_index].join("\n");
Ok(ReadFileOutput { Ok(ReadFileOutput {
@@ -285,7 +285,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
.output_mode .output_mode
.clone() .clone()
.unwrap_or_else(|| String::from("files_with_matches")); .unwrap_or_else(|| String::from("files_with_matches"));
let context_window = input.context.or(input.context_short).unwrap_or(0); let context = input.context.or(input.context_short).unwrap_or(0);
let mut filenames = Vec::new(); let mut filenames = Vec::new();
let mut content_lines = Vec::new(); let mut content_lines = Vec::new();
@@ -296,12 +296,12 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
continue; continue;
} }
let Ok(file_content) = fs::read_to_string(&file_path) else { let Ok(content) = fs::read_to_string(&file_path) else {
continue; continue;
}; };
if output_mode == "count" { if output_mode == "count" {
let count = regex.find_iter(&file_content).count(); let count = regex.find_iter(&content).count();
if count > 0 { if count > 0 {
filenames.push(file_path.to_string_lossy().into_owned()); filenames.push(file_path.to_string_lossy().into_owned());
total_matches += count; total_matches += count;
@@ -309,7 +309,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
continue; continue;
} }
let lines: Vec<&str> = file_content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
let mut matched_lines = Vec::new(); let mut matched_lines = Vec::new();
for (index, line) in lines.iter().enumerate() { for (index, line) in lines.iter().enumerate() {
if regex.is_match(line) { if regex.is_match(line) {
@@ -325,15 +325,15 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
filenames.push(file_path.to_string_lossy().into_owned()); filenames.push(file_path.to_string_lossy().into_owned());
if output_mode == "content" { if output_mode == "content" {
for index in matched_lines { for index in matched_lines {
let start = index.saturating_sub(input.before.unwrap_or(context_window)); let start = index.saturating_sub(input.before.unwrap_or(context));
let end = (index + input.after.unwrap_or(context_window) + 1).min(lines.len()); let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
for (current, line_content) in lines.iter().enumerate().take(end).skip(start) { for current in start..end {
let prefix = if input.line_numbers.unwrap_or(true) { let prefix = if input.line_numbers.unwrap_or(true) {
format!("{}:{}:", file_path.to_string_lossy(), current + 1) format!("{}:{}:", file_path.to_string_lossy(), current + 1)
} else { } else {
format!("{}:", file_path.to_string_lossy()) format!("{}:", file_path.to_string_lossy())
}; };
content_lines.push(format!("{prefix}{line_content}")); content_lines.push(format!("{prefix}{}", lines[current]));
} }
} }
} }
@@ -376,7 +376,8 @@ fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
let mut files = Vec::new(); let mut files = Vec::new();
for entry in WalkDir::new(base_path) { for entry in WalkDir::new(base_path) {
let entry = entry.map_err(|error| io::Error::other(error.to_string()))?; let entry =
entry.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))?;
if entry.file_type().is_file() { if entry.file_type().is_file() {
files.push(entry.path().to_path_buf()); files.push(entry.path().to_path_buf());
} }

File diff suppressed because it is too large Load Diff

1
rust/crates/tools/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.clawd-agents/

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies] [dependencies]
runtime = { path = "../runtime" } runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

File diff suppressed because it is too large Load Diff