From 188c35f8a669a9a1db2426e2b76a1055e0be66ba Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:57:38 +0000 Subject: [PATCH] feat(cli): add safe claude-md init command Add a genuinely useful /init command that creates a starter CLAUDE.md from the current repository shape without inventing unsupported setup flows. The scaffold pulls in real verification commands and repo-structure notes for this workspace, and it refuses to overwrite an existing CLAUDE.md. This keeps the command honest and low-risk while moving the CLI closer to Claude Code's practical bootstrap surface. Constraint: /init must be non-destructive and must not overwrite an existing CLAUDE.md Constraint: Generated guidance must come from observable repo structure rather than placeholder text Rejected: Interactive multi-step init workflow | too much unsupported UI/state machinery for this Rust CLI slice Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep generated CLAUDE.md templates concise and repo-derived; do not let /init drift into fake setup promises Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: manual /init invocation in a separate temporary repository without a preexisting CLAUDE.md --- rust/crates/commands/src/lib.rs | 12 +++- rust/crates/rusty-claude-cli/src/main.rs | 91 +++++++++++++++++++++++- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 2d3c264..8a2e2eb 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -88,6 +88,11 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ summary: "Inspect loaded Claude instruction memory files", argument_hint: None, }, + SlashCommandSpec { + name: "init", + summary: "Create a starter CLAUDE.md for this repo", + argument_hint: None, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,6 +107,7 @@ pub enum SlashCommand { Resume { session_path: Option }, Config, Memory, + Init, Unknown(String), } @@ -132,6 +138,7 @@ impl SlashCommand { }, "config" => Self::Config, "memory" => Self::Memory, + "init" => Self::Init, other => Self::Unknown(other.to_string()), }) } @@ -195,6 +202,7 @@ pub fn handle_slash_command( | SlashCommand::Resume { .. } | SlashCommand::Config | SlashCommand::Memory + | SlashCommand::Init | SlashCommand::Unknown(_) => None, } } @@ -236,6 +244,7 @@ mod tests { ); assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); + assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); } #[test] @@ -251,7 +260,8 @@ mod tests { assert!(help.contains("/resume ")); assert!(help.contains("/config")); assert!(help.contains("/memory")); - assert_eq!(slash_command_specs().len(), 10); + assert!(help.contains("/init")); + assert_eq!(slash_command_specs().len(), 11); } #[test] diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 3ba1a32..3d71743 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2,6 +2,7 @@ mod input; mod render; use std::env; +use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -282,6 +283,7 @@ fn run_resume_command( | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Clear + | SlashCommand::Init | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -377,6 +379,7 @@ impl LiveCli { SlashCommand::Resume { session_path } => self.resume_session(session_path)?, SlashCommand::Config => Self::print_config()?, SlashCommand::Memory => Self::print_memory()?, + SlashCommand::Init => Self::run_init()?, SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), } Ok(()) @@ -502,6 +505,11 @@ impl LiveCli { Ok(()) } + fn run_init() -> Result<(), Box> { + println!("{}", init_claude_md()?); + Ok(()) + } + fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; @@ -614,6 +622,74 @@ 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: skipped because {} already exists", + claude_md.display() + )); + } + + let content = render_init_claude_md(&cwd); + fs::write(&claude_md, content)?; + Ok(format!("init: created {}", claude_md.display())) +} + +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 normalize_permission_mode(mode: &str) -> Option<&'static str> { match mode.trim() { "read-only" => Some("read-only"), @@ -951,11 +1027,11 @@ fn print_help() { #[cfg(test)] mod tests { use super::{ - format_status_line, normalize_permission_mode, parse_args, render_repl_help, CliAction, - SlashCommand, DEFAULT_MODEL, + format_status_line, normalize_permission_mode, parse_args, render_init_claude_md, + render_repl_help, CliAction, SlashCommand, DEFAULT_MODEL, }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; #[test] fn defaults_to_repl_when_no_args() { @@ -1029,6 +1105,7 @@ mod tests { assert!(help.contains("/resume ")); assert!(help.contains("/config")); assert!(help.contains("/memory")); + assert!(help.contains("/init")); assert!(help.contains("/exit")); } @@ -1084,6 +1161,14 @@ mod tests { ); assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); + assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); + } + + #[test] + fn init_template_mentions_detected_rust_workspace() { + let rendered = render_init_claude_md(Path::new(".")); + assert!(rendered.contains("# CLAUDE.md")); + assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings")); } #[test]