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
331 lines
10 KiB
Rust
331 lines
10 KiB
Rust
use runtime::{compact_session, CompactionConfig, Session};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CommandManifestEntry {
|
|
pub name: String,
|
|
pub source: CommandSource,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CommandSource {
|
|
Builtin,
|
|
InternalOnly,
|
|
FeatureGated,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct CommandRegistry {
|
|
entries: Vec<CommandManifestEntry>,
|
|
}
|
|
|
|
impl CommandRegistry {
|
|
#[must_use]
|
|
pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
|
|
Self { entries }
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn entries(&self) -> &[CommandManifestEntry] {
|
|
&self.entries
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct SlashCommandSpec {
|
|
pub name: &'static str,
|
|
pub summary: &'static str,
|
|
pub argument_hint: Option<&'static str>,
|
|
}
|
|
|
|
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|
SlashCommandSpec {
|
|
name: "help",
|
|
summary: "Show available slash commands",
|
|
argument_hint: None,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "status",
|
|
summary: "Show current session status",
|
|
argument_hint: None,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "compact",
|
|
summary: "Compact local session history",
|
|
argument_hint: None,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "model",
|
|
summary: "Show or switch the active model",
|
|
argument_hint: Some("[model]"),
|
|
},
|
|
SlashCommandSpec {
|
|
name: "permissions",
|
|
summary: "Show or switch the active permission mode",
|
|
argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
|
|
},
|
|
SlashCommandSpec {
|
|
name: "clear",
|
|
summary: "Start a fresh local session",
|
|
argument_hint: None,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "cost",
|
|
summary: "Show cumulative token usage for this session",
|
|
argument_hint: None,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "resume",
|
|
summary: "Load a saved session into the REPL",
|
|
argument_hint: Some("<session-path>"),
|
|
},
|
|
SlashCommandSpec {
|
|
name: "config",
|
|
summary: "Inspect discovered Claude config files",
|
|
argument_hint: None,
|
|
},
|
|
SlashCommandSpec {
|
|
name: "memory",
|
|
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)]
|
|
pub enum SlashCommand {
|
|
Help,
|
|
Status,
|
|
Compact,
|
|
Model { model: Option<String> },
|
|
Permissions { mode: Option<String> },
|
|
Clear,
|
|
Cost,
|
|
Resume { session_path: Option<String> },
|
|
Config,
|
|
Memory,
|
|
Init,
|
|
Unknown(String),
|
|
}
|
|
|
|
impl SlashCommand {
|
|
#[must_use]
|
|
pub fn parse(input: &str) -> Option<Self> {
|
|
let trimmed = input.trim();
|
|
if !trimmed.starts_with('/') {
|
|
return None;
|
|
}
|
|
|
|
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
|
|
let command = parts.next().unwrap_or_default();
|
|
Some(match command {
|
|
"help" => Self::Help,
|
|
"status" => Self::Status,
|
|
"compact" => Self::Compact,
|
|
"model" => Self::Model {
|
|
model: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"permissions" => Self::Permissions {
|
|
mode: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"clear" => Self::Clear,
|
|
"cost" => Self::Cost,
|
|
"resume" => Self::Resume {
|
|
session_path: parts.next().map(ToOwned::to_owned),
|
|
},
|
|
"config" => Self::Config,
|
|
"memory" => Self::Memory,
|
|
"init" => Self::Init,
|
|
other => Self::Unknown(other.to_string()),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
|
|
SLASH_COMMAND_SPECS
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn render_slash_command_help() -> String {
|
|
let mut lines = vec!["Available commands:".to_string()];
|
|
for spec in slash_command_specs() {
|
|
let name = match spec.argument_hint {
|
|
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
|
None => format!("/{}", spec.name),
|
|
};
|
|
lines.push(format!(" {name:<20} {}", spec.summary));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SlashCommandResult {
|
|
pub message: String,
|
|
pub session: Session,
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn handle_slash_command(
|
|
input: &str,
|
|
session: &Session,
|
|
compaction: CompactionConfig,
|
|
) -> Option<SlashCommandResult> {
|
|
match SlashCommand::parse(input)? {
|
|
SlashCommand::Compact => {
|
|
let result = compact_session(session, compaction);
|
|
let message = if result.removed_message_count == 0 {
|
|
"Compaction skipped: session is below the compaction threshold.".to_string()
|
|
} else {
|
|
format!(
|
|
"Compacted {} messages into a resumable system summary.",
|
|
result.removed_message_count
|
|
)
|
|
};
|
|
Some(SlashCommandResult {
|
|
message,
|
|
session: result.compacted_session,
|
|
})
|
|
}
|
|
SlashCommand::Help => Some(SlashCommandResult {
|
|
message: render_slash_command_help(),
|
|
session: session.clone(),
|
|
}),
|
|
SlashCommand::Status
|
|
| SlashCommand::Model { .. }
|
|
| SlashCommand::Permissions { .. }
|
|
| SlashCommand::Clear
|
|
| SlashCommand::Cost
|
|
| SlashCommand::Resume { .. }
|
|
| SlashCommand::Config
|
|
| SlashCommand::Memory
|
|
| SlashCommand::Init
|
|
| SlashCommand::Unknown(_) => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
handle_slash_command, render_slash_command_help, slash_command_specs, SlashCommand,
|
|
};
|
|
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
|
|
|
#[test]
|
|
fn parses_supported_slash_commands() {
|
|
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
|
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
|
|
assert_eq!(
|
|
SlashCommand::parse("/model claude-opus"),
|
|
Some(SlashCommand::Model {
|
|
model: Some("claude-opus".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/model"),
|
|
Some(SlashCommand::Model { model: None })
|
|
);
|
|
assert_eq!(
|
|
SlashCommand::parse("/permissions read-only"),
|
|
Some(SlashCommand::Permissions {
|
|
mode: Some("read-only".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(SlashCommand::parse("/clear"), Some(SlashCommand::Clear));
|
|
assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
|
|
assert_eq!(
|
|
SlashCommand::parse("/resume session.json"),
|
|
Some(SlashCommand::Resume {
|
|
session_path: Some("session.json".to_string()),
|
|
})
|
|
);
|
|
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
|
|
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
|
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
|
}
|
|
|
|
#[test]
|
|
fn renders_help_from_shared_specs() {
|
|
let help = render_slash_command_help();
|
|
assert!(help.contains("/help"));
|
|
assert!(help.contains("/status"));
|
|
assert!(help.contains("/compact"));
|
|
assert!(help.contains("/model [model]"));
|
|
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
|
assert!(help.contains("/clear"));
|
|
assert!(help.contains("/cost"));
|
|
assert!(help.contains("/resume <session-path>"));
|
|
assert!(help.contains("/config"));
|
|
assert!(help.contains("/memory"));
|
|
assert!(help.contains("/init"));
|
|
assert_eq!(slash_command_specs().len(), 11);
|
|
}
|
|
|
|
#[test]
|
|
fn compacts_sessions_via_slash_command() {
|
|
let session = Session {
|
|
version: 1,
|
|
messages: vec![
|
|
ConversationMessage::user_text("a ".repeat(200)),
|
|
ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "b ".repeat(200),
|
|
}]),
|
|
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
|
|
ConversationMessage::assistant(vec![ContentBlock::Text {
|
|
text: "recent".to_string(),
|
|
}]),
|
|
],
|
|
};
|
|
|
|
let result = handle_slash_command(
|
|
"/compact",
|
|
&session,
|
|
CompactionConfig {
|
|
preserve_recent_messages: 2,
|
|
max_estimated_tokens: 1,
|
|
},
|
|
)
|
|
.expect("slash command should be handled");
|
|
|
|
assert!(result.message.contains("Compacted 2 messages"));
|
|
assert_eq!(result.session.messages[0].role, MessageRole::System);
|
|
}
|
|
|
|
#[test]
|
|
fn help_command_is_non_mutating() {
|
|
let session = Session::new();
|
|
let result = handle_slash_command("/help", &session, CompactionConfig::default())
|
|
.expect("help command should be handled");
|
|
assert_eq!(result.session, session);
|
|
assert!(result.message.contains("Available commands:"));
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
|
let session = Session::new();
|
|
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
|
assert!(
|
|
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
|
|
);
|
|
assert!(handle_slash_command(
|
|
"/permissions read-only",
|
|
&session,
|
|
CompactionConfig::default()
|
|
)
|
|
.is_none());
|
|
assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
|
|
assert!(handle_slash_command(
|
|
"/resume session.json",
|
|
&session,
|
|
CompactionConfig::default()
|
|
)
|
|
.is_none());
|
|
assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
|
|
}
|
|
}
|