2 Commits

Author SHA1 Message Date
Yeachan-Heo
962292dd27 auto: save WIP progress from rcc session 2026-04-01 04:01:37 +00:00
Yeachan-Heo
074bd5b7b7 Make Claude project bootstrap available from a real init command
The Rust CLI previously hid init behind the REPL slash-command surface and only
created a starter CLAUDE.md. This change adds a direct `init` subcommand and
moves bootstrap behavior into a shared helper so `/init` and `init` create the
same project scaffolding: `.claude/`, `.claude.json`, starter `CLAUDE.md`, and
local-only `.gitignore` entries. The generated guidance now adapts to a small,
explicit set of repository markers so new projects get language/framework-aware
starting instructions without overwriting existing files.

Constraint: Runtime config precedence already treats `.claude.json`, `.claude/settings.json`, and `.claude/settings.local.json` as separate scopes
Constraint: `.claude/sessions/` is used for local session persistence and should not be committed by default
Rejected: Keep init as REPL-only `/init` behavior | would not satisfy the requested direct init command and keeps bootstrap discoverability low
Rejected: Ignore all of `.claude/` | would hide shared project config that the runtime can intentionally load
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep direct `init` and `/init` on the same helper path and keep detection heuristics bounded to explicit repository markers
Tested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: interactive manual run of `rusty-claude-cli init` against a non-test repository
2026-04-01 01:14:44 +00:00
6 changed files with 711 additions and 360 deletions

View File

@@ -0,0 +1,127 @@
{
"version": "1.0.0",
"lastScanned": 1774971516826,
"projectRoot": "/home/bellman/Workspace/clawd-code-worktrees/api/rust",
"techStack": {
"languages": [
{
"name": "Rust",
"version": null,
"confidence": "high",
"markers": [
"Cargo.toml"
]
}
],
"frameworks": [],
"packageManager": "cargo",
"runtime": null
},
"build": {
"buildCommand": "cargo build",
"testCommand": "cargo test",
"lintCommand": "cargo clippy",
"devCommand": "cargo run",
"scripts": {}
},
"conventions": {
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": null
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [],
"gitBranches": {
"defaultBranch": "main",
"branchingStrategy": null
}
},
"customNotes": [],
"directoryMap": {
"crates": {
"path": "crates",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774971516823,
"keyFiles": []
},
"target": {
"path": "target",
"purpose": null,
"fileCount": 2,
"lastAccessed": 1774971516823,
"keyFiles": [
"CACHEDIR.TAG"
]
},
"crates/api": {
"path": "crates/api",
"purpose": "API routes",
"fileCount": 1,
"lastAccessed": 1774971516824,
"keyFiles": [
"Cargo.toml"
]
}
},
"hotPaths": [
{
"path": "crates/api/Cargo.toml",
"accessCount": 1,
"lastAccessed": 1774971547109,
"type": "file"
},
{
"path": "crates/api/src/lib.rs",
"accessCount": 1,
"lastAccessed": 1774971547624,
"type": "file"
},
{
"path": "crates/api/src/client.rs",
"accessCount": 1,
"lastAccessed": 1774971548001,
"type": "file"
},
{
"path": "crates/api/src/error.rs",
"accessCount": 1,
"lastAccessed": 1774971548503,
"type": "file"
},
{
"path": "crates/api/src/sse.rs",
"accessCount": 1,
"lastAccessed": 1774971549311,
"type": "file"
},
{
"path": "crates/api/src/types.rs",
"accessCount": 1,
"lastAccessed": 1774971549472,
"type": "file"
},
{
"path": "crates/api/tests/client_integration.rs",
"accessCount": 1,
"lastAccessed": 1774971550143,
"type": "file"
},
{
"path": "Cargo.toml",
"accessCount": 1,
"lastAccessed": 1774971550539,
"type": "file"
},
{
"path": "crates/rusty-claude-cli/src/main.rs",
"accessCount": 1,
"lastAccessed": 1774971551474,
"type": "file"
}
],
"userDirectives": []
}

View File

@@ -0,0 +1,3 @@
{
"lastSentAt": "2026-03-31T15:39:44.771Z"
}

View File

