Add useful config subviews without fake mutation flows

Extend /config so operators can inspect specific merged sections like env, hooks, and model while keeping the command read-only and grounded in the actual loaded config. This improves Claude Code-style inspectability without inventing an unsafe config editing surface.

Constraint: Config handling must remain read-only and reflect only the merged runtime config that already exists
Rejected: Add /config set mutation commands | persistence semantics and edit safety are not mature enough for a small honest slice
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep config subviews aligned with real merged keys and avoid advertising writable behavior until persistence is designed
Tested: cargo fmt --manifest-path ./rust/Cargo.toml --all; cargo clippy --manifest-path ./rust/Cargo.toml --workspace --all-targets -- -D warnings; cargo test --manifest-path ./rust/Cargo.toml --workspace
Not-tested: Manual inspection of richer hooks/env config payloads in a customized user setup
This commit is contained in:
Yeachan-Heo
2026-03-31 21:11:57 +00:00
parent 88cd2e31df
commit 9f3be03463
2 changed files with 76 additions and 17 deletions

View File

@@ -89,8 +89,8 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
}, },
SlashCommandSpec { SlashCommandSpec {
name: "config", name: "config",
summary: "Inspect discovered Claude config files", summary: "Inspect Claude config files or merged sections",
argument_hint: None, argument_hint: Some("[env|hooks|model]"),
resume_supported: true, resume_supported: true,
}, },
SlashCommandSpec { SlashCommandSpec {
@@ -117,7 +117,7 @@ pub enum SlashCommand {
Clear { confirm: bool }, Clear { confirm: bool },
Cost, Cost,
Resume { session_path: Option<String> }, Resume { session_path: Option<String> },
Config, Config { section: Option<String> },
Memory, Memory,
Init, Init,
Unknown(String), Unknown(String),
@@ -150,7 +150,9 @@ impl SlashCommand {
"resume" => Self::Resume { "resume" => Self::Resume {
session_path: parts.next().map(ToOwned::to_owned), session_path: parts.next().map(ToOwned::to_owned),
}, },
"config" => Self::Config, "config" => Self::Config {
section: parts.next().map(ToOwned::to_owned),
},
"memory" => Self::Memory, "memory" => Self::Memory,
"init" => Self::Init, "init" => Self::Init,
other => Self::Unknown(other.to_string()), other => Self::Unknown(other.to_string()),
@@ -230,7 +232,7 @@ pub fn handle_slash_command(
| SlashCommand::Clear { .. } | SlashCommand::Clear { .. }
| SlashCommand::Cost | SlashCommand::Cost
| SlashCommand::Resume { .. } | SlashCommand::Resume { .. }
| SlashCommand::Config | SlashCommand::Config { .. }
| SlashCommand::Memory | SlashCommand::Memory
| SlashCommand::Init | SlashCommand::Init
| SlashCommand::Unknown(_) => None, | SlashCommand::Unknown(_) => None,
@@ -280,7 +282,16 @@ mod tests {
session_path: Some("session.json".to_string()), session_path: Some("session.json".to_string()),
}) })
); );
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); assert_eq!(
SlashCommand::parse("/config"),
Some(SlashCommand::Config { section: None })
);
assert_eq!(
SlashCommand::parse("/config env"),
Some(SlashCommand::Config {
section: Some("env".to_string())
})
);
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
} }
@@ -297,7 +308,7 @@ mod tests {
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
assert_eq!(slash_command_specs().len(), 11); assert_eq!(slash_command_specs().len(), 11);
@@ -370,5 +381,8 @@ mod tests {
) )
.is_none()); .is_none());
assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
);
} }
} }

View File

