diff --git a/rust/README.md b/rust/README.md index f5fb366..1c7f794 100644 --- a/rust/README.md +++ b/rust/README.md @@ -109,6 +109,13 @@ cd rust cargo run -p rusty-claude-cli -- --allowedTools read,glob ``` +Bootstrap Claude project files for the current repo: + +```bash +cd rust +cargo run -p rusty-claude-cli -- init +``` + ### 2) REPL mode Start the interactive shell: @@ -179,7 +186,7 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config - `/resume ` — load a saved session into the REPL - `/config [env|hooks|model]` — inspect discovered Claude config - `/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 - `/version` — print version and build metadata locally - `/export [file]` — export the current conversation transcript diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index b396bb0..ba3e571 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -101,7 +101,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ }, SlashCommandSpec { name: "init", - summary: "Create a starter CLAUDE.md for this repo", + summary: "Bootstrap Claude project files for this repo", argument_hint: None, resume_supported: true, }, diff --git a/rust/crates/rusty-claude-cli/src/init.rs b/rust/crates/rusty-claude-cli/src/init.rs new file mode 100644 index 0000000..4847c0a --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/init.rs @@ -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, +} + +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> { + 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 { + 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 { + if path.exists() { + return Ok(InitStatus::Skipped); + } + fs::write(path, content)?; + Ok(InitStatus::Created) +} + +fn ensure_gitignore_entries(path: &Path) -> Result { + 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::>(); + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 47ecd98..dd08aee 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1,3 +1,4 @@ +mod init; mod input; mod render; @@ -20,6 +21,7 @@ use commands::{ render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; +use init::initialize_repo; use render::{Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, @@ -74,6 +76,7 @@ fn run() -> Result<(), Box> { .run_turn_with_output(&prompt, output_format)?, CliAction::Login => run_login()?, CliAction::Logout => run_logout()?, + CliAction::Init => run_init()?, CliAction::Repl { model, allowed_tools, @@ -106,6 +109,7 @@ enum CliAction { }, Login, Logout, + Init, Repl { model: String, allowed_tools: Option, @@ -230,6 +234,7 @@ fn parse_args(args: &[String]) -> Result { "system-prompt" => parse_system_prompt_args(&rest[1..]), "login" => Ok(CliAction::Login), "logout" => Ok(CliAction::Logout), + "init" => Ok(CliAction::Init), "prompt" => { let prompt = rest[1..].join(" "); if prompt.trim().is_empty() { @@ -703,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 { if skipped { format!( @@ -1112,7 +1097,7 @@ impl LiveCli { false } SlashCommand::Init => { - Self::run_init()?; + run_init()?; false } SlashCommand::Diff => { @@ -1320,11 +1305,6 @@ impl LiveCli { Ok(()) } - fn run_init() -> Result<(), Box> { - println!("{}", init_claude_md()?); - Ok(()) - } - fn print_diff() -> Result<(), Box> { println!("{}", render_diff_report()?); Ok(()) @@ -1722,67 +1702,12 @@ fn render_memory_report() -> Result> { fn init_claude_md() -> Result> { let cwd = env::current_dir()?; - let claude_md = cwd.join("CLAUDE.md"); - if claude_md.exists() { - return Ok(format_init_report(&claude_md, false)); - } - - let content = render_init_claude_md(&cwd); - fs::write(&claude_md, content)?; - Ok(format_init_report(&claude_md, true)) + Ok(initialize_repo(&cwd)?.render()) } -fn render_init_claude_md(cwd: &Path) -> String { - let mut lines = vec![ - "# CLAUDE.md".to_string(), - String::new(), - "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(), - String::new(), - ]; - - let mut command_lines = Vec::new(); - if cwd.join("rust").join("Cargo.toml").is_file() { - command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string()); - } else if cwd.join("Cargo.toml").is_file() { - command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string()); - } - if cwd.join("tests").is_dir() && cwd.join("src").is_dir() { - command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string()); - } - if !command_lines.is_empty() { - lines.push("## Verification".to_string()); - lines.extend(command_lines); - lines.push(String::new()); - } - - let mut structure_lines = Vec::new(); - if cwd.join("rust").is_dir() { - structure_lines.push( - "- `rust/` contains the Rust workspace and the active CLI/runtime implementation." - .to_string(), - ); - } - if cwd.join("src").is_dir() { - structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string()); - } - if cwd.join("tests").is_dir() { - structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string()); - } - if !structure_lines.is_empty() { - lines.push("## Repository shape".to_string()); - lines.extend(structure_lines); - lines.push(String::new()); - } - - lines.push("## Working agreement".to_string()); - lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string()); - lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string()); - lines.push(String::new()); - - lines.join( - " -", - ) +fn run_init() -> Result<(), Box> { + println!("{}", init_claude_md()?); + Ok(()) } fn normalize_permission_mode(mode: &str) -> Option<&'static str> { @@ -2341,34 +2266,65 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { .collect() } -fn print_help() { - println!("rusty-claude-cli v{VERSION}"); - println!(); - println!("Usage:"); - println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]"); - println!(" Start the interactive REPL"); - println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT"); - println!(" Send one prompt and exit"); - println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT"); - println!(" Shorthand non-interactive prompt mode"); - println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]"); - println!(" Inspect or maintain a saved session without entering the REPL"); - println!(" rusty-claude-cli dump-manifests"); - println!(" rusty-claude-cli bootstrap-plan"); - println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"); - println!(" rusty-claude-cli login"); - println!(" rusty-claude-cli logout"); - println!(); - println!("Flags:"); - println!(" --model MODEL Override the active model"); - println!(" --output-format FORMAT Non-interactive output format: text or json"); - println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"); - println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"); - println!(" --version, -V Print version and build information locally"); - println!(); - println!("Interactive slash commands:"); - println!("{}", render_slash_command_help()); - println!(); +fn print_help_to(out: &mut impl Write) -> io::Result<()> { + writeln!(out, "rusty-claude-cli v{VERSION}")?; + writeln!(out)?; + writeln!(out, "Usage:")?; + writeln!( + out, + " rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]" + )?; + writeln!(out, " Start the interactive REPL")?; + writeln!( + out, + " rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT" + )?; + writeln!(out, " Send one prompt and exit")?; + writeln!( + out, + " rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT" + )?; + writeln!(out, " Shorthand non-interactive prompt mode")?; + writeln!( + out, + " rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]" + )?; + writeln!( + out, + " Inspect or maintain a saved session without entering the REPL" + )?; + writeln!(out, " rusty-claude-cli dump-manifests")?; + 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() .into_iter() .map(|spec| match spec.argument_hint { @@ -2377,28 +2333,46 @@ fn print_help() { }) .collect::>() .join(", "); - println!("Resume-safe commands: {resume_commands}"); - println!("Examples:"); - println!(" rusty-claude-cli --model claude-opus \"summarize this repo\""); - println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\""); - println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\""); - println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt"); - println!(" rusty-claude-cli login"); + writeln!(out, "Resume-safe commands: {resume_commands}")?; + writeln!(out, "Examples:")?; + writeln!( + out, + " rusty-claude-cli --model claude-opus \"summarize this repo\"" + )?; + 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)] mod tests { use super::{ - filter_tool_specs, format_compact_report, format_cost_report, format_init_report, - format_model_report, format_model_switch_report, format_permissions_report, - format_permissions_switch_report, format_resume_report, format_status_report, - format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, - parse_git_status_metadata, render_config_report, render_init_claude_md, - render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, - CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, + filter_tool_specs, format_compact_report, format_cost_report, format_model_report, + format_model_switch_report, format_permissions_report, format_permissions_switch_report, + format_resume_report, format_status_report, format_tool_call_start, format_tool_result, + normalize_permission_mode, parse_args, parse_git_status_metadata, print_help_to, + render_config_report, render_memory_report, render_repl_help, + resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, + StatusUsage, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; - use std::path::{Path, PathBuf}; + use std::path::PathBuf; #[test] fn defaults_to_repl_when_no_args() { @@ -2534,6 +2508,10 @@ mod tests { parse_args(&["logout".to_string()]).expect("logout should parse"), CliAction::Logout ); + assert_eq!( + parse_args(&["init".to_string()]).expect("init should parse"), + CliAction::Init + ); } #[test] @@ -2688,12 +2666,11 @@ mod tests { } #[test] - fn init_report_uses_structured_output() { - let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true); - assert!(created.contains("Init")); - assert!(created.contains("Result created")); - let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false); - assert!(skipped.contains("skipped (already exists)")); + fn init_help_mentions_direct_subcommand() { + let mut help = Vec::new(); + print_help_to(&mut help).expect("help should render"); + let help = String::from_utf8(help).expect("help should be utf8"); + assert!(help.contains("rusty-claude-cli init")); } #[test] @@ -2797,7 +2774,7 @@ mod tests { fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); assert!(context.cwd.is_absolute()); - assert_eq!(context.discovered_config_files, 3); + assert_eq!(context.discovered_config_files, 5); assert!(context.loaded_config_files <= context.discovered_config_files); } @@ -2855,7 +2832,7 @@ mod tests { #[test] 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("cargo clippy --workspace --all-targets -- -D warnings")); }