@@ -109,6 +109,13 @@ cd rust
cargo run -p rusty-claude-cli -- --allowedTools read,glob cargo run -p rusty-claude-cli -- --allowedTools read,glob
``` ```
Bootstrap Claude project files for the current repo:
```bash
cd rust
cargo run -p rusty-claude-cli -- init
```
### 2) REPL mode ### 2) REPL mode
Start the interactive shell: Start the interactive shell:
@@ -179,7 +186,7 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
- `/resume <session-path>` — load a saved session into the REPL - `/resume <session-path>` — load a saved session into the REPL
- `/config [env|hooks|model]` — inspect discovered Claude config - `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files - `/memory` — inspect loaded instruction memory files
- `/init`create a starter `CLAUDE.md` - `/init`bootstrap `.claude.json`, `.claude/`, `CLAUDE.md`, and local ignore rules
- `/diff` — show the current git diff for the workspace - `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally - `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript - `/export [file]` — export the current conversation transcript

View File

@@ -101,7 +101,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
}, },
SlashCommandSpec { SlashCommandSpec {
name: "init", name: "init",
summary: "Create a starter CLAUDE.md for this repo", summary: "Bootstrap Claude project files for this repo",
argument_hint: None, argument_hint: None,
resume_supported: true, resume_supported: true,
}, },

View File

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

View File

@@ -1,3 +1,4 @@
mod init;
mod input; mod input;
mod render; mod render;
@@ -20,11 +21,12 @@ use commands::{
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
}; };
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
use render::{Spinner, TerminalRenderer}; use render::{Spinner, TerminalRenderer};
use runtime::{ use runtime::{
clear_oauth_credentials, format_usd, generate_pkce_pair, generate_state, load_system_prompt, clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, pricing_for_model, save_oauth_credentials, ApiClient, parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
@@ -36,7 +38,6 @@ const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS: u32 = 32; const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const COST_WARNING_FRACTION: f64 = 0.8;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
@@ -71,23 +72,22 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
max_cost_usd, } => LiveCli::new(model, false, allowed_tools, permission_mode)?
} => LiveCli::new(model, false, allowed_tools, permission_mode, max_cost_usd)?
.run_turn_with_output(&prompt, output_format)?, .run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?, CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?, CliAction::Logout => run_logout()?,
CliAction::Init => run_init()?,
CliAction::Repl { CliAction::Repl {
model, model,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
max_cost_usd, } => run_repl(model, allowed_tools, permission_mode)?,
} => run_repl(model, allowed_tools, permission_mode, max_cost_usd)?,
CliAction::Help => print_help(), CliAction::Help => print_help(),
} }
Ok(()) Ok(())
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction { enum CliAction {
DumpManifests, DumpManifests,
BootstrapPlan, BootstrapPlan,
@@ -106,15 +106,14 @@ enum CliAction {
output_format: CliOutputFormat, output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
}, },
Login, Login,
Logout, Logout,
Init,
Repl { Repl {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
}, },
// prompt-mode formatting is only supported for non-interactive runs // prompt-mode formatting is only supported for non-interactive runs
Help, Help,
@@ -144,7 +143,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut output_format = CliOutputFormat::Text; let mut output_format = CliOutputFormat::Text;
let mut permission_mode = default_permission_mode(); let mut permission_mode = default_permission_mode();
let mut wants_version = false; let mut wants_version = false;
let mut max_cost_usd: Option<f64> = None;
let mut allowed_tool_values = Vec::new(); let mut allowed_tool_values = Vec::new();
let mut rest = Vec::new(); let mut rest = Vec::new();
let mut index = 0; let mut index = 0;
@@ -180,13 +178,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode = parse_permission_mode_arg(value)?; permission_mode = parse_permission_mode_arg(value)?;
index += 2; index += 2;
} }
"--max-cost" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --max-cost".to_string())?;
max_cost_usd = Some(parse_max_cost_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;
@@ -195,10 +186,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode = parse_permission_mode_arg(&flag[18..])?; permission_mode = parse_permission_mode_arg(&flag[18..])?;
index += 1; index += 1;
} }
flag if flag.starts_with("--max-cost=") => {
max_cost_usd = Some(parse_max_cost_arg(&flag[11..])?);
index += 1;
}
"--allowedTools" | "--allowed-tools" => { "--allowedTools" | "--allowed-tools" => {
let value = args let value = args
.get(index + 1) .get(index + 1)
@@ -232,7 +219,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
model, model,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
max_cost_usd,
}); });
} }
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
@@ -248,6 +234,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"system-prompt" => parse_system_prompt_args(&rest[1..]), "system-prompt" => parse_system_prompt_args(&rest[1..]),
"login" => Ok(CliAction::Login), "login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout), "logout" => Ok(CliAction::Logout),
"init" => Ok(CliAction::Init),
"prompt" => { "prompt" => {
let prompt = rest[1..].join(" "); let prompt = rest[1..].join(" ");
if prompt.trim().is_empty() { if prompt.trim().is_empty() {
@@ -259,7 +246,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
max_cost_usd,
}) })
} }
other if !other.starts_with('/') => Ok(CliAction::Prompt { other if !other.starts_with('/') => Ok(CliAction::Prompt {
@@ -268,7 +254,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
max_cost_usd,
}), }),
other => Err(format!("unknown subcommand: {other}")), other => Err(format!("unknown subcommand: {other}")),
} }
@@ -332,18 +317,6 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
.map(permission_mode_from_label) .map(permission_mode_from_label)
} }
fn parse_max_cost_arg(value: &str) -> Result<f64, String> {
let parsed = value
.parse::<f64>()
.map_err(|_| format!("invalid value for --max-cost: {value}"))?;
if !parsed.is_finite() || parsed <= 0.0 {
return Err(format!(
"--max-cost must be a positive finite USD amount: {value}"
));
}
Ok(parsed)
}
fn permission_mode_from_label(mode: &str) -> PermissionMode { fn permission_mode_from_label(mode: &str) -> PermissionMode {
match mode { match mode {
"read-only" => PermissionMode::ReadOnly, "read-only" => PermissionMode::ReadOnly,
@@ -710,78 +683,22 @@ fn format_permissions_switch_report(previous: &str, next: &str) -> String {
) )
} }
fn format_cost_report(model: &str, usage: TokenUsage, max_cost_usd: Option<f64>) -> String { fn format_cost_report(usage: TokenUsage) -> String {
let estimate = usage_cost_estimate(model, usage);
format!( format!(
"Cost "Cost
Model {model}
Input tokens {} Input tokens {}
Output tokens {} Output tokens {}
Cache create {} Cache create {}
Cache read {} Cache read {}
Total tokens {} Total tokens {}",
Input cost {}
Output cost {}
Cache create usd {}
Cache read usd {}
Estimated cost {}
Budget {}",
usage.input_tokens, usage.input_tokens,
usage.output_tokens, usage.output_tokens,
usage.cache_creation_input_tokens, usage.cache_creation_input_tokens,
usage.cache_read_input_tokens, usage.cache_read_input_tokens,
usage.total_tokens(), usage.total_tokens(),
format_usd(estimate.input_cost_usd),
format_usd(estimate.output_cost_usd),
format_usd(estimate.cache_creation_cost_usd),
format_usd(estimate.cache_read_cost_usd),
format_usd(estimate.total_cost_usd()),
format_budget_line(estimate.total_cost_usd(), max_cost_usd),
) )
} }
fn usage_cost_estimate(model: &str, usage: TokenUsage) -> runtime::UsageCostEstimate {
pricing_for_model(model).map_or_else(
|| usage.estimate_cost_usd(),
|pricing| usage.estimate_cost_usd_with_pricing(pricing),
)
}
fn usage_cost_total(model: &str, usage: TokenUsage) -> f64 {
usage_cost_estimate(model, usage).total_cost_usd()
}
fn format_budget_line(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
match max_cost_usd {
Some(limit) => format!("{} / {}", format_usd(cost_usd), format_usd(limit)),
None => format!("{} (unlimited)", format_usd(cost_usd)),
}
}
fn budget_notice_message(
model: &str,
usage: TokenUsage,
max_cost_usd: Option<f64>,
) -> Option<String> {
let limit = max_cost_usd?;
let cost = usage_cost_total(model, usage);
if cost >= limit {
Some(format!(
"cost budget exceeded: cumulative={} budget={}",
format_usd(cost),
format_usd(limit)
))
} else if cost >= limit * COST_WARNING_FRACTION {
Some(format!(
"approaching cost budget: cumulative={} budget={}",
format_usd(cost),
format_usd(limit)
))
} else {
None
}
}
fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
format!( format!(
"Session resumed "Session resumed
@@ -791,26 +708,6 @@ fn format_resume_report(session_path: &str, message_count: usize, turns: u32) ->
) )
} }
fn format_init_report(path: &Path, created: bool) -> String {
if created {
format!(
"Init
CLAUDE.md {}
Result created
Next step Review and tailor the generated guidance",
path.display()
)
} else {
format!(
"Init
CLAUDE.md {}
Result skipped (already exists)
Next step Edit the existing file intentionally if workflows changed",
path.display()
)
}
}
fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String { fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
if skipped { if skipped {
format!( format!(
@@ -925,7 +822,6 @@ fn run_resume_command(
}, },
default_permission_mode().as_str(), default_permission_mode().as_str(),
&status_context(Some(session_path))?, &status_context(Some(session_path))?,
None,
)), )),
}) })
} }
@@ -933,7 +829,7 @@ fn run_resume_command(
let usage = UsageTracker::from_session(session).cumulative_usage(); let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(format_cost_report("restored-session", usage, None)), message: Some(format_cost_report(usage)),
}) })
} }
SlashCommand::Config { section } => Ok(ResumeCommandOutcome { SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
@@ -980,9 +876,8 @@ fn run_repl(
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode, max_cost_usd)?; let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
let mut editor = input::LineEditor::new(" ", slash_command_completion_candidates()); let mut editor = input::LineEditor::new(" ", slash_command_completion_candidates());
println!("{}", cli.startup_banner()); println!("{}", cli.startup_banner());
@@ -1035,7 +930,6 @@ struct LiveCli {
model: String, model: String,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
system_prompt: Vec<String>, system_prompt: Vec<String>,
runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, runtime: ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>,
session: SessionHandle, session: SessionHandle,
@@ -1047,7 +941,6 @@ impl LiveCli {
enable_tools: bool, enable_tools: bool,
allowed_tools: Option<AllowedToolSet>, allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode, permission_mode: PermissionMode,
max_cost_usd: Option<f64>,
) -> 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()?;
@@ -1063,7 +956,6 @@ impl LiveCli {
model, model,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
max_cost_usd,
system_prompt, system_prompt,
runtime, runtime,
session, session,
@@ -1074,10 +966,9 @@ impl LiveCli {
fn startup_banner(&self) -> String { fn startup_banner(&self) -> String {
format!( format!(
"Rusty Claude CLI\n Model {}\n Permission mode {}\n Cost budget {}\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(), self.permission_mode.as_str(),
self.max_cost_usd.map_or_else(|| "none".to_string(), format_usd),
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(),
@@ -1087,7 +978,6 @@ impl LiveCli {
} }
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> { fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
self.enforce_budget_before_turn()?;
let mut spinner = Spinner::new(); let mut spinner = Spinner::new();
let mut stdout = io::stdout(); let mut stdout = io::stdout();
spinner.tick( spinner.tick(
@@ -1098,14 +988,13 @@ impl LiveCli {
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); let result = self.runtime.run_turn(input, Some(&mut permission_prompter));
match result { match result {
Ok(summary) => { Ok(_) => {
spinner.finish( spinner.finish(
"Claude response complete", "Claude response complete",
TerminalRenderer::new().color_theme(), TerminalRenderer::new().color_theme(),
&mut stdout, &mut stdout,
)?; )?;
println!(); println!();
self.print_budget_notice(summary.usage);
self.persist_session()?; self.persist_session()?;
Ok(()) Ok(())
} }
@@ -1132,7 +1021,6 @@ impl LiveCli {
} }
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> { fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
self.enforce_budget_before_turn()?;
let client = AnthropicClient::from_auth(resolve_cli_auth_source()?); let client = AnthropicClient::from_auth(resolve_cli_auth_source()?);
let request = MessageRequest { let request = MessageRequest {
model: self.model.clone(), model: self.model.clone(),
@@ -1159,27 +1047,17 @@ impl LiveCli {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(""); .join("");
let usage = TokenUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
cache_read_input_tokens: response.usage.cache_read_input_tokens,
};
println!( println!(
"{}", "{}",
json!({ json!({
"message": text, "message": text,
"model": self.model, "model": self.model,
"usage": { "usage": {
"input_tokens": usage.input_tokens, "input_tokens": response.usage.input_tokens,
"output_tokens": usage.output_tokens, "output_tokens": response.usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens, "cache_creation_input_tokens": response.usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens, "cache_read_input_tokens": response.usage.cache_read_input_tokens,
}, }
"cost_usd": usage_cost_total(&self.model, usage),
"cumulative_cost_usd": usage_cost_total(&self.model, usage),
"max_cost_usd": self.max_cost_usd,
"budget_warning": budget_notice_message(&self.model, usage, self.max_cost_usd),
}) })
); );
Ok(()) Ok(())
@@ -1219,7 +1097,7 @@ impl LiveCli {
false false
} }
SlashCommand::Init => { SlashCommand::Init => {
Self::run_init()?; run_init()?;
false false
} }
SlashCommand::Diff => { SlashCommand::Diff => {
@@ -1249,28 +1127,6 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn enforce_budget_before_turn(&self) -> Result<(), Box<dyn std::error::Error>> {
let Some(limit) = self.max_cost_usd else {
return Ok(());
};
let cost = usage_cost_total(&self.model, self.runtime.usage().cumulative_usage());
if cost >= limit {
return Err(format!(
"cost budget exceeded before starting turn: cumulative={} budget={}",
format_usd(cost),
format_usd(limit)
)
.into());
}
Ok(())
}
fn print_budget_notice(&self, usage: TokenUsage) {
if let Some(message) = budget_notice_message(&self.model, usage, self.max_cost_usd) {
eprintln!("warning: {message}");
}
}
fn print_status(&self) { fn print_status(&self) {
let cumulative = self.runtime.usage().cumulative_usage(); let cumulative = self.runtime.usage().cumulative_usage();
let latest = self.runtime.usage().current_turn_usage(); let latest = self.runtime.usage().current_turn_usage();
@@ -1287,7 +1143,6 @@ impl LiveCli {
}, },
self.permission_mode.as_str(), 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"),
self.max_cost_usd,
) )
); );
} }
@@ -1405,10 +1260,7 @@ impl LiveCli {
fn print_cost(&self) { fn print_cost(&self) {
let cumulative = self.runtime.usage().cumulative_usage(); let cumulative = self.runtime.usage().cumulative_usage();
println!( println!("{}", format_cost_report(cumulative));
"{}",
format_cost_report(&self.model, cumulative, self.max_cost_usd)
);
} }
fn resume_session( fn resume_session(
@@ -1453,11 +1305,6 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn run_init() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", init_claude_md()?);
Ok(())
}
fn print_diff() -> Result<(), Box<dyn std::error::Error>> { fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_diff_report()?); println!("{}", render_diff_report()?);
Ok(()) Ok(())
@@ -1686,10 +1533,7 @@ fn format_status_report(
usage: StatusUsage, usage: StatusUsage,
permission_mode: &str, permission_mode: &str,
context: &StatusContext, context: &StatusContext,
max_cost_usd: Option<f64>,
) -> String { ) -> String {
let latest_cost = usage_cost_total(model, usage.latest);
let cumulative_cost = usage_cost_total(model, usage.cumulative);
[ [
format!( format!(
"Status "Status
@@ -1697,27 +1541,19 @@ fn format_status_report(
Permission mode {permission_mode} Permission mode {permission_mode}
Messages {} Messages {}
Turns {} Turns {}
Estimated tokens {} Estimated tokens {}",
Cost budget {}", usage.message_count, usage.turns, usage.estimated_tokens,
usage.message_count,
usage.turns,
usage.estimated_tokens,
format_budget_line(cumulative_cost, max_cost_usd),
), ),
format!( format!(
"Usage "Usage
Latest total {} Latest total {}
Latest cost {}
Cumulative input {} Cumulative input {}
Cumulative output {} Cumulative output {}
Cumulative total {} Cumulative total {}",
Cumulative cost {}",
usage.latest.total_tokens(), usage.latest.total_tokens(),
format_usd(latest_cost),
usage.cumulative.input_tokens, usage.cumulative.input_tokens,
usage.cumulative.output_tokens, usage.cumulative.output_tokens,
usage.cumulative.total_tokens(), usage.cumulative.total_tokens(),
format_usd(cumulative_cost),
), ),
format!( format!(
"Workspace "Workspace
@@ -1866,67 +1702,12 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> { fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let claude_md = cwd.join("CLAUDE.md"); Ok(initialize_repo(&cwd)?.render())
if claude_md.exists() {
return Ok(format_init_report(&claude_md, false));
}
let content = render_init_claude_md(&cwd);
fs::write(&claude_md, content)?;
Ok(format_init_report(&claude_md, true))
} }
fn render_init_claude_md(cwd: &Path) -> String { fn run_init() -> Result<(), Box<dyn std::error::Error>> {
let mut lines = vec![ println!("{}", init_claude_md()?);
"# CLAUDE.md".to_string(), Ok(())
String::new(),
"This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
String::new(),
];
let mut command_lines = Vec::new();
if cwd.join("rust").join("Cargo.toml").is_file() {
command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
} else if cwd.join("Cargo.toml").is_file() {
command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
}
if cwd.join("tests").is_dir() && cwd.join("src").is_dir() {
command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string());
}
if !command_lines.is_empty() {
lines.push("## Verification".to_string());
lines.extend(command_lines);
lines.push(String::new());
}
let mut structure_lines = Vec::new();
if cwd.join("rust").is_dir() {
structure_lines.push(
"- `rust/` contains the Rust workspace and the active CLI/runtime implementation."
.to_string(),
);
}
if cwd.join("src").is_dir() {
structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string());
}
if cwd.join("tests").is_dir() {
structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string());
}
if !structure_lines.is_empty() {
lines.push("## Repository shape".to_string());
lines.extend(structure_lines);
lines.push(String::new());
}
lines.push("## Working agreement".to_string());
lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string());
lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string());
lines.push(String::new());
lines.join(
"
",
)
} }
fn normalize_permission_mode(mode: &str) -> Option<&'static str> { fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
@@ -2485,35 +2266,65 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
.collect() .collect()
} }
fn print_help() { fn print_help_to(out: &mut impl Write) -> io::Result<()> {
println!("rusty-claude-cli v{VERSION}"); writeln!(out, "rusty-claude-cli v{VERSION}")?;
println!(); writeln!(out)?;
println!("Usage:"); writeln!(out, "Usage:")?;
println!(" rusty-claude-cli [--model MODEL] [--max-cost USD] [--allowedTools TOOL[,TOOL...]]"); writeln!(
println!(" Start the interactive REPL"); out,
println!(" rusty-claude-cli [--model MODEL] [--max-cost USD] [--output-format text|json] prompt TEXT"); " rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]"
println!(" Send one prompt and exit"); )?;
println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT"); writeln!(out, " Start the interactive REPL")?;
println!(" Shorthand non-interactive prompt mode"); writeln!(
println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]"); out,
println!(" Inspect or maintain a saved session without entering the REPL"); " rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT"
println!(" rusty-claude-cli dump-manifests"); )?;
println!(" rusty-claude-cli bootstrap-plan"); writeln!(out, " Send one prompt and exit")?;
println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"); writeln!(
println!(" rusty-claude-cli login"); out,
println!(" rusty-claude-cli logout"); " rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT"
println!(); )?;
println!("Flags:"); writeln!(out, " Shorthand non-interactive prompt mode")?;
println!(" --model MODEL Override the active model"); writeln!(
println!(" --output-format FORMAT Non-interactive output format: text or json"); out,
println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"); " rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]"
println!(" --max-cost USD Warn at 80% of budget and stop at/exceeding the budget"); )?;
println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"); writeln!(
println!(" --version, -V Print version and build information locally"); out,
println!(); " Inspect or maintain a saved session without entering the REPL"
println!("Interactive slash commands:"); )?;
println!("{}", render_slash_command_help()); writeln!(out, " rusty-claude-cli dump-manifests")?;
println!(); writeln!(out, " rusty-claude-cli bootstrap-plan")?;
writeln!(
out,
" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"
)?;
writeln!(out, " rusty-claude-cli login")?;
writeln!(out, " rusty-claude-cli logout")?;
writeln!(out, " rusty-claude-cli init")?;
writeln!(out)?;
writeln!(out, "Flags:")?;
writeln!(
out,
" --model MODEL Override the active model"
)?;
writeln!(
out,
" --output-format FORMAT Non-interactive output format: text or json"
)?;
writeln!(
out,
" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"
)?;
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
writeln!(
out,
" --version, -V Print version and build information locally"
)?;
writeln!(out)?;
writeln!(out, "Interactive slash commands:")?;
writeln!(out, "{}", render_slash_command_help())?;
writeln!(out)?;
let resume_commands = resume_supported_slash_commands() let resume_commands = resume_supported_slash_commands()
.into_iter() .into_iter()
.map(|spec| match spec.argument_hint { .map(|spec| match spec.argument_hint {
@@ -2522,29 +2333,46 @@ fn print_help() {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
println!("Resume-safe commands: {resume_commands}"); writeln!(out, "Resume-safe commands: {resume_commands}")?;
println!("Examples:"); writeln!(out, "Examples:")?;
println!(" rusty-claude-cli --model claude-opus \"summarize this repo\""); writeln!(
println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\""); out,
println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\""); " rusty-claude-cli --model claude-opus \"summarize this repo\""
println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt"); )?;
println!(" rusty-claude-cli login"); writeln!(
out,
" rusty-claude-cli --output-format json prompt \"explain src/main.rs\""
)?;
writeln!(
out,
" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\""
)?;
writeln!(
out,
" rusty-claude-cli --resume session.json /status /diff /export notes.txt"
)?;
writeln!(out, " rusty-claude-cli login")?;
writeln!(out, " rusty-claude-cli init")?;
Ok(())
}
fn print_help() {
let _ = print_help_to(&mut io::stdout());
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
budget_notice_message, filter_tool_specs, format_compact_report, format_cost_report, filter_tool_specs, format_compact_report, format_cost_report, format_model_report,
format_init_report, format_model_report, format_model_switch_report, format_model_switch_report, format_permissions_report, format_permissions_switch_report,
format_permissions_report, format_permissions_switch_report, format_resume_report, format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
format_status_report, format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, parse_git_status_metadata, print_help_to,
normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, render_config_report, render_memory_report, render_repl_help,
render_init_claude_md, render_memory_report, render_repl_help,
resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand,
StatusUsage, DEFAULT_MODEL, StatusUsage, DEFAULT_MODEL,
}; };
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::{Path, PathBuf}; use std::path::PathBuf;
#[test] #[test]
fn defaults_to_repl_when_no_args() { fn defaults_to_repl_when_no_args() {
@@ -2554,7 +2382,6 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
} }
); );
} }
@@ -2574,7 +2401,6 @@ mod tests {
output_format: CliOutputFormat::Text, output_format: CliOutputFormat::Text,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
} }
); );
} }
@@ -2596,7 +2422,6 @@ mod tests {
output_format: CliOutputFormat::Json, output_format: CliOutputFormat::Json,
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
} }
); );
} }
@@ -2622,32 +2447,10 @@ mod tests {
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
allowed_tools: None, allowed_tools: None,
permission_mode: PermissionMode::ReadOnly, permission_mode: PermissionMode::ReadOnly,
max_cost_usd: None,
} }
); );
} }
#[test]
fn parses_max_cost_flag() {
let args = vec!["--max-cost=1.25".to_string()];
assert_eq!(
parse_args(&args).expect("args should parse"),
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: Some(1.25),
}
);
}
#[test]
fn rejects_invalid_max_cost_flag() {
let error = parse_args(&["--max-cost".to_string(), "0".to_string()])
.expect_err("zero max cost should be rejected");
assert!(error.contains("--max-cost must be a positive finite USD amount"));
}
#[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![
@@ -2666,7 +2469,6 @@ mod tests {
.collect() .collect()
), ),
permission_mode: PermissionMode::WorkspaceWrite, permission_mode: PermissionMode::WorkspaceWrite,
max_cost_usd: None,
} }
); );
} }
@@ -2706,6 +2508,10 @@ mod tests {
parse_args(&["logout".to_string()]).expect("logout should parse"), parse_args(&["logout".to_string()]).expect("logout should parse"),
CliAction::Logout CliAction::Logout
); );
assert_eq!(
parse_args(&["init".to_string()]).expect("init should parse"),
CliAction::Init
);
} }
#[test] #[test]
@@ -2824,24 +2630,18 @@ mod tests {
#[test] #[test]
fn cost_report_uses_sectioned_layout() { fn cost_report_uses_sectioned_layout() {
let report = format_cost_report( let report = format_cost_report(runtime::TokenUsage {
"claude-sonnet", input_tokens: 20,
runtime::TokenUsage { output_tokens: 8,
input_tokens: 20, cache_creation_input_tokens: 3,
output_tokens: 8, cache_read_input_tokens: 1,
cache_creation_input_tokens: 3, });
cache_read_input_tokens: 1,
},
None,
);
assert!(report.contains("Cost")); assert!(report.contains("Cost"));
assert!(report.contains("Input tokens 20")); assert!(report.contains("Input tokens 20"));
assert!(report.contains("Output tokens 8")); assert!(report.contains("Output tokens 8"));
assert!(report.contains("Cache create 3")); assert!(report.contains("Cache create 3"));
assert!(report.contains("Cache read 1")); assert!(report.contains("Cache read 1"));
assert!(report.contains("Total tokens 32")); assert!(report.contains("Total tokens 32"));
assert!(report.contains("Estimated cost"));
assert!(report.contains("Budget $0.0010 (unlimited)"));
} }
#[test] #[test]
@@ -2866,12 +2666,11 @@ mod tests {
} }
#[test] #[test]
fn init_report_uses_structured_output() { fn init_help_mentions_direct_subcommand() {
let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true); let mut help = Vec::new();
assert!(created.contains("Init")); print_help_to(&mut help).expect("help should render");
assert!(created.contains("Result created")); let help = String::from_utf8(help).expect("help should be utf8");
let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false); assert!(help.contains("rusty-claude-cli init"));
assert!(skipped.contains("skipped (already exists)"));
} }
#[test] #[test]
@@ -2923,7 +2722,6 @@ mod tests {
project_root: Some(PathBuf::from("/tmp")), project_root: Some(PathBuf::from("/tmp")),
git_branch: Some("main".to_string()), git_branch: Some("main".to_string()),
}, },
Some(1.0),
); );
assert!(status.contains("Status")); assert!(status.contains("Status"));
assert!(status.contains("Model claude-sonnet")); assert!(status.contains("Model claude-sonnet"));
@@ -2931,7 +2729,6 @@ mod tests {
assert!(status.contains("Messages 7")); assert!(status.contains("Messages 7"));
assert!(status.contains("Latest total 10")); assert!(status.contains("Latest total 10"));
assert!(status.contains("Cumulative total 31")); assert!(status.contains("Cumulative total 31"));
assert!(status.contains("Cost budget $0.0009 / $1.0000"));
assert!(status.contains("Cwd /tmp/project")); assert!(status.contains("Cwd /tmp/project"));
assert!(status.contains("Project root /tmp")); assert!(status.contains("Project root /tmp"));
assert!(status.contains("Git branch main")); assert!(status.contains("Git branch main"));
@@ -2940,22 +2737,6 @@ mod tests {
assert!(status.contains("Memory files 4")); assert!(status.contains("Memory files 4"));
} }
#[test]
fn budget_notice_warns_near_limit() {
let message = budget_notice_message(
"claude-sonnet",
runtime::TokenUsage {
input_tokens: 60_000,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
Some(1.0),
)
.expect("budget warning expected");
assert!(message.contains("approaching cost budget"));
}
#[test] #[test]
fn config_report_supports_section_views() { fn config_report_supports_section_views() {
let report = render_config_report(Some("env")).expect("config report should render"); let report = render_config_report(Some("env")).expect("config report should render");
@@ -2993,8 +2774,8 @@ 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!(context.discovered_config_files >= context.loaded_config_files); assert_eq!(context.discovered_config_files, 5);
assert!(context.discovered_config_files >= 1); assert!(context.loaded_config_files <= context.discovered_config_files);
} }
#[test] #[test]
@@ -3051,7 +2832,7 @@ mod tests {
#[test] #[test]
fn init_template_mentions_detected_rust_workspace() { fn init_template_mentions_detected_rust_workspace() {
let rendered = render_init_claude_md(Path::new(".")); let rendered = crate::init::render_init_claude_md(std::path::Path::new("."));
assert!(rendered.contains("# CLAUDE.md")); assert!(rendered.contains("# CLAUDE.md"));
assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings")); assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings"));
} }