slug-code/CLAUDE.md
Bryan Ramos 386ac8a391 Expand CLAUDE.md to full project reference (~15K chars)
Covers: complete type signatures, data flow, coding standards,
testing requirements, documentation standards, anti-patterns,
CLI reference, and prioritized remaining work.
2026-03-31 14:34:26 -04:00

15 KiB

Slug Code — Agent & Developer Reference

FOSS Rust rewrite of Claude Code. Pluggable LLM backend targeting OpenAI-compatible APIs (vLLM primary). Binary: slug. Package: slug-code. Rust 2024 edition.

Repo layout: master = original TypeScript Claude Code source (reference only). rust-rewrite = this Rust implementation (all work happens here).


Module Map

src/
├── main.rs          CLI entry (clap). Wires all modules. Handles session flags.
├── config/mod.rs    Config struct + PermissionMode enum. TOML + CLI + env.
├── provider/
│   ├── mod.rs       Provider trait + OpenAIProvider (SSE streaming impl)
│   └── types.rs     ChatMessage, Role, ToolCall, StreamEvent, StreamChunk
├── agent/mod.rs     Core loop: user input → LLM stream → tool exec → repeat
├── tools/
│   ├── mod.rs       Tool trait + ToolRegistry (HashMap<name, Box<dyn Tool>>)
│   ├── bash.rs      BashTool — runs via `bash -c`, captures stdout+stderr
│   ├── read.rs      ReadTool — reads file with line numbers, offset+limit
│   ├── write.rs     WriteTool — writes file, creates parent dirs
│   ├── edit.rs      EditTool — exact string replacement, enforces uniqueness
│   ├── glob.rs      GlobTool — globwalk, max_depth 20, sorted output
│   └── grep.rs      GrepTool — prefers rg, falls back to grep, 250 line cap
├── permissions/mod.rs  PermissionHandler. Glob allow/deny. ask/yolo/sandbox/allowEdits.
├── slugmd/mod.rs    Loads SLUG.md hierarchy every turn, 40K char budget.
├── session/mod.rs   JSONL persistence at ~/.slug/sessions/. SessionManager.
├── hooks/mod.rs     HookManager. 5 events, command+prompt types. .slug/hooks.json.
├── compact/mod.rs   Compactor. ToolResultTrim→Truncate. /compact command.
└── tui/mod.rs       Interactive REPL. Wires agent+session+hooks. Slash commands.

Key Types

provider/types.rs

ChatMessage { role: Role, content: Option<String>, tool_calls: Option<Vec<ToolCall>>, tool_call_id: Option<String> }
// Constructors: ChatMessage::system(&str), ::user(&str), ::assistant(Option<String>, Option<Vec<ToolCall>>), ::tool_result(&str, &str)

Role: System | User | Assistant | Tool  // serde: lowercase

ToolCall { id: String, call_type: String, function: FunctionCall }
FunctionCall { name: String, arguments: String }  // arguments is JSON string

StreamEvent: Text(String) | ToolCallDelta(ToolCallDelta) | Finish | Done
ToolCallDelta { index: usize, id: Option<String>, name: Option<String>, arguments_delta: Option<String> }

provider/mod.rs

trait Provider: Send + Sync {
    fn stream_chat(&self, messages: &[ChatMessage], tools: &[ToolDefinition])
        -> Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send + '_>>;
}
// OpenAIProvider implements Provider. POST /chat/completions with stream:true.
// SSE parsing: buffer bytes → split on \n → strip "data: " prefix → parse StreamChunk.
// Tool calls assembled via ToolCallAccumulator keyed by delta.index.

tools/mod.rs

trait Tool: Send + Sync {
    fn definition(&self) -> ToolDefinition;
    fn execute(&self, args: &Value) -> Result<String>;
}
ToolDefinition { name: String, description: String, parameters: Value }  // parameters is JSON Schema
// ToolRegistry: HashMap<String, Box<dyn Tool>>. execute() returns Result<String> — errors become tool result strings in agent.

config/mod.rs

Config { endpoint, api_key, model, system_prompt, max_tokens: u32, temperature: Option<f32>, max_tool_rounds: usize, permission_mode: PermissionMode }
// Defaults: endpoint="http://localhost:8000/v1", model="default", max_tokens=4096, max_tool_rounds=50
// Load order: TOML file → with_cli_overrides()

PermissionMode: Ask | Yolo | AllowEdits | Sandbox(String)
// serde: { "type": "ask" } / { "type": "yolo" } / { "type": "allowedits" } / { "type": "sandbox", ... }

permissions/mod.rs

PermissionRequest: Bash { command: &str } | FileWrite { path: &str } | FileEdit { path: &str }
// check() order: deny rules → allow rules → mode fallback
// Glob patterns: "Bash(npm *)", "Edit(src/**)", "Write(tests/**)"
// Settings loaded from ~/.slug/settings.json then .slug/settings.json (merged, project extends global)
// glob_match(pattern, text): ** matches across /, * does not

session/mod.rs

