diff --git a/src/components.rs b/src/components.rs index e1317609..c79590ef 100644 --- a/src/components.rs +++ b/src/components.rs @@ -50,7 +50,7 @@ fn claude_json() -> Value { fn json_at(path: &PathBuf) -> Value { fs::read_to_string(path) .ok() - .and_then(|s| serde_json::from_str(&s).ok()) + .and_then(|s| crate::jsonc::parse_jsonc(&s).ok()) .unwrap_or(Value::Null) } @@ -83,7 +83,7 @@ fn write_pinned_mode(path: &PathBuf) -> bool { fn merge_json(path: &PathBuf, root_key: &str, key: &str, value: Value) -> bool { let mut existing: Value = fs::read_to_string(path) .ok() - .and_then(|s| serde_json::from_str(&s).ok()) + .and_then(|s| crate::jsonc::parse_jsonc(&s).ok()) .unwrap_or_else(|| serde_json::json!({})); if !existing.is_object() { existing = serde_json::json!({}); @@ -106,7 +106,7 @@ fn merge_json(path: &PathBuf, root_key: &str, key: &str, value: Value) -> bool { fn merge_opencode_mcp(path: &PathBuf, key: &str, entry: Value) -> bool { let mut existing: Value = fs::read_to_string(path) .ok() - .and_then(|s| serde_json::from_str(&s).ok()) + .and_then(|s| crate::jsonc::parse_jsonc(&s).ok()) .unwrap_or_else(|| serde_json::json!({})); if !existing.is_object() { existing = serde_json::json!({}); @@ -812,6 +812,48 @@ mod tests { }); } + #[test] + fn agentflare_mcp_opencode_apply_survives_jsonc_comments_and_trailing_comma() { + crate::paths::test_support::with_temp_home(|| { + let path = home() + .join(".config") + .join("opencode") + .join("opencode.jsonc"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + // Real jsonc: comments + trailing comma + an existing mcp server + // entry that must survive. Before the jsonc parser, this parse + // failure was treated as "no existing config" and everything + // below (including `other-server`) was silently dropped on write. + fs::write( + &path, + r#"{ + // OpenCode configuration + "mcp": { + "other-server": { "type": "local", "command": ["other-server"] }, + }, +}"#, + ) + .unwrap(); + + let components = get_components("opencode"); + let agentflare_mcp = components + .iter() + .find(|c| c.id == "agentflare-mcp") + .unwrap(); + (agentflare_mcp.apply)(); + + let value = json_at(&path); + assert!( + value["mcp"]["other-server"].is_object(), + "existing entry must survive" + ); + assert!( + value["mcp"]["flare"].is_object(), + "flare entry must be added" + ); + }); + } + #[test] fn agentflare_mcp_windsurf_check_then_apply_then_check() { crate::paths::test_support::with_temp_home(|| { diff --git a/src/init.rs b/src/init.rs index 83d9b722..c7143926 100644 --- a/src/init.rs +++ b/src/init.rs @@ -510,7 +510,7 @@ fn wire_opencode() { let mut config: Value = fs::read_to_string(&path) .ok() - .and_then(|s| serde_json::from_str(&s).ok()) + .and_then(|s| crate::jsonc::parse_jsonc(&s).ok()) .unwrap_or_else(|| json!({})); if !config.is_object() { config = json!({}); @@ -1015,6 +1015,44 @@ mod tests { }); } + #[test] + fn wire_opencode_preserves_existing_content_with_comments() { + with_temp_home(|| { + let config_path = home() + .join(".config") + .join("opencode") + .join("opencode.jsonc"); + let rules_dir = home().join(".config").join("opencode").join("rules"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + // Real jsonc: comments + a trailing comma + an existing mcp + // server entry that must survive. Before the jsonc parser, this + // parse failure was treated as "no existing config" and every + // key below (including `mcp`) was silently dropped on write. + fs::write( + &config_path, + r#"{ + // OpenCode configuration + "mcp": { + "some-server": { "type": "local", "command": ["some-server"] } + }, +}"#, + ) + .unwrap(); + fs::create_dir_all(&rules_dir).unwrap(); + fs::write(rules_dir.join("exa.md"), "# exa\n").unwrap(); + + wire_opencode(); + + let content = fs::read_to_string(&config_path).unwrap(); + let parsed: Value = crate::jsonc::parse_jsonc(&content).unwrap(); + assert!( + parsed["mcp"]["some-server"].is_object(), + "existing mcp server entry must survive: {content}" + ); + assert!(content.contains("exa.md")); + }); + } + #[test] fn wire_opencode_removes_legacy_engram_instruction_on_upgrade() { with_temp_home(|| { diff --git a/src/jsonc.rs b/src/jsonc.rs new file mode 100644 index 00000000..4de4e0b7 --- /dev/null +++ b/src/jsonc.rs @@ -0,0 +1,369 @@ +use serde_json::Value; + +/// Strip `//` line comments, `/* */` block comments and trailing commas from +/// JSONC, then parse with serde_json. String contents are preserved verbatim. +/// +/// Several agent config files agentflare reads/writes (`opencode.jsonc`, +/// editor `settings.json`/`mcp.json` dialects) are JSONC, not strict JSON — +/// plain `serde_json::from_str` silently fails on them (comments/trailing +/// commas are syntax errors in strict JSON), which upstream call sites were +/// treating as "file doesn't exist yet" and overwriting. +pub fn parse_jsonc(input: &str) -> Result { + let stripped = strip_json_comments(input); + let cleaned = strip_trailing_commas(&stripped); + serde_json::from_str(&cleaned) +} + +fn strip_json_comments(input: &str) -> String { + let bytes = input.as_bytes(); + let len = bytes.len(); + let mut out = String::with_capacity(len); + let mut i = 0; + let mut seg = 0; + + while i < len { + let b = bytes[i]; + + if b == b'"' { + i += 1; + while i < len { + let c = bytes[i]; + i += 1; + if c == b'\\' && i < len { + i += 1; + } else if c == b'"' { + break; + } + } + continue; + } + + if b == b'/' && i + 1 < len { + if bytes[i + 1] == b'/' { + out.push_str(&input[seg..i]); + i += 2; + while i < len && bytes[i] != b'\n' { + i += 1; + } + seg = i; + continue; + } + if bytes[i + 1] == b'*' { + out.push_str(&input[seg..i]); + i += 2; + while i + 1 < len { + if bytes[i] == b'*' && bytes[i + 1] == b'/' { + i += 2; + break; + } + i += 1; + } + seg = i; + continue; + } + } + + i += 1; + } + + out.push_str(&input[seg..]); + out +} + +/// Remove trailing commas that appear before a closing `}` or `]`. +/// String contents are preserved verbatim (commas inside strings are ignored). +/// +/// Operates on already comment-stripped input. Uses byte-segment copying so +/// multi-byte UTF-8 sequences are never split (all decision bytes are ASCII). +fn strip_trailing_commas(input: &str) -> String { + let bytes = input.as_bytes(); + let len = bytes.len(); + let mut out = String::with_capacity(len); + let mut i = 0; + let mut seg = 0; + + while i < len { + let b = bytes[i]; + + if b == b'"' { + i += 1; + while i < len { + let c = bytes[i]; + i += 1; + if c == b'\\' && i < len { + i += 1; + } else if c == b'"' { + break; + } + } + continue; + } + + if b == b',' { + let mut j = i + 1; + while j < len && bytes[j].is_ascii_whitespace() { + j += 1; + } + if j < len && (bytes[j] == b'}' || bytes[j] == b']') { + out.push_str(&input[seg..i]); + i += 1; + seg = i; + continue; + } + } + + i += 1; + } + + out.push_str(&input[seg..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_line_comments() { + let input = r#"{ + // this is a comment + "key": "value" +}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["key"], "value"); + } + + #[test] + fn strips_block_comments() { + let input = r#"{ + /* block + comment */ + "key": "value" +}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["key"], "value"); + } + + #[test] + fn preserves_slashes_in_strings() { + let input = r#"{"url": "https://example.com/path"}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["url"], "https://example.com/path"); + } + + #[test] + fn preserves_comment_like_content_in_strings() { + let input = r#"{"note": "see // inline", "code": "/* not a comment */"}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["note"], "see // inline"); + assert_eq!(v["code"], "/* not a comment */"); + } + + #[test] + fn handles_escaped_quotes_in_strings() { + let input = r#"{"msg": "say \"hello\" // world"}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["msg"], r#"say "hello" // world"#); + } + + #[test] + fn handles_trailing_comma_free_json() { + let input = r#"{ + "a": 1, + // comment between entries + "b": 2 +}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["a"], 1); + assert_eq!(v["b"], 2); + } + + #[test] + fn empty_input() { + assert!(parse_jsonc("").is_err()); + } + + // --- trailing comma support (VS Code / JSONC dialect) --- + + #[test] + fn strips_trailing_comma_in_object() { + let input = r#"{ + "a": 1, + "b": 2, +}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["a"], 1); + assert_eq!(v["b"], 2); + } + + #[test] + fn strips_trailing_comma_in_array() { + let input = r#"{"list": [1, 2, 3,]}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["list"][2], 3); + } + + #[test] + fn strips_trailing_comma_with_whitespace_and_newlines() { + let input = "{\n \"a\": 1 ,\n\n}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["a"], 1); + } + + #[test] + fn strips_nested_trailing_commas() { + let input = r#"{ + "outer": { + "inner": [ + "x", + "y", + ], + }, +}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["outer"]["inner"][1], "y"); + } + + #[test] + fn preserves_comma_inside_string_before_brace() { + // A comma inside a string value must not be treated as trailing. + let input = r#"{"msg": "hello, world"}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["msg"], "hello, world"); + } + + #[test] + fn vscode_settings_with_trailing_comma_and_comments() { + // Mirrors a real VS Code user settings.json: trailing comma plus + // JSONC comments, both invalid in strict JSON. + let input = r#"{ + // editor settings + "editor.fontSize": 14, + "editor.tabSize": 2, + "chat.mcp.enabled": true, +}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["editor.fontSize"], 14); + assert!(v["chat.mcp.enabled"].as_bool().unwrap()); + } + + #[test] + fn pure_json_passthrough() { + let input = r#"{"key": "value", "num": 42}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["key"], "value"); + assert_eq!(v["num"], 42); + } + + #[test] + fn real_opencode_config_with_comments() { + let input = r#"{ + // OpenCode configuration + "$schema": "https://opencode.ai/config.json", + "mcp": { + /* existing tool */ + "my-tool": { + "type": "local", + "command": ["my-tool"], + "enabled": true + } + } +}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["$schema"], "https://opencode.ai/config.json"); + assert!(v["mcp"]["my-tool"]["enabled"].as_bool().unwrap()); + } + + #[test] + fn utf8_umlauts_preserved() { + let input = "{\n // German names\n \"name\": \"Müller\",\n \"city\": \"Zürich\"\n}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["name"], "Müller"); + assert_eq!(v["city"], "Zürich"); + } + + #[test] + fn utf8_cjk_with_block_comment() { + let input = "{\n /* 日本語コメント */\n \"desc\": \"日本語テスト\"\n}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["desc"], "日本語テスト"); + } + + #[test] + fn utf8_emoji_between_comments() { + let input = "{\n // before\n \"icon\": \"🚀🔥\",\n /* after */\n \"ok\": true\n}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["icon"], "🚀🔥"); + assert!(v["ok"].as_bool().unwrap()); + } + + #[test] + fn utf8_in_comment_stripped_cleanly() { + let input = "{\n // Achtung: ä ö ü ß\n \"key\": \"value\"\n}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["key"], "value"); + } + + #[test] + fn utf8_in_key() { + let input = "{\"straße\": \"Hauptstraße 42\"}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["straße"], "Hauptstraße 42"); + } + + #[test] + fn mixed_ascii_and_utf8_values() { + let input = "{\n // config\n \"en\": \"hello\",\n \"ru\": \"привет\",\n \"jp\": \"こんにちは\"\n}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["en"], "hello"); + assert_eq!(v["ru"], "привет"); + assert_eq!(v["jp"], "こんにちは"); + } + + #[test] + fn escaped_unicode_unchanged() { + let input = "{\"test\": \"\\u00e4\\u00f6\\u00fc\"}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["test"], "\u{00e4}\u{00f6}\u{00fc}"); + } + + #[test] + fn utf8_at_comment_boundary() { + let input = "{\n \"before\": \"текст\"// комментарий\n, \"after\": 1\n}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["before"], "текст"); + assert_eq!(v["after"], 1); + } + + #[test] + fn empty_string_after_utf8_comment() { + let input = "{\n // Ü\n \"key\": \"\"\n}"; + let v = parse_jsonc(input).unwrap(); + assert_eq!(v["key"], ""); + } + + #[test] + fn real_opencode_config_survives_roundtrip_with_existing_content() { + // Regression guard for the actual bug: a jsonc `opencode.jsonc` with + // comments must not be silently replaced with an empty object by + // any caller treating a parse failure as "no config yet". + let input = r#"{ + // OpenCode config + "mcpServers": { + "lean-ctx": { + "command": "/usr/local/bin/lean-ctx", + "args": ["--project", "/home/user/project"] + } + }, + "model": "anthropic/claude-sonnet-5", +}"#; + let v = parse_jsonc(input).unwrap(); + assert_eq!( + v["mcpServers"]["lean-ctx"]["command"], + "/usr/local/bin/lean-ctx" + ); + let args = v["mcpServers"]["lean-ctx"]["args"].as_array().unwrap(); + assert_eq!(args[1], "/home/user/project"); + assert_eq!(v["model"], "anthropic/claude-sonnet-5"); + } +} diff --git a/src/main.rs b/src/main.rs index 1387e37d..47788166 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ mod gateway_secrets; mod hook; mod hook_redirect; mod init; +mod jsonc; mod mcp_prompts; mod mcp_server; mod memory;