use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::app::*;
use crate::args::*;
use crate::format::*;
use crate::tui::diff_view::format_colored_diff;
use crate::{
BUILD_TARGET, DEFAULT_DATE, DEPRECATED_INSTALL_COMMAND, GIT_SHA, OFFICIAL_REPO_SLUG,
OFFICIAL_REPO_URL, VERSION,
};
use ninmu_api::detect_provider_kind;
use ninmu_commands::{
classify_skills_slash_command, handle_agents_slash_command, handle_mcp_slash_command,
handle_mcp_slash_command_json, handle_skills_slash_command, handle_skills_slash_command_json,
SkillSlashDispatch, SlashCommand,
};
use ninmu_compat_harness::{extract_manifest, UpstreamPaths};
use ninmu_runtime::{
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status,
ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource,
ContentBlock, ConversationMessage, ConversationRuntime, McpServer, McpServerManager,
McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, PermissionPolicy,
ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage,
ToolError, ToolExecutor, UsageTracker,
};
use ninmu_tools::{execute_tool, mvp_tool_specs, GlobalToolRegistry};
use serde_json::{json, Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DiagnosticLevel {
Ok,
Warn,
Fail,
}
impl DiagnosticLevel {
fn label(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Warn => "warn",
Self::Fail => "fail",
}
}
fn is_failure(self) -> bool {
matches!(self, Self::Fail)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DiagnosticCheck {
name: &'static str,
level: DiagnosticLevel,
summary: String,
details: Vec<String>,
data: Map<String, Value>,
}
impl DiagnosticCheck {
fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into<String>) -> Self {
Self {
name,
level,
summary: summary.into(),
details: Vec::new(),
data: Map::new(),
}
}
fn with_details(mut self, details: Vec<String>) -> Self {
self.details = details;
self
}
fn with_data(mut self, data: Map<String, Value>) -> Self {
self.data = data;
self
}
pub(crate) fn json_value(&self) -> Value {
let mut value = Map::from_iter([
(
"name".to_string(),
Value::String(self.name.to_ascii_lowercase()),
),
(
"status".to_string(),
Value::String(self.level.label().to_string()),
),
("summary".to_string(), Value::String(self.summary.clone())),
(
"details".to_string(),
Value::Array(
self.details
.iter()
.cloned()
.map(Value::String)
.collect::<Vec<_>>(),
),
),
]);
value.extend(self.data.clone());
Value::Object(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DoctorReport {
checks: Vec<DiagnosticCheck>,
}
impl DoctorReport {
fn counts(&self) -> (usize, usize, usize) {
(
self.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Ok)
.count(),
self.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Warn)
.count(),
self.checks
.iter()
.filter(|check| check.level == DiagnosticLevel::Fail)
.count(),
)
}
fn has_failures(&self) -> bool {
self.checks.iter().any(|check| check.level.is_failure())
}
pub(crate) fn render(&self) -> String {
let (ok_count, warn_count, fail_count) = self.counts();
let mut lines = vec![
"Doctor".to_string(),
format!(
"Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}"
),
];
lines.extend(self.checks.iter().map(render_diagnostic_check));
lines.join("\n\n")
}
pub(crate) fn json_value(&self) -> Value {
let report = self.render();
let (ok_count, warn_count, fail_count) = self.counts();
json!({
"kind": "doctor",
"message": report,
"report": report,
"has_failures": self.has_failures(),
"summary": {
"total": self.checks.len(),
"ok": ok_count,
"warnings": warn_count,
"failures": fail_count,
},
"checks": self
.checks
.iter()
.map(DiagnosticCheck::json_value)
.collect::<Vec<_>>(),
})
}
}
#[derive(Debug, Clone)]
pub(crate) struct ResumeCommandOutcome {
pub(crate) session: Session,
pub(crate) message: Option<String>,
pub(crate) json: Option<serde_json::Value>,
}
pub(crate) fn summarize_tool_payload_for_markdown(payload: &str) -> String {
let compact = match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => value.to_string(),
Err(_) => payload.trim().to_string(),
};
crate::format::tool_fmt::truncate_for_summary(&compact, 96)
}
pub(crate) fn render_diagnostic_check(check: &DiagnosticCheck) -> String {
let mut lines = vec![format!(
"{}\n Status {}\n Summary {}",
check.name,
check.level.label(),
check.summary
)];
if !check.details.is_empty() {
lines.push(" Details".to_string());
lines.extend(check.details.iter().map(|detail| format!(" - {detail}")));
}
lines.join("\n")
}
pub(crate) fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let config_loader = ConfigLoader::default_for(&cwd);
let config = config_loader.load();
let discovered_config = config_loader.discover();
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref());
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
let empty_config = ninmu_runtime::RuntimeConfig::empty();
let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config);
let context = StatusContext {
cwd: cwd.clone(),
session_path: None,
loaded_config_files: config
.as_ref()
.ok()
.map_or(0, |runtime_config| runtime_config.loaded_entries().len()),
discovered_config_files: discovered_config.len(),
memory_file_count: project_context.instruction_files.len(),
project_root,
git_branch,
git_summary,
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
config_load_error: config.as_ref().err().map(ToString::to_string),
};
Ok(DoctorReport {
checks: vec![
check_providers_health(),
check_config_health(&config_loader, config.as_ref()),
check_install_source_health(),
check_workspace_health(&context),
check_sandbox_health(&context.sandbox_status),
check_system_health(&cwd, config.as_ref().ok()),
],
})
}
pub(crate) fn render_config_report(
section: Option<&str>,
) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
let runtime_config = loader.load()?;
let mut lines = vec![
format!(
"Config\n Working directory {}\n Loaded files {}\n Merged keys {}",
cwd.display(),
runtime_config.loaded_entries().len(),
runtime_config.merged().len()
),
"Discovered files".to_string(),
];
for entry in discovered {
let source = match entry.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let status = if runtime_config
.loaded_entries()
.iter()
.any(|loaded_entry| loaded_entry.path == entry.path)
{
"loaded"
} else {
"missing"
};
lines.push(format!(
" {source:<7} {status:<7} {}",
entry.path.display()
));
}
if let Some(section) = section {
lines.push(format!("Merged section: {section}"));
let value = match section {
"env" => runtime_config.get("env"),
"hooks" => runtime_config.get("hooks"),
"model" => runtime_config.get("model"),
"plugins" => runtime_config
.get("plugins")
.or_else(|| runtime_config.get("enabledPlugins")),
other => {
lines.push(format!(
" Unsupported config section '{other}'. Use env, hooks, model, or plugins."
));
return Ok(lines.join("\n\n"));
}
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
));
return Ok(lines.join("\n\n"));
}
lines.push("Merged JSON".to_string());
lines.push(format!(" {}", runtime_config.as_json().render()));
Ok(lines.join("\n\n"))
}
pub(crate) fn render_config_json(
_section: Option<&str>,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
let runtime_config = loader.load()?;
let loaded_paths: Vec<_> = runtime_config
.loaded_entries()
.iter()
.map(|e| e.path.display().to_string())
.collect();
let files: Vec<_> = discovered
.iter()
.map(|e| {
let source = match e.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let is_loaded = runtime_config
.loaded_entries()
.iter()
.any(|le| le.path == e.path);
serde_json::json!({
"path": e.path.display().to_string(),
"source": source,
"loaded": is_loaded,
})
})
.collect();
Ok(serde_json::json!({
"kind": "config",
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
"merged_keys": runtime_config.merged().len(),
"files": files,
}))
}
pub(crate) fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
let mut lines = vec![format!(
"Memory\n Working directory {}\n Instruction files {}",
cwd.display(),
project_context.instruction_files.len()
)];
if project_context.instruction_files.is_empty() {
lines.push("Discovered files".to_string());
lines.push(
" No CLAUDE instruction files discovered in the current directory ancestry."
.to_string(),
);
} else {
lines.push("Discovered files".to_string());
for (index, file) in project_context.instruction_files.iter().enumerate() {
let preview = file.content.lines().next().unwrap_or("").trim();
let preview = if preview.is_empty() {
"<empty>"
} else {
preview
};
lines.push(format!(" {}. {}", index + 1, file.path.display()));
lines.push(format!(
" lines={} preview={}",
file.content.lines().count(),
preview
));
}
}
Ok(lines.join("\n\n"))
}
pub(crate) fn render_memory_json() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
let files: Vec<_> = project_context
.instruction_files
.iter()
.map(|f| {
json!({
"path": f.path.display().to_string(),
"lines": f.content.lines().count(),
"preview": f.content.lines().next().unwrap_or("").trim(),
})
})
.collect();
Ok(json!({
"kind": "memory",
"cwd": cwd.display().to_string(),
"instruction_files": files.len(),
"files": files,
}))
}
pub(crate) fn run_mcp_serve() -> Result<(), Box<dyn std::error::Error>> {
let tools = mvp_tool_specs()
.into_iter()
.map(|spec| McpTool {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: Some(spec.input_schema),
annotations: None,
meta: None,
})
.collect();
let spec = ninmu_runtime::McpServerSpec {
server_name: "ninmu".to_string(),
server_version: VERSION.to_string(),
tools,
tool_handler: Box::new(execute_tool),
};
let tokio_runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
tokio_runtime.block_on(async move {
let mut server = ninmu_runtime::McpServer::new(spec);
server.run().await
})?;
Ok(())
}
pub(crate) fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
render_diff_report_for(&env::current_dir()?)
}
pub(crate) fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
let in_git_repo = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !in_git_repo {
return Ok(format!(
"Diff\n Result no git repository\n Detail {} is not inside a git project",
cwd.display()
));
}
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
if staged.trim().is_empty() && unstaged.trim().is_empty() {
return Ok(
"Diff\n Result clean working tree\n Detail no current changes"
.to_string(),
);
}
let mut sections = Vec::new();
if !staged.trim().is_empty() {
let colored = format_colored_diff(staged.trim_end());
sections.push(format!("Staged changes:\n{colored}"));
}
if !unstaged.trim().is_empty() {
let colored = format_colored_diff(unstaged.trim_end());
sections.push(format!("Unstaged changes:\n{colored}"));
}
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
}
pub(crate) fn render_diff_json_for(
cwd: &Path,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let in_git_repo = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !in_git_repo {
return Ok(serde_json::json!({
"kind": "diff",
"result": "no_git_repo",
"detail": format!("{} is not inside a git project", cwd.display()),
}));
}
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
Ok(serde_json::json!({
"kind": "diff",
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
"staged": staged.trim(),
"unstaged": unstaged.trim(),
}))
}
pub(crate) fn resolve_export_path(
requested_path: Option<&str>,
session: &Session,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let file_name =
requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned);
let final_name = if Path::new(&file_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
{
file_name
} else {
format!("{file_name}.txt")
};
Ok(cwd.join(final_name))
}
pub(crate) fn render_export_text(session: &Session) -> String {
let mut lines = vec!["# Conversation Export".to_string(), String::new()];
for (index, message) in session.messages.iter().enumerate() {
let role = match message.role {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
};
lines.push(format!("## {}. {role}", index + 1));
for block in &message.blocks {
match block {
ContentBlock::Text { text } => lines.push(text.clone()),
ContentBlock::ToolUse { id, name, input } => {
lines.push(format!("[tool_use id={id} name={name}] {input}"));
}
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} => {
lines.push(format!(
"[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}"
));
}
}
}
lines.push(String::new());
}
lines.join("\n")
}
pub(crate) fn format_bughunter_report(scope: Option<&str>) -> String {
format!(
"Bughunter\n Scope {}\n Action inspect the selected code for likely bugs and correctness issues\n Output findings should include file paths, severity, and suggested fixes",
scope.unwrap_or("the current repository")
)
}
pub(crate) fn format_ultraplan_report(task: Option<&str>) -> String {
format!(
"Ultraplan\n Task {}\n Action break work into a multi-step execution plan\n Output plan should cover goals, risks, sequencing, verification, and rollback",
task.unwrap_or("the current repo work")
)
}
pub(crate) fn render_teleport_report(target: &str) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let file_list = Command::new("rg")
.args(["--files"])
.current_dir(&cwd)
.output()?;
let file_matches = if file_list.status.success() {
String::from_utf8(file_list.stdout)?
.lines()
.filter(|line| line.contains(target))
.take(10)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
} else {
Vec::new()
};
let content_output = Command::new("rg")
.args(["-n", "-S", "--color", "never", target, "."])
.current_dir(&cwd)
.output()?;
let mut lines = vec![
"Teleport".to_string(),
format!(" Target {target}"),
" Action search workspace files and content for the target".to_string(),
];
if !file_matches.is_empty() {
lines.push(String::new());
lines.push("File matches".to_string());
lines.extend(file_matches.into_iter().map(|path| format!(" {path}")));
}
if content_output.status.success() {
let matches = String::from_utf8(content_output.stdout)?;
if !matches.trim().is_empty() {
lines.push(String::new());
lines.push("Content matches".to_string());
lines.push(truncate_for_prompt(&matches, 4_000));
}
}
if lines.len() == 1 {
lines.push(" Result no matches found".to_string());
}
Ok(lines.join("\n"))
}
pub(crate) fn validate_no_args(
command_name: &str,
args: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) {
return Err(format!(
"{command_name} does not accept arguments. Received: {args}\nUsage: {command_name}"
)
.into());
}
Ok(())
}
pub(crate) fn render_last_tool_debug_report(
session: &Session,
) -> Result<String, Box<dyn std::error::Error>> {
let last_tool_use = session
.messages
.iter()
.rev()
.find_map(|message| {
message.blocks.iter().rev().find_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => {
Some((id.clone(), name.clone(), input.clone()))
}
_ => None,
})
})
.ok_or_else(|| "no prior tool call found in session".to_string())?;
let tool_result = session.messages.iter().rev().find_map(|message| {
message.blocks.iter().rev().find_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} if tool_use_id == &last_tool_use.0 => {
Some((tool_name.clone(), output.clone(), *is_error))
}
_ => None,
})
});
let mut lines = vec![
"Debug tool call".to_string(),
" Action inspect the last recorded tool call and its result".to_string(),
format!(" Tool id {}", last_tool_use.0),
format!(" Tool name {}", last_tool_use.1),
" Input".to_string(),
indent_block(&last_tool_use.2, 4),
];
match tool_result {
Some((tool_name, output, is_error)) => {
lines.push(" Result".to_string());
lines.push(format!(" name {tool_name}"));
lines.push(format!(
" status {}",
if is_error { "error" } else { "ok" }
));
lines.push(indent_block(&output, 4));
}
None => lines.push(" Result missing tool result".to_string()),
}
Ok(lines.join("\n"))
}
pub(crate) fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(args)
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
}
Ok(String::from_utf8(output.stdout)?)
}
pub(crate) fn format_pr_report(branch: &str, context: Option<&str>) -> String {
format!(
"PR\n Branch {branch}\n Context {}\n Action draft or create a pull request for the current branch\n Output title and markdown body suitable for GitHub",
context.unwrap_or("none")
)
}
pub(crate) fn format_issue_report(context: Option<&str>) -> String {
format!(
"Issue\n Context {}\n Action draft or create a GitHub issue from the current context\n Output title and markdown body suitable for GitHub",
context.unwrap_or("none")
)
}
pub(crate) fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let report = render_doctor_report()?;
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&report.json_value())?);
}
}
if report.has_failures() {
return Err("doctor found failing checks".into());
}
Ok(())
}
pub(crate) fn run_worker_state(
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let state_path = cwd.join(".claw").join("worker-state.json");
if !state_path.exists() {
return Err(format!(
"no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: ninmu # start the REPL (writes state on first turn)\n Or: ninmu prompt <text> # run one non-interactive turn\n Then rerun: ninmu state [--output-format json]",
path = state_path.display()
)
.into());
}
let raw = std::fs::read_to_string(&state_path)?;
match output_format {
CliOutputFormat::Text => println!("{raw}"),
CliOutputFormat::Json => {
let _: serde_json::Value = serde_json::from_str(&raw)?;
println!("{raw}");
}
}
Ok(())
}
pub(crate) fn print_version(
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Text => println!("{}", render_version_report()),
CliOutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&version_json_value())?);
}
}
Ok(())
}
pub(crate) fn print_system_prompt(
cwd: PathBuf,
date: String,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?;
let message = sections.join("\n\n");
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "system-prompt",
"message": message,
"sections": sections,
}))?
),
}
Ok(())
}
pub(crate) fn print_bootstrap_plan(
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let phases = ninmu_runtime::BootstrapPlan::claude_code_default()
.phases()
.iter()
.map(|phase| format!("{phase:?}"))
.collect::<Vec<_>>();
match output_format {
CliOutputFormat::Text => {
for phase in &phases {
println!("- {phase}");
}
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "bootstrap-plan",
"phases": phases,
}))?
),
}
Ok(())
}
#[allow(clippy::too_many_lines)]
pub(crate) fn resume_session(
session_path: &Path,
commands: &[String],
output_format: CliOutputFormat,
) {
let session_reference = session_path.display().to_string();
let (handle, session) = match load_session_reference(&session_reference) {
Ok(loaded) => loaded,
Err(error) => {
if output_format == CliOutputFormat::Json {
let full_message = format!("failed to restore session: {error}");
let kind = classify_error_kind(&full_message);
let (short_reason, hint) = split_error_hint(&full_message);
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": short_reason,
"kind": kind,
"hint": hint,
})
);
} else {
eprintln!("failed to restore session: {error}");
}
std::process::exit(1);
}
};
let resolved_path = handle.path.clone();
if commands.is_empty() {
if output_format == CliOutputFormat::Json {
println!(
"{}",
serde_json::json!({
"kind": "restored",
"session_id": session.session_id,
"path": handle.path.display().to_string(),
"message_count": session.messages.len(),
})
);
} else {
println!(
"Restored session from {} ({} messages).",
handle.path.display(),
session.messages.len()
);
}
return;
}
let mut session = session;
for raw_command in commands {
{
let cmd_root = raw_command
.trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or("");
if STUB_COMMANDS.contains(&cmd_root) {
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("/{cmd_root} is not yet implemented in this build"),
"kind": "unsupported_command",
"command": raw_command,
})
);
} else {
eprintln!("/{cmd_root} is not yet implemented in this build");
}
std::process::exit(2);
}
}
let command = match SlashCommand::parse(raw_command) {
Ok(Some(command)) => command,
Ok(None) => {
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("unsupported resumed command: {raw_command}"),
"kind": "unsupported_resumed_command",
"command": raw_command,
})
);
} else {
eprintln!("unsupported resumed command: {raw_command}");
}
std::process::exit(2);
}
Err(error) => {
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": error.to_string(),
"command": raw_command,
})
);
} else {
eprintln!("{error}");
}
std::process::exit(2);
}
};
match run_resume_command(&resolved_path, &session, &command) {
Ok(ResumeCommandOutcome {
session: next_session,
message,
json,
}) => {
session = next_session;
if output_format == CliOutputFormat::Json {
if let Some(value) = json {
println!(
"{}",
serde_json::to_string_pretty(&value)
.expect("resume command json output")
);
} else if let Some(message) = message {
println!("{message}");
}
} else if let Some(message) = message {
println!("{message}");
}
}
Err(error) => {
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": error.to_string(),
"command": raw_command,
})
);
} else {
eprintln!("{error}");
}
std::process::exit(2);
}
}
}
}
#[allow(clippy::too_many_lines)]
pub(crate) fn run_resume_command(
session_path: &Path,
session: &Session,
command: &SlashCommand,
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
match command {
SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_repl_help()),
json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })),
}),
SlashCommand::Compact => {
let result = ninmu_runtime::compact_session(
session,
CompactionConfig {
max_estimated_tokens: 0,
..CompactionConfig::default()
},
);
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
result.compacted_session.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: result.compacted_session,
message: Some(format_compact_report(removed, kept, skipped)),
json: Some(serde_json::json!({
"kind": "compact",
"skipped": skipped,
"removed_messages": removed,
"kept_messages": kept,
})),
})
}
SlashCommand::Clear { confirm } => {
if !confirm {
return Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(
"clear: confirmation required; rerun with /clear --confirm".to_string(),
),
json: Some(serde_json::json!({
"kind": "error",
"error": "confirmation required",
"hint": "rerun with /clear --confirm",
})),
});
}
let backup_path = write_session_clear_backup(session, session_path)?;
let previous_session_id = session.session_id.clone();
let cleared = new_cli_session()?;
let new_session_id = cleared.session_id.clone();
cleared.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: cleared,
message: Some(format!(
"Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous ninmu --resume {}\n New session {new_session_id}\n Session file {}",
backup_path.display(),
backup_path.display(),
session_path.display()
)),
json: Some(serde_json::json!({
"kind": "clear",
"previous_session_id": previous_session_id,
"new_session_id": new_session_id,
"backup": backup_path.display().to_string(),
"session_file": session_path.display().to_string(),
})),
})
}
SlashCommand::Status => {
let tracker = UsageTracker::from_session(session);
let usage = tracker.cumulative_usage();
let context = status_context(Some(session_path))?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_status_report(
session.model.as_deref().unwrap_or("restored-session"),
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
latest: tracker.current_turn_usage(),
cumulative: usage,
estimated_tokens: 0,
},
default_permission_mode().as_str(),
&context,
None,
)),
json: Some(status_json_value(
session.model.as_deref(),
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
latest: tracker.current_turn_usage(),
cumulative: usage,
estimated_tokens: 0,
},
default_permission_mode().as_str(),
&context,
None,
)),
})
}
SlashCommand::Sandbox => {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?;
let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_sandbox_report(&status)),
json: Some(sandbox_json_value(&status)),
})
}
SlashCommand::Cost => {
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
json: Some(serde_json::json!({
"kind": "cost",
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
"total_tokens": usage.total_tokens(),
})),
})
}
SlashCommand::Config { section } => {
let message = render_config_report(section.as_deref())?;
let json = render_config_json(section.as_deref())?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message),
json: Some(json),
})
}
SlashCommand::Mcp { action, target } => {
let cwd = env::current_dir()?;
let args = match (action.as_deref(), target.as_deref()) {
(None, None) => None,
(Some(action), None) => Some(action.to_string()),
(Some(action), Some(target)) => Some(format!("{action} {target}")),
(None, Some(target)) => Some(target.to_string()),
};
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?),
json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)?),
})
}
SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_memory_report()?),
json: Some(render_memory_json()?),
}),
SlashCommand::Init => {
let cwd = env::current_dir()?;
let report = crate::init::initialize_repo(&cwd)?;
let message = report.render();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message.clone()),
json: Some(init_json_value(&report, &message)),
})
}
SlashCommand::Diff => {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let message = render_diff_report_for(&cwd)?;
let json = render_diff_json_for(&cwd)?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message),
json: Some(json),
})
}
SlashCommand::Version => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_version_report()),
json: Some(version_json_value()),
}),
SlashCommand::Export { path } => {
let export_path = resolve_export_path(path.as_deref(), session)?;
fs::write(&export_path, render_export_text(session))?;
let msg_count = session.messages.len();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format!(
"Export\n Result wrote transcript\n File {}\n Messages {}",
export_path.display(),
msg_count,
)),
json: Some(serde_json::json!({
"kind": "export",
"file": export_path.display().to_string(),
"message_count": msg_count,
})),
})
}
SlashCommand::Agents { args } => {
let cwd = env::current_dir()?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
json: Some(serde_json::json!({
"kind": "agents",
"text": handle_agents_slash_command(args.as_deref(), &cwd)?,
})),
})
}
SlashCommand::Skills { args } => {
if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) {
return Err(
"resumed /skills invocations are interactive-only; start `ninmu` and run `/skills <skill>` in the REPL".into(),
);
}
let cwd = env::current_dir()?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?),
json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?),
})
}
SlashCommand::Doctor => {
let report = render_doctor_report()?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(report.render()),
json: Some(report.json_value()),
})
}
SlashCommand::Stats => {
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
json: Some(serde_json::json!({
"kind": "stats",
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
"total_tokens": usage.total_tokens(),
})),
})
}
SlashCommand::History { count } => {
let limit = parse_history_count(count.as_deref())
.map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
let entries = collect_session_prompt_history(session);
let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_prompt_history_report(&entries, limit)),
json: Some(serde_json::json!({
"kind": "history",
"total": entries.len(),
"showing": shown.len(),
"entries": shown.iter().map(|e| serde_json::json!({
"timestamp_ms": e.timestamp_ms,
"text": e.text,
})).collect::<Vec<_>>(),
})),
})
}
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
SlashCommand::Session {
action: Some(ref act),
..
} if act == "list" => {
let sessions = list_managed_sessions().unwrap_or_default();
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
let active_id = session.session_id.clone();
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(text),
json: Some(serde_json::json!({
"kind": "session_list",
"sessions": session_ids,
"active": active_id,
})),
})
}
SlashCommand::Bughunter { .. }
| SlashCommand::Commit { .. }
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
| SlashCommand::Ultraplan { .. }
| SlashCommand::Teleport { .. }
| SlashCommand::DebugToolCall { .. }
| SlashCommand::Resume { .. }
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
| SlashCommand::Login
| SlashCommand::Logout
| SlashCommand::Vim
| SlashCommand::Upgrade
| SlashCommand::Share
| SlashCommand::Feedback
| SlashCommand::Files
| SlashCommand::Fast
| SlashCommand::Exit
| SlashCommand::Summary
| SlashCommand::Desktop
| SlashCommand::Brief
| SlashCommand::Advisor
| SlashCommand::Stickers
| SlashCommand::Insights
| SlashCommand::Thinkback
| SlashCommand::ReleaseNotes
| SlashCommand::SecurityReview
| SlashCommand::Keybindings
| SlashCommand::PrivacySettings
| SlashCommand::Plan { .. }
| SlashCommand::Review { .. }
| SlashCommand::Tasks { .. }
| SlashCommand::Theme { .. }
| SlashCommand::Voice { .. }
| SlashCommand::Usage { .. }
| SlashCommand::Rename { .. }
| SlashCommand::Copy { .. }
| SlashCommand::Hooks { .. }
| SlashCommand::Context { .. }
| SlashCommand::Color { .. }
| SlashCommand::Effort { .. }
| SlashCommand::Branch { .. }
| SlashCommand::Rewind { .. }
| SlashCommand::Ide { .. }
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()),
}
}
pub(crate) fn dump_manifests(
manifests_dir: Option<&Path>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
dump_manifests_at_path(&workspace_dir, manifests_dir, output_format)
}
const DUMP_MANIFESTS_OVERRIDE_HINT: &str =
"Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `ninmu dump-manifests --manifests-dir /path/to/upstream`.";
pub(crate) fn dump_manifests_at_path(
workspace_dir: &std::path::Path,
manifests_dir: Option<&Path>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let paths = if let Some(dir) = manifests_dir {
let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
UpstreamPaths::from_repo_root(resolved)
} else {
let resolved = workspace_dir
.canonicalize()
.unwrap_or_else(|_| workspace_dir.to_path_buf());
UpstreamPaths::from_workspace_dir(&resolved)
};
let source_root = paths.repo_root();
if !source_root.exists() {
return Err(format!(
"Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
source_root.display(),
)
.into());
}
let required_paths = [
("src/commands.ts", paths.commands_path()),
("src/tools.ts", paths.tools_path()),
("src/entrypoints/cli.tsx", paths.cli_path()),
];
let missing = required_paths
.iter()
.filter_map(|(label, path)| (!path.is_file()).then_some(*label))
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(format!(
"Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
source_root.display(),
missing.join(", "),
)
.into());
}
match extract_manifest(&paths) {
Ok(manifest) => {
match output_format {
CliOutputFormat::Text => {
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "dump-manifests",
"commands": manifest.commands.entries().len(),
"tools": manifest.tools.entries().len(),
"bootstrap_phases": manifest.bootstrap.phases().len(),
}))?
),
}
Ok(())
}
Err(error) => Err(format!(
"failed to extract manifests: {error}\n looked in: {path}\n {DUMP_MANIFESTS_OVERRIDE_HINT}",
path = paths.repo_root().display()
)
.into()),
}
}
pub(crate) fn init_json_value(
report: &crate::init::InitReport,
message: &str,
) -> serde_json::Value {
use crate::init::InitStatus;
json!({
"kind": "init",
"project_path": report.project_root.display().to_string(),
"created": report.artifacts_with_status(InitStatus::Created),
"updated": report.artifacts_with_status(InitStatus::Updated),
"skipped": report.artifacts_with_status(InitStatus::Skipped),
"artifacts": report.artifact_json_entries(),
"next_step": crate::init::InitReport::NEXT_STEP,
"message": message,
})
}
pub(crate) fn print_acp_status(
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let message = "ACP/Zed editor integration is not implemented in ninmu-code yet. `ninmu acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support.";
match output_format {
CliOutputFormat::Text => {
println!(
"ACP / Zed\n Status discoverability only\n Launch `ninmu acp serve` / `ninmu --acp` / `ninmu -acp` report status only; no editor daemon is available yet\n Today use `ninmu prompt`, the REPL, or `ninmu doctor` for local verification\n Tracking ROADMAP #76\n Message {message}"
);
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "acp",
"status": "discoverability_only",
"supported": false,
"serve_alias_only": true,
"message": message,
"launch_command": serde_json::Value::Null,
"aliases": ["acp", "--acp", "-acp"],
"discoverability_tracking": "ROADMAP #64a",
"tracking": "ROADMAP #76",
"recommended_workflows": [
"ninmu prompt TEXT",
"ninmu",
"ninmu doctor"
],
}))?
);
}
}
Ok(())
}
pub(crate) fn render_session_markdown(
session: &Session,
session_id: &str,
session_path: &Path,
) -> String {
let mut lines = vec![
"# Conversation Export".to_string(),
String::new(),
format!("- **Session**: `{session_id}`"),
format!("- **File**: `{}`", session_path.display()),
format!("- **Messages**: {}", session.messages.len()),
];
if let Some(workspace_root) = session.workspace_root() {
lines.push(format!("- **Workspace**: `{}`", workspace_root.display()));
}
if let Some(fork) = &session.fork {
let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)");
lines.push(format!(
"- **Forked from**: `{}` (branch `{branch}`)",
fork.parent_session_id
));
}
if let Some(compaction) = &session.compaction {
lines.push(format!(
"- **Compactions**: {} (last removed {} messages)",
compaction.count, compaction.removed_message_count
));
}
lines.push(String::new());
lines.push("---".to_string());
lines.push(String::new());
for (index, message) in session.messages.iter().enumerate() {
let role = match message.role {
MessageRole::System => "System",
MessageRole::User => "User",
MessageRole::Assistant => "Assistant",
MessageRole::Tool => "Tool",
};
lines.push(format!("## {}. {role}", index + 1));
lines.push(String::new());
for block in &message.blocks {
match block {
ContentBlock::Text { text } => {
let trimmed = text.trim_end();
if !trimmed.is_empty() {
lines.push(trimmed.to_string());
lines.push(String::new());
}
}
ContentBlock::ToolUse { id, name, input } => {
lines.push(format!(
"**Tool call** `{name}` _(id `{}`)_",
short_tool_id(id)
));
let summary = summarize_tool_payload_for_markdown(input);
if !summary.is_empty() {
lines.push(format!("> {summary}"));
}
lines.push(String::new());
}
ContentBlock::ToolResult {
tool_use_id,
tool_name,
output,
is_error,
} => {
let status = if *is_error { "error" } else { "ok" };
lines.push(format!(
"**Tool result** `{tool_name}` _(id `{}`, {status})_",
short_tool_id(tool_use_id)
));
let summary = summarize_tool_payload_for_markdown(output);
if !summary.is_empty() {
lines.push(format!("> {summary}"));
}
lines.push(String::new());
}
}
}
if let Some(usage) = message.usage {
lines.push(format!(
"_tokens: in={} out={} cache_create={} cache_read={}_",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
));
lines.push(String::new());
}
}
lines.join("\n")
}
pub(crate) fn run_export(
session_reference: &str,
output_path: Option<&Path>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let (handle, session) = load_session_reference(session_reference)?;
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
if let Some(path) = output_path {
fs::write(path, &markdown)?;
let report = format!(
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
path.display(),
handle.id,
session.messages.len(),
);
match output_format {
CliOutputFormat::Text => println!("{report}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"message": report,
"session_id": handle.id,
"file": path.display().to_string(),
"messages": session.messages.len(),
}))?
),
}
return Ok(());
}
match output_format {
CliOutputFormat::Text => {
print!("{markdown}");
if !markdown.ends_with('\n') {
println!();
}
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"session_id": handle.id,
"file": handle.path.display().to_string(),
"messages": session.messages.len(),
"markdown": markdown,
}))?
),
}
Ok(())
}
pub(crate) fn default_export_filename(session: &Session) -> String {
let stem = session
.messages
.iter()
.find_map(|message| match message.role {
MessageRole::User => message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
}),
_ => None,
})
.map_or("conversation", |text| {
text.lines().next().unwrap_or("conversation")
})
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.take(8)
.collect::<Vec<_>>()
.join("-");
let fallback = if stem.is_empty() {
"conversation"
} else {
&stem
};
format!("{fallback}.txt")
}
pub(crate) fn indent_block(value: &str, spaces: usize) -> String {
let indent = " ".repeat(spaces);
value
.lines()
.map(|line| format!("{indent}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
let output = Command::new("git")
.args(args)
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
}
Ok(())
}
pub(crate) fn command_exists(name: &str) -> bool {
Command::new("which")
.arg(name)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub(crate) fn write_temp_text_file(
filename: &str,
contents: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let path = env::temp_dir().join(filename);
fs::write(&path, contents)?;
Ok(path)
}
pub(crate) fn short_tool_id(id: &str) -> String {
let char_count = id.chars().count();
if char_count <= 12 {
return id.to_string();
}
let prefix: String = id.chars().take(12).collect();
format!("{prefix}…")
}
pub(crate) fn run_git_diff_command_in(
cwd: &Path,
args: &[&str],
) -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
}
Ok(String::from_utf8(output.stdout)?)
}
pub(crate) fn sanitize_generated_message(value: &str) -> String {
value.trim().trim_matches('`').trim().replace("\r\n", "\n")
}
pub(crate) fn parse_titled_body(value: &str) -> Option<(String, String)> {
let normalized = sanitize_generated_message(value);
let title = normalized
.lines()
.find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?;
let body_start = normalized.find("BODY:")?;
let body = normalized[body_start + "BODY:".len()..].trim();
Some((title.to_string(), body.to_string()))
}
pub(crate) fn truncate_for_prompt(value: &str, limit: usize) -> String {
if value.chars().count() <= limit {
value.trim().to_string()
} else {
let truncated = value.chars().take(limit).collect::<String>();
format!("{}\n…[truncated]", truncated.trim_end())
}
}
pub(crate) fn recent_user_context(session: &Session, limit: usize) -> String {
let requests = session
.messages
.iter()
.filter(|message| message.role == MessageRole::User)
.filter_map(|message| {
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(text.trim().to_string()),
_ => None,
})
})
.rev()
.take(limit)
.collect::<Vec<_>>();
if requests.is_empty() {
"<no prior user messages>".to_string()
} else {
requests
.into_iter()
.rev()
.enumerate()
.map(|(index, text)| format!("{}. {}", index + 1, text))
.collect::<Vec<_>>()
.join("\n")
}
}
pub(crate) fn write_mcp_server_fixture(script_path: &Path) {
let script = [
"#!/usr/bin/env python3",
"import json, sys",
"",
"def read_message():",
" header = b''",
r" while not header.endswith(b'\r\n\r\n'):",
" chunk = sys.stdin.buffer.read(1)",
" if not chunk:",
" return None",
" header += chunk",
" length = 0",
r" for line in header.decode().split('\r\n'):",
r" if line.lower().startswith('content-length:'):",
" length = int(line.split(':', 1)[1].strip())",
" payload = sys.stdin.buffer.read(length)",
" return json.loads(payload.decode())",
"",
"def send_message(message):",
" payload = json.dumps(message).encode()",
r" sys.stdout.buffer.write(f'Content-Length: {len(payload)}\r\n\r\n'.encode() + payload)",
" sys.stdout.buffer.flush()",
"",
"while True:",
" request = read_message()",
" if request is None:",
" break",
" method = request['method']",
" if method == 'initialize':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'protocolVersion': request['params']['protocolVersion'],",
" 'capabilities': {'tools': {}, 'resources': {}},",
" 'serverInfo': {'name': 'fixture', 'version': '1.0.0'}",
" }",
" })",
" elif method == 'tools/list':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'tools': [",
" {",
" 'name': 'echo',",
" 'description': 'Echo from MCP fixture',",
" 'inputSchema': {",
" 'type': 'object',",
" 'properties': {'text': {'type': 'string'}},",
" 'required': ['text'],",
" 'additionalProperties': False",
" },",
" 'annotations': {'readOnlyHint': True}",
" }",
" ]",
" }",
" })",
" elif method == 'tools/call':",
" args = request['params'].get('arguments') or {}",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'content': [{'type': 'text', 'text': f\"echo:{args.get('text', '')}\"}],",
" 'structuredContent': {'echoed': args.get('text', '')},",
" 'isError': False",
" }",
" })",
" elif method == 'resources/list':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'resources': [{'uri': 'file://guide.txt', 'name': 'guide', 'mimeType': 'text/plain'}]",
" }",
" })",
" elif method == 'resources/read':",
" uri = request['params']['uri']",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'contents': [{'uri': uri, 'mimeType': 'text/plain', 'text': f'contents for {uri}'}]",
" }",
" })",
" else:",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'error': {'code': -32601, 'message': method}",
" })",
"",
]
.join("\n");
fs::write(script_path, script).expect("mcp fixture script should write");
}
#[allow(clippy::too_many_lines)]
fn check_providers_health() -> DiagnosticCheck {
struct ProviderStatus {
label: &'static str,
env_var: &'static str,
is_local: bool,
configured: bool,
}
let providers: &[ProviderStatus] = &[
ProviderStatus {
label: "Anthropic",
env_var: "ANTHROPIC_API_KEY",
is_local: false,
configured: env_present("ANTHROPIC_API_KEY") || env_present("ANTHROPIC_AUTH_TOKEN"),
},
ProviderStatus {
label: "OpenAI",
env_var: "OPENAI_API_KEY",
is_local: false,
configured: env_present("OPENAI_API_KEY"),
},
ProviderStatus {
label: "xAI (Grok)",
env_var: "XAI_API_KEY",
is_local: false,
configured: env_present("XAI_API_KEY"),
},
ProviderStatus {
label: "DeepSeek",
env_var: "DEEPSEEK_API_KEY",
is_local: false,
configured: env_present("DEEPSEEK_API_KEY"),
},
ProviderStatus {
label: "DashScope",
env_var: "DASHSCOPE_API_KEY",
is_local: false,
configured: env_present("DASHSCOPE_API_KEY"),
},
ProviderStatus {
label: "Ollama",
env_var: "OLLAMA_API_KEY / OLLAMA_BASE_URL",
is_local: true,
configured: env_present("OLLAMA_API_KEY") || env_present("OLLAMA_BASE_URL"),
},
ProviderStatus {
label: "vLLM",
env_var: "VLLM_BASE_URL",
is_local: true,
configured: env_present("VLLM_BASE_URL"),
},
ProviderStatus {
label: "Qwen (external)",
env_var: "QWEN_API_KEY",
is_local: false,
configured: env_present("QWEN_API_KEY") || env_present("OPENAI_API_KEY"),
},
ProviderStatus {
label: "Mistral",
env_var: "MISTRAL_API_KEY",
is_local: false,
configured: env_present("MISTRAL_API_KEY"),
},
ProviderStatus {
label: "Gemini",
env_var: "GEMINI_API_KEY",
is_local: false,
configured: env_present("GEMINI_API_KEY"),
},
ProviderStatus {
label: "Cohere",
env_var: "COHERE_API_KEY",
is_local: false,
configured: env_present("COHERE_API_KEY"),
},
];
let configured_count = providers.iter().filter(|p| p.configured).count();
let total = providers.len();
let level = if configured_count > 0 {
DiagnosticLevel::Ok
} else {
DiagnosticLevel::Warn
};
let summary = if configured_count > 0 {
format!("{configured_count} of {total} providers configured")
} else {
"no providers configured".to_string()
};
let mut details: Vec<String> = providers
.iter()
.map(|p| {
let status = if p.configured {
"configured"
} else {
"not configured"
};
let note = if p.is_local && !p.configured {
" (default: localhost)".to_string()
} else {
String::new()
};
format!(
" {:<18} {:<14} {env}{note}",
p.label,
status,
env = p.env_var
)
})
.collect();
if configured_count == 0 {
details.push(String::new());
details.push(
"Set one of the above env vars or create ~/.claw/models.json for custom providers"
.to_string(),
);
}
let ollama_cloud = env_present("OLLAMA_API_KEY");
let ollama_url = env::var("OLLAMA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| {
if ollama_cloud {
"https://api.ollama.com".to_string()
} else {
"http://localhost:11434".to_string()
}
});
let ollama_models = if ollama_cloud {
discover_vllm_models(&ollama_url) } else {
discover_ollama_models(&ollama_url) };
if let Some(ref models) = ollama_models {
if !models.is_empty() {
let label = if ollama_cloud {
"Ollama Cloud models"
} else {
"Ollama models"
};
details.push(format!(
"\n {label} ({}) {}",
models.len(),
models.join(", ")
));
}
}
let vllm_url = env::var("VLLM_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| "http://localhost:8000".to_string());
let vllm_models = discover_vllm_models(&vllm_url);
if let Some(ref models) = vllm_models {
if !models.is_empty() {
details.push(format!(
"\n vLLM models ({}) {}",
models.len(),
models.join(", ")
));
}
}
DiagnosticCheck::new("Providers", level, &summary).with_details(details)
}
fn discover_ollama_models(base_url: &str) -> Option<Vec<String>> {
let root = base_url
.trim_end_matches('/')
.strip_suffix("/v1")
.unwrap_or(base_url);
let url = format!("{root}/api/tags");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let body: serde_json::Value = response.json().ok()?;
let models = body["models"]
.as_array()?
.iter()
.filter_map(|m| m["name"].as_str().map(String::from))
.take(20) .collect::<Vec<_>>();
Some(models)
}
fn discover_vllm_models(base_url: &str) -> Option<Vec<String>> {
let url = format!("{}/v1/models", base_url.trim_end_matches('/'));
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.ok()?;
let response = client.get(&url).send().ok()?;
let body: serde_json::Value = response.json().ok()?;
let models = body["data"]
.as_array()?
.iter()
.filter_map(|m| m["id"].as_str().map(String::from))
.take(20)
.collect::<Vec<_>>();
Some(models)
}
fn env_present(name: &str) -> bool {
env::var(name)
.ok()
.is_some_and(|value| !value.trim().is_empty())
}
fn check_auth_health() -> DiagnosticCheck {
let api_key_present = env::var("ANTHROPIC_API_KEY")
.ok()
.is_some_and(|value| !value.trim().is_empty());
let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN")
.ok()
.is_some_and(|value| !value.trim().is_empty());
let env_details = format!(
"Environment api_key={} auth_token={}",
if api_key_present { "present" } else { "absent" },
if auth_token_present {
"present"
} else {
"absent"
}
);
match load_oauth_credentials() {
Ok(Some(token_set)) => DiagnosticCheck::new(
"Auth",
if api_key_present || auth_token_present {
DiagnosticLevel::Ok
} else {
DiagnosticLevel::Warn
},
if api_key_present || auth_token_present {
"supported auth env vars are configured; legacy saved OAuth is ignored"
} else {
"legacy saved OAuth credentials are present but unsupported"
},
)
.with_details(vec![
env_details,
format!(
"Legacy OAuth expires_at={} refresh_token={} scopes={}",
token_set
.expires_at
.map_or_else(|| "<none>".to_string(), |value| value.to_string()),
if token_set.refresh_token.is_some() {
"present"
} else {
"absent"
},
if token_set.scopes.is_empty() {
"<none>".to_string()
} else {
token_set.scopes.join(",")
}
),
"Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `ninmu login` is removed"
.to_string(),
])
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), json!(true)),
(
"legacy_saved_oauth_expires_at".to_string(),
json!(token_set.expires_at),
),
(
"legacy_refresh_token_present".to_string(),
json!(token_set.refresh_token.is_some()),
),
("legacy_scopes".to_string(), json!(token_set.scopes)),
])),
Ok(None) => DiagnosticCheck::new(
"Auth",
if api_key_present || auth_token_present {
DiagnosticLevel::Ok
} else {
DiagnosticLevel::Warn
},
if api_key_present || auth_token_present {
"supported auth env vars are configured"
} else {
"no supported auth env vars were found"
},
)
.with_details(vec![env_details])
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), json!(false)),
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
("legacy_refresh_token_present".to_string(), json!(false)),
("legacy_scopes".to_string(), json!(Vec::<String>::new())),
])),
Err(error) => DiagnosticCheck::new(
"Auth",
DiagnosticLevel::Fail,
format!("failed to inspect legacy saved credentials: {error}"),
)
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), Value::Null),
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
("legacy_refresh_token_present".to_string(), Value::Null),
("legacy_scopes".to_string(), Value::Null),
("legacy_saved_oauth_error".to_string(), json!(error.to_string())),
])),
}
}
fn check_config_health(
config_loader: &ConfigLoader,
config: Result<&ninmu_runtime::RuntimeConfig, &ninmu_runtime::ConfigError>,
) -> DiagnosticCheck {
let discovered = config_loader.discover();
let discovered_count = discovered.len();
let present_paths: Vec<String> = discovered
.iter()
.filter(|e| e.path.exists())
.map(|e| e.path.display().to_string())
.collect();
let discovered_paths = discovered
.iter()
.map(|entry| entry.path.display().to_string())
.collect::<Vec<_>>();
match config {
Ok(runtime_config) => {
let loaded_entries = runtime_config.loaded_entries();
let loaded_count = loaded_entries.len();
let present_count = present_paths.len();
let mut details = vec![format!(
"Config files loaded {}/{}",
loaded_count, present_count
)];
if let Some(model) = runtime_config.model() {
details.push(format!("Resolved model {model}"));
}
details.push(format!(
"MCP servers {}",
runtime_config.mcp().servers().len()
));
if present_paths.is_empty() {
details.push("Discovered files <none> (defaults active)".to_string());
} else {
details.extend(
present_paths
.iter()
.map(|path| format!("Discovered file {path}")),
);
}
DiagnosticCheck::new(
"Config",
DiagnosticLevel::Ok,
if present_count == 0 {
"no config files present; defaults are active"
} else {
"runtime config loaded successfully"
},
)
.with_details(details)
.with_data(Map::from_iter([
("discovered_files".to_string(), json!(present_paths)),
("discovered_files_count".to_string(), json!(present_count)),
("loaded_config_files".to_string(), json!(loaded_count)),
("resolved_model".to_string(), json!(runtime_config.model())),
(
"mcp_servers".to_string(),
json!(runtime_config.mcp().servers().len()),
),
]))
}
Err(error) => DiagnosticCheck::new(
"Config",
DiagnosticLevel::Fail,
format!("runtime config failed to load: {error}"),
)
.with_details(if discovered_paths.is_empty() {
vec!["Discovered files <none>".to_string()]
} else {
discovered_paths
.iter()
.map(|path| format!("Discovered file {path}"))
.collect()
})
.with_data(Map::from_iter([
("discovered_files".to_string(), json!(discovered_paths)),
(
"discovered_files_count".to_string(),
json!(discovered_count),
),
("loaded_config_files".to_string(), json!(0)),
("resolved_model".to_string(), Value::Null),
("mcp_servers".to_string(), Value::Null),
("load_error".to_string(), json!(error.to_string())),
])),
}
}
fn check_install_source_health() -> DiagnosticCheck {
DiagnosticCheck::new(
"Install source",
DiagnosticLevel::Ok,
format!(
"official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`"
),
)
.with_details(vec![
format!("Official repo {OFFICIAL_REPO_URL}"),
"Recommended path build from this repo or use the upstream binary documented in README.md"
.to_string(),
format!(
"Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `ninmu` binary"
)
.to_string(),
])
.with_data(Map::from_iter([
("official_repo".to_string(), json!(OFFICIAL_REPO_URL)),
(
"deprecated_install".to_string(),
json!(DEPRECATED_INSTALL_COMMAND),
),
(
"recommended_install".to_string(),
json!("build from source or follow the upstream binary instructions in README.md"),
),
]))
}
fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
let in_repo = context.project_root.is_some();
DiagnosticCheck::new(
"Workspace",
if in_repo {
DiagnosticLevel::Ok
} else {
DiagnosticLevel::Warn
},
if in_repo {
format!(
"project root detected on branch {}",
context.git_branch.as_deref().unwrap_or("unknown")
)
} else {
"current directory is not inside a git project".to_string()
},
)
.with_details(vec![
format!("Cwd {}", context.cwd.display()),
format!(
"Project root {}",
context
.project_root
.as_ref()
.map_or_else(|| "<none>".to_string(), |path| path.display().to_string())
),
format!(
"Git branch {}",
context.git_branch.as_deref().unwrap_or("unknown")
),
format!("Git state {}", context.git_summary.headline()),
format!("Changed files {}", context.git_summary.changed_files),
format!(
"Memory files {} · config files loaded {}/{}",
context.memory_file_count, context.loaded_config_files, context.discovered_config_files
),
])
.with_data(Map::from_iter([
("cwd".to_string(), json!(context.cwd.display().to_string())),
(
"project_root".to_string(),
json!(context
.project_root
.as_ref()
.map(|path| path.display().to_string())),
),
("in_git_repo".to_string(), json!(in_repo)),
("git_branch".to_string(), json!(context.git_branch)),
(
"git_state".to_string(),
json!(context.git_summary.headline()),
),
(
"changed_files".to_string(),
json!(context.git_summary.changed_files),
),
(
"memory_file_count".to_string(),
json!(context.memory_file_count),
),
(
"loaded_config_files".to_string(),
json!(context.loaded_config_files),
),
(
"discovered_config_files".to_string(),
json!(context.discovered_config_files),
),
]))
}
fn check_sandbox_health(status: &ninmu_runtime::SandboxStatus) -> DiagnosticCheck {
let degraded = status.enabled && !status.active;
let mut details = vec![
format!("Enabled {}", status.enabled),
format!("Active {}", status.active),
format!("Supported {}", status.supported),
format!("Filesystem mode {}", status.filesystem_mode.as_str()),
format!("Filesystem live {}", status.filesystem_active),
];
if let Some(reason) = &status.fallback_reason {
details.push(format!("Fallback reason {reason}"));
}
DiagnosticCheck::new(
"Sandbox",
if degraded {
DiagnosticLevel::Warn
} else {
DiagnosticLevel::Ok
},
if degraded {
"sandbox was requested but is not currently active"
} else if status.active {
"sandbox protections are active"
} else {
"sandbox is not active for this session"
},
)
.with_details(details)
.with_data(Map::from_iter([
("enabled".to_string(), json!(status.enabled)),
("active".to_string(), json!(status.active)),
("supported".to_string(), json!(status.supported)),
(
"namespace_supported".to_string(),
json!(status.namespace_supported),
),
(
"namespace_active".to_string(),
json!(status.namespace_active),
),
(
"network_supported".to_string(),
json!(status.network_supported),
),
("network_active".to_string(), json!(status.network_active)),
(
"filesystem_mode".to_string(),
json!(status.filesystem_mode.as_str()),
),
(
"filesystem_active".to_string(),
json!(status.filesystem_active),
),
("allowed_mounts".to_string(), json!(status.allowed_mounts)),
("in_container".to_string(), json!(status.in_container)),
(
"container_markers".to_string(),
json!(status.container_markers),
),
("fallback_reason".to_string(), json!(status.fallback_reason)),
]))
}
fn check_system_health(
cwd: &Path,
config: Option<&ninmu_runtime::RuntimeConfig>,
) -> DiagnosticCheck {
let default_model = config.and_then(ninmu_runtime::RuntimeConfig::model);
let mut details = vec![
format!("OS {} {}", env::consts::OS, env::consts::ARCH),
format!("Working dir {}", cwd.display()),
format!("Version {}", VERSION),
format!("Build target {}", BUILD_TARGET.unwrap_or("<unknown>")),
format!("Git SHA {}", GIT_SHA.unwrap_or("<unknown>")),
];
if let Some(model) = default_model {
details.push(format!("Default model {model}"));
}
DiagnosticCheck::new(
"System",
DiagnosticLevel::Ok,
"captured local runtime metadata",
)
.with_details(details)
.with_data(Map::from_iter([
("os".to_string(), json!(env::consts::OS)),
("arch".to_string(), json!(env::consts::ARCH)),
("working_dir".to_string(), json!(cwd.display().to_string())),
("version".to_string(), json!(VERSION)),
("build_target".to_string(), json!(BUILD_TARGET)),
("git_sha".to_string(), json!(GIT_SHA)),
("default_model".to_string(), json!(default_model)),
]))
}
fn version_json_value() -> serde_json::Value {
json!({
"kind": "version",
"message": render_version_report(),
"version": VERSION,
"git_sha": GIT_SHA,
"target": BUILD_TARGET,
})
}
pub(crate) fn render_version_report() -> String {
let git_sha = GIT_SHA.unwrap_or("unknown");
let target = BUILD_TARGET.unwrap_or("unknown");
format!(
"Ninmu Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}"
)
}
pub(crate) fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let report = crate::init::initialize_repo(&cwd)?;
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&init_json_value(&report, &message))?
),
}
Ok(())
}
pub(crate) fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let mut buffer = Vec::new();
print_help_to(&mut buffer)?;
let message = String::from_utf8(buffer)?;
match output_format {
CliOutputFormat::Text => print!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "help",
"message": message,
}))?
),
}
Ok(())
}
pub(crate) fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
Ok(crate::init::initialize_repo(&cwd)?.render())
}
pub(crate) const STUB_COMMANDS: &[&str] = &[
"login",
"logout",
"vim",
"upgrade",
"share",
"feedback",
"files",
"fast",
"exit",
"summary",
"desktop",
"brief",
"advisor",
"stickers",
"insights",
"thinkback",
"release-notes",
"security-review",
"keybindings",
"privacy-settings",
"plan",
"review",
"tasks",
"theme",
"voice",
"usage",
"rename",
"copy",
"hooks",
"context",
"color",
"effort",
"branch",
"rewind",
"ide",
"tag",
"output-style",
"add-dir",
"allowed-tools",
"bookmarks",
"workspace",
"reasoning",
"budget",
"rate-limit",
"changelog",
"diagnostics",
"metrics",
"tool-details",
"focus",
"unfocus",
"pin",
"unpin",
"language",
"profile",
"max-tokens",
"temperature",
"system-prompt",
"notifications",
"telemetry",
"env",
"project",
"terminal-setup",
"api-key",
"reset",
"undo",
"stop",
"retry",
"paste",
"screenshot",
"image",
"search",
"listen",
"speak",
"format",
"test",
"lint",
"build",
"run",
"git",
"stash",
"blame",
"log",
"cron",
"team",
"benchmark",
"migrate",
"templates",
"explain",
"refactor",
"docs",
"fix",
"perf",
"chat",
"web",
"map",
"symbols",
"references",
"definition",
"hover",
"autofix",
"multi",
"macro",
"alias",
"parallel",
"subagent",
"agent",
];