SessionManager { sessions_dir: PathBuf }
Session { session_id: String, messages: Vec<ChatMessage> }
SessionMeta { session_id, created_at, last_used_at, working_directory, model, summary }
// Storage: ~/.slug/sessions/{id}.jsonl (one ChatMessage per line) + {id}.meta.json
// Methods: create_session(), load_session(&str), save_message(&str, &ChatMessage), list_sessions(), get_latest_session(), fork_session(&str)

hooks/mod.rs

HookEvent: PreToolUse { tool_name, args } | PostToolUse { tool_name, args, result } | UserPromptSubmit { prompt } | SessionStart | SessionEnd
HookAction: Command { command: String } | Prompt { content: String }
HookResult { additional_context: Option<String>, blocked: bool, block_reason: Option<String> }
// HookManager::new() loads ~/.slug/settings.json + .slug/hooks.json
// fire(&HookEvent) → HookResult. Command hooks: sh -c, 30s timeout, non-zero = blocked.
// Config: { "hooks": [{ "event": "PreToolUse", "tool_filter": "bash", "action": { "type": "command", "command": "..." } }] }

compact/mod.rs

CompactionStrategy: Full | Truncate | ToolResultTrim
Compactor { max_context_tokens: usize }  // default 200_000
// estimate_tokens(text) = chars / 4
// needs_compaction() = estimated > 80% of max
// ToolResultTrim: replace Tool role messages older than last 5 with "[result truncated — N chars]"
// Truncate: keep system prompt + last 10 user-turn groups
// extract_session_memory(): structured summary of task, files, errors, decisions

slugmd/mod.rs

load_slug_context() -> String
// Load order (all optional, concatenated with headers):
//   1. ~/.slug/SLUG.md          "# Global Rules"
//   2. ./SLUG.md                "# Project Rules"
//   3. .slug/rules/*.md         "# Rule: {filename}" (sorted alphabetically)
//   4. ./SLUG.local.md          "# Local Notes (private)"
// 40,000 char hard limit — truncates at line boundary with warning appended
// Returns "" if no files exist (no-op path, no overhead)
// NEVER stored in Agent.messages — reloaded from disk on every LLM call

agent/mod.rs

Agent { provider, tools, permissions, messages: Vec<ChatMessage>, max_rounds: usize }
// new() → seeds messages with [ChatMessage::system(config.system_prompt)]
// new_with_history() → prepends system prompt then extends with prior_messages
// messages() → &[ChatMessage]  (used for session saving)
// stream_turn(input, on_text) → streams LLM response, executes tools, loops
// run_once(prompt) → non-interactive, returns final text
// compact() → ToolResultTrim first, then Truncate if still over threshold
// messages_with_slug_context() → [slug_context_system_msg, ...self.messages] (rebuilt each call)
// execute_with_permission(name, args) → checks PermissionHandler before calling ToolRegistry
// Tool execution: read/glob/grep = always allowed. bash/write/edit = permission checked.

Data Flow

User input
  → tui::run()
    → hook_mgr.fire(UserPromptSubmit)  // can block or inject context
    → agent.stream_turn(input, on_text)
      → messages.push(ChatMessage::user(input))
      → messages_with_slug_context()   // fresh SLUG.md prepended every call
      → provider.stream_chat(messages, tool_defs)
        → POST /v1/chat/completions (stream: true)
        → SSE parse → yield StreamEvent::{Text, ToolCallDelta, Finish, Done}
      → on_text(chunk) called for each Text event  // prints to stdout in TUI
      → ToolCallAccumulators assembled from deltas
      → if tool_calls not empty:
          → messages.push(assistant msg with tool_calls)
          → for each tool_call:
              → execute_with_permission(name, args)
                → permissions.check(request)  // deny→allow→mode
                → tools.execute(name, args)   // ToolRegistry dispatch
              → messages.push(tool_result)
          → loop (up to max_tool_rounds)
      → if no tool_calls: messages.push(assistant msg), return
  → session_mgr.save_message() for each new message

Coding Standards

Error Handling

  • Use ? for propagation everywhere. No unwrap() outside tests.
  • Return anyhow::Result<T> from all fallible functions.
  • Tool execution errors are caught and returned as the tool result string — never propagate to agent loop.
  • Use anyhow::bail!("message") for early returns with context.
  • Use anyhow::anyhow!("message") to construct errors.

Async

  • All async code uses tokio. Runtime is #[tokio::main] in main.rs.
  • Streams use async_stream::stream! macro with explicit yield Ok(...) / yield Err(...).
  • Never yield Err inside async_stream using ? — yield the error explicitly and return.
  • Provider stream_chat returns Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send + '_>>.

Serialization

  • All wire types (ChatMessage, ToolCall, SessionMeta) derive Serialize + Deserialize.
  • skip_serializing_if = "Option::is_none" on optional fields to keep JSON clean.
  • Tool arguments arrive as serde_json::Value — extract with .as_str(), .as_u64() etc, never index directly.
  • Config uses #[serde(tag = "type", rename_all = "lowercase")] for PermissionMode enum.

