mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-04 21:47:10 +08:00
fix: report config file load statuses
This commit is contained in:
@@ -55,8 +55,8 @@ use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||
use 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, BaseCommitState, CompactionConfig, ConfigLoader,
|
||||
ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, McpServer,
|
||||
ApiClient, ApiRequest, AssistantEvent, BaseCommitState, CompactionConfig, ConfigFileReport,
|
||||
ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, McpServer,
|
||||
McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
|
||||
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
|
||||
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||
@@ -417,6 +417,9 @@ fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> {
|
||||
"missing_credentials" => {
|
||||
Some("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN before running claw.")
|
||||
}
|
||||
"config_parse_error" => Some(
|
||||
"Fix the JSON syntax or schema in the referenced .claw/settings.json or .claw.json file, then rerun the command.",
|
||||
),
|
||||
// #787: session load failures have no \n-delimited hint from the OS error path
|
||||
"session_load_failed" => Some(
|
||||
"Pass a path to a .jsonl session file, not a directory. Managed sessions live in .claw/sessions/.",
|
||||
@@ -8483,38 +8486,28 @@ fn render_config_json(
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
let discovered = loader.discover();
|
||||
// #773: use load_collecting_warnings so deprecation warnings are surfaced in the
|
||||
// JSON envelope instead of only as unstructured stderr text.
|
||||
let (runtime_config, config_warnings) = loader.load_collecting_warnings()?;
|
||||
|
||||
let loaded_paths: Vec<_> = runtime_config
|
||||
.loaded_entries()
|
||||
// #773: keep deprecation warnings in the JSON envelope, and #407: include
|
||||
// per-file status/reason/detail for every discovered config path.
|
||||
let inspection = loader.inspect_collecting_warnings();
|
||||
if section.is_some() {
|
||||
if let Some(error) = &inspection.load_error {
|
||||
return Err(error.clone().into());
|
||||
}
|
||||
}
|
||||
let runtime_config = inspection
|
||||
.runtime_config
|
||||
.clone()
|
||||
.unwrap_or_else(runtime::RuntimeConfig::empty);
|
||||
let loaded_files = runtime_config.loaded_entries().len();
|
||||
let merged_keys = runtime_config.merged().len();
|
||||
let files: Vec<_> = inspection
|
||||
.files
|
||||
.iter()
|
||||
.map(|e| e.path.display().to_string())
|
||||
.map(config_file_report_json)
|
||||
.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();
|
||||
|
||||
let warnings_json: Vec<serde_json::Value> = config_warnings
|
||||
let warnings_json: Vec<serde_json::Value> = inspection
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|w| serde_json::Value::String(w.clone()))
|
||||
.collect();
|
||||
@@ -8522,14 +8515,15 @@ fn render_config_json(
|
||||
let base = serde_json::json!({
|
||||
"kind": "config",
|
||||
"action": if section.is_some() { "show" } else { "list" },
|
||||
"status": "ok",
|
||||
"status": if inspection.load_error.is_some() { "error" } else { "ok" },
|
||||
"cwd": cwd.display().to_string(),
|
||||
"loaded_files": loaded_paths.len(),
|
||||
"merged_keys": runtime_config.merged().len(),
|
||||
"loaded_files": loaded_files,
|
||||
"merged_keys": merged_keys,
|
||||
"merged_key_count": merged_keys,
|
||||
"merged_keys_meaning": "count of top-level keys in the effective merged JSON object",
|
||||
"files": files,
|
||||
// #773: deprecation warnings surfaced structurally so JSON-mode callers
|
||||
// don't need to strip unstructured text from stderr
|
||||
"warnings": warnings_json,
|
||||
"load_error": inspection.load_error.clone(),
|
||||
});
|
||||
|
||||
if let Some(section) = section {
|
||||
@@ -8576,8 +8570,8 @@ fn render_config_json(
|
||||
"hint": hint,
|
||||
"supported_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents", "settings"],
|
||||
"cwd": cwd.display().to_string(),
|
||||
"loaded_files": loaded_paths.len(),
|
||||
"files": files,
|
||||
"loaded_files": loaded_files,
|
||||
"files": base["files"].clone(),
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -8600,6 +8594,45 @@ fn render_config_json(
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
fn config_file_report_json(file: &ConfigFileReport) -> serde_json::Value {
|
||||
let source = match file.entry.source {
|
||||
ConfigSource::User => "user",
|
||||
ConfigSource::Project => "project",
|
||||
ConfigSource::Local => "local",
|
||||
};
|
||||
let mut object = serde_json::Map::new();
|
||||
object.insert(
|
||||
"path".to_string(),
|
||||
serde_json::Value::String(file.entry.path.display().to_string()),
|
||||
);
|
||||
object.insert(
|
||||
"source".to_string(),
|
||||
serde_json::Value::String(source.to_string()),
|
||||
);
|
||||
object.insert("loaded".to_string(), serde_json::Value::Bool(file.loaded));
|
||||
object.insert(
|
||||
"status".to_string(),
|
||||
serde_json::Value::String(file.status.as_str().to_string()),
|
||||
);
|
||||
if let Some(reason) = &file.reason {
|
||||
object.insert(
|
||||
"reason".to_string(),
|
||||
serde_json::Value::String(reason.clone()),
|
||||
);
|
||||
object.insert(
|
||||
"skip_reason".to_string(),
|
||||
serde_json::Value::String(reason.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(detail) = &file.detail {
|
||||
object.insert(
|
||||
"detail".to_string(),
|
||||
serde_json::Value::String(detail.clone()),
|
||||
);
|
||||
}
|
||||
serde_json::Value::Object(object)
|
||||
}
|
||||
|
||||
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
|
||||
|
||||
@@ -1458,6 +1458,114 @@ fn config_json_reports_deprecations_structurally_without_stderr_duplicate_815()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_json_reports_structured_unloaded_file_reasons_407() {
|
||||
let root = unique_temp_dir("config-file-status-407");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(root.join(".claw")).expect("workspace config should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::write(root.join(".claw.json"), "{not json").expect("legacy skip fixture should write");
|
||||
fs::write(
|
||||
root.join(".claw").join("settings.json"),
|
||||
r#"{"model":"opus"}"#,
|
||||
)
|
||||
.expect("project config fixture should write");
|
||||
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
];
|
||||
let output = run_claw(&root, &["--output-format", "json", "config"], &envs);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let parsed: Value = serde_json::from_slice(&output.stdout).expect("stdout valid json");
|
||||
|
||||
assert_eq!(parsed["kind"], "config");
|
||||
assert_eq!(parsed["status"], "ok");
|
||||
assert_eq!(parsed["loaded_files"], 1);
|
||||
assert_eq!(parsed["merged_keys"], parsed["merged_key_count"]);
|
||||
assert_eq!(
|
||||
parsed["merged_keys_meaning"].as_str(),
|
||||
Some("count of top-level keys in the effective merged JSON object")
|
||||
);
|
||||
assert!(parsed["load_error"].is_null());
|
||||
|
||||
let files = parsed["files"].as_array().expect("files array");
|
||||
let loaded = files
|
||||
.iter()
|
||||
.find(|file| file["loaded"] == true)
|
||||
.expect("loaded config file");
|
||||
assert_eq!(loaded["status"], "loaded");
|
||||
assert!(loaded.get("reason").is_none());
|
||||
let missing = files
|
||||
.iter()
|
||||
.find(|file| file["status"] == "not_found")
|
||||
.expect("missing config file");
|
||||
assert_eq!(missing["loaded"], false);
|
||||
assert_eq!(missing["reason"], "not_found");
|
||||
assert_eq!(missing["skip_reason"], "not_found");
|
||||
let skipped = files
|
||||
.iter()
|
||||
.find(|file| file["status"] == "skipped")
|
||||
.expect("skipped legacy config file");
|
||||
assert_eq!(skipped["loaded"], false);
|
||||
assert_eq!(skipped["reason"], "legacy_invalid_json");
|
||||
assert_eq!(skipped["skip_reason"], "legacy_invalid_json");
|
||||
assert!(skipped["detail"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_json_list_reports_parse_errors_without_dropping_file_statuses_407() {
|
||||
let root = unique_temp_dir("config-file-load-error-407");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(root.join(".claw")).expect("workspace config should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::write(config_home.join("settings.json"), r#"{"model":"sonnet"}"#)
|
||||
.expect("user config fixture should write");
|
||||
fs::write(root.join(".claw").join("settings.json"), "{not json")
|
||||
.expect("invalid project config fixture should write");
|
||||
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
];
|
||||
let output = run_claw(&root, &["--output-format", "json", "config"], &envs);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"config list should be best-effort even with one parse-broken file; stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let parsed: Value = serde_json::from_slice(&output.stdout).expect("stdout valid json");
|
||||
|
||||
assert_eq!(parsed["status"], "error");
|
||||
assert!(parsed["load_error"].as_str().is_some());
|
||||
assert_eq!(parsed["loaded_files"], 1);
|
||||
let files = parsed["files"].as_array().expect("files array");
|
||||
let error_file = files
|
||||
.iter()
|
||||
.find(|file| file["status"] == "load_error")
|
||||
.expect("load error config file");
|
||||
assert_eq!(error_file["loaded"], false);
|
||||
assert_eq!(error_file["reason"], "parse_error");
|
||||
assert_eq!(error_file["skip_reason"], "parse_error");
|
||||
assert!(error_file["detail"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_json_surfaces_suppress_config_deprecation_stderr_810_821_824() {
|
||||
let root = unique_temp_dir("global-json-warning-810-821-824");
|
||||
|
||||
Reference in New Issue
Block a user