@@ -446,9 +446,9 @@ fn run_resume_command(
message: Some(format_cost_report(usage)), message: Some(format_cost_report(usage)),
}) })
} }
SlashCommand::Config => Ok(ResumeCommandOutcome { SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(render_config_report()?), message: Some(render_config_report(section.as_deref())?),
}), }),
SlashCommand::Memory => Ok(ResumeCommandOutcome { SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
@@ -554,7 +554,7 @@ impl LiveCli {
SlashCommand::Clear { confirm } => self.clear_session(confirm)?, SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
SlashCommand::Cost => self.print_cost(), SlashCommand::Cost => self.print_cost(),
SlashCommand::Resume { session_path } => self.resume_session(session_path)?, SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
SlashCommand::Config => Self::print_config()?, SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
SlashCommand::Memory => Self::print_memory()?, SlashCommand::Memory => Self::print_memory()?,
SlashCommand::Init => Self::run_init()?, SlashCommand::Init => Self::run_init()?,
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
@@ -708,8 +708,8 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn print_config() -> Result<(), Box<dyn std::error::Error>> { fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_config_report()?); println!("{}", render_config_report(section)?);
Ok(()) Ok(())
} }
@@ -830,7 +830,7 @@ fn format_status_report(
) )
} }
fn render_config_report() -> Result<String, Box<dyn std::error::Error>> { fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd); let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover(); let discovered = loader.discover();
@@ -868,6 +868,36 @@ fn render_config_report() -> Result<String, Box<dyn std::error::Error>> {
entry.path.display() 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"),
other => {
lines.push(format!(
" Unsupported config section '{other}'. Use env, hooks, or model."
));
return Ok(lines.join(
"
",
));
}
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
));
return Ok(lines.join(
"
",
));
}
lines.push("Merged JSON".to_string()); lines.push("Merged JSON".to_string());
lines.push(format!(" {}", runtime_config.as_json().render())); lines.push(format!(" {}", runtime_config.as_json().render()));
Ok(lines.join( Ok(lines.join(
@@ -1340,7 +1370,7 @@ mod tests {
format_cost_report, format_model_report, format_model_switch_report, format_cost_report, format_model_report, format_model_switch_report,
format_permissions_report, format_permissions_switch_report, format_resume_report, format_permissions_report, format_permissions_switch_report, format_resume_report,
format_status_report, normalize_permission_mode, parse_args, parse_git_status_metadata, format_status_report, normalize_permission_mode, parse_args, parse_git_status_metadata,
render_init_claude_md, render_memory_report, render_repl_help, render_config_report, render_init_claude_md, render_memory_report, render_repl_help,
resume_supported_slash_commands, status_context, CliAction, SlashCommand, StatusUsage, resume_supported_slash_commands, status_context, CliAction, SlashCommand, StatusUsage,
DEFAULT_MODEL, DEFAULT_MODEL,
}; };
@@ -1447,7 +1477,7 @@ mod tests {
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
assert!(help.contains("/exit")); assert!(help.contains("/exit"));
@@ -1571,6 +1601,12 @@ mod tests {
assert!(status.contains("Memory files 4")); assert!(status.contains("Memory files 4"));
} }
#[test]
fn config_report_supports_section_views() {
let report = render_config_report(Some("env")).expect("config report should render");
assert!(report.contains("Merged section: env"));
}
#[test] #[test]
fn memory_report_uses_sectioned_layout() { fn memory_report_uses_sectioned_layout() {
let report = render_memory_report().expect("memory report should render"); let report = render_memory_report().expect("memory report should render");
@@ -1582,7 +1618,7 @@ mod tests {
#[test] #[test]
fn config_report_uses_sectioned_layout() { fn config_report_uses_sectioned_layout() {
let report = super::render_config_report().expect("config report should render"); let report = render_config_report(None).expect("config report should render");
assert!(report.contains("Config")); assert!(report.contains("Config"));
assert!(report.contains("Discovered files")); assert!(report.contains("Discovered files"));
assert!(report.contains("Merged JSON")); assert!(report.contains("Merged JSON"));
@@ -1644,7 +1680,16 @@ mod tests {
SlashCommand::parse("/clear --confirm"), SlashCommand::parse("/clear --confirm"),
Some(SlashCommand::Clear { confirm: true }) Some(SlashCommand::Clear { confirm: true })
); );
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); assert_eq!(
SlashCommand::parse("/config"),
Some(SlashCommand::Config { section: None })
);
assert_eq!(
SlashCommand::parse("/config env"),
Some(SlashCommand::Config {
section: Some("env".to_string())
})
);
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
} }