Adding a New Tool

  1. Create src/tools/{name}.rs implementing Tool trait.
  2. Add mod {name}; to src/tools/mod.rs.
  3. Register: registry.register(Box::new({name}::{Name}Tool)); in ToolRegistry::new().
  4. Permission: if mutating (writes files, runs code), add a PermissionRequest variant and check in agent::execute_with_permission.
  5. Write unit tests in the same file under #[cfg(test)].

Adding a New Provider

  1. Create src/provider/{name}.rs implementing Provider trait.
  2. pub use from src/provider/mod.rs.
  3. Wire into main.rs behind a config flag or CLI arg.
  4. Must return Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send + '_>>.
  5. Normalize provider-specific formats to StreamEvent — the agent loop is provider-agnostic.

Testing Standards

Testing is non-negotiable. Every module must have tests. Agents: do not consider a task complete without writing tests.

Required Tests Per Module

  • Tools: Test each tool with valid args, missing args, and error cases (file not found, bad pattern, etc.)
  • Provider: Test build_request_body with and without tools, with and without temperature.
  • Permissions: Test each PermissionMode, glob pattern matching, deny overrides allow, sandbox path checking.
  • Session: Test create/save/load round-trip, fork copies messages, list_sessions returns sorted.
  • Hooks: Test command hook execution, blocked result on non-zero exit, prompt hook injection.
  • Compact: Test ToolResultTrim preserves last 5, Truncate preserves system prompt + last 10 groups, estimate_tokens.
  • Slugmd: Test 40K truncation, missing files are skipped, section headers are present.

Test Patterns

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;  // use tempfile crate for file-based tests

    #[test]
    fn test_edit_tool_not_found() {
        let tool = EditTool;
        let args = serde_json::json!({ "file_path": "/nonexistent", "old_string": "x", "new_string": "y" });
        assert!(tool.execute(&args).is_err());
    }
}
  • Use tempfile::TempDir for any test touching the filesystem.
  • Test tool errors produce Err, not panics.
  • Test permission glob matching exhaustively — it's a custom implementation.
  • Integration tests go in tests/ at crate root.

Documentation Standards

Every public item must have a doc comment. Agents: do not submit code without doc comments on public API.

/// One-line summary.
///
/// Longer explanation if the behavior is non-obvious.
/// Include what errors are returned and under what conditions.
pub fn load_slug_context() -> String { ... }
  • /// for public items (rendered by rustdoc).
  • // for implementation comments explaining why, not what.
  • Mark unfinished areas with // TODO(feature): description not just // TODO.
  • Module-level //! ... doc comments required for every mod.rs.

CLI Reference

slug [OPTIONS]

  -e, --endpoint <URL>       LLM API base URL [env: SLUG_ENDPOINT]
  -k, --api-key <KEY>        API key          [env: SLUG_API_KEY]
  -m, --model <NAME>         Model name       [env: SLUG_MODEL]
  -p, --prompt <TEXT>        Non-interactive single prompt
  -c, --config <PATH>        Config file path
      --system-prompt <TEXT> Override system prompt
      --yolo                 Skip all permission prompts
      --sandbox <DIR>        Auto-approve ops within directory
      --allow-edits          Auto-approve file edits in cwd
      --continue             Resume most recent session
      --resume <ID>          Resume specific session
      --fork-session <ID>    Fork session into new branch

Config file: ~/.slug/config.toml Permission rules: ~/.slug/settings.json or .slug/settings.json Hooks: .slug/hooks.json SLUG.md files: ~/.slug/SLUG.md, ./SLUG.md, .slug/rules/*.md, ./SLUG.local.md Sessions: ~/.slug/sessions/{id}.jsonl


What's Not Done Yet (Remaining Work)

High priority:

  • Subagent parallelism — fork (shared cache), teammate (file mailbox), worktree (isolated branch)
  • Ratatui TUI — proper terminal UI with markdown rendering, scrollback, syntax highlighting
  • Retry with exponential backoff — 10 retries, jitter, model fallback on repeated 5xx
  • Stream interruption — Escape key aborts active stream cleanly, context preserved
  • Wire PreToolUse/PostToolUse hooks into agent::execute_with_permission
  • Concurrent tool execution — read/glob/grep run in parallel, bash/write/edit serial

Medium priority:

  • MCP client — connect to MCP servers, deferred tool loading
  • Computer use (screenshot, click, type)
  • Auto-compact trigger when context approaches limit

Low priority:

  • Session search / conversation history browser
  • Anthropic provider — /v1/messages format, different streaming protocol
  • Google Gemini provider

Anti-Patterns — Never Do These

  • No unwrap() or expect() in non-test code.
  • Never store SLUG.md content in Agent.messages — it must be reloaded every turn.
  • Never add analytics, telemetry, or tracking of any kind.
  • Never add Anthropic-internal features (feature flags, bridge mode, CCR, Kairos, etc.).
  • Do not add backwards-compat shims — just change the code.
  • Do not add error handling for impossible cases — trust internal invariants.
  • Do not add abstractions for one-time use — three similar lines beat a premature helper.
  • Tool errors should return Err(...) — the agent catches them and turns them into tool result strings. Don't swallow errors silently.
  • Permission checks must happen in agent::execute_with_permission — tools themselves are unaware of permissions.