Skip to main content

lean_ctx/core/editor_registry/
plan_mode.rs

1use serde_json::Value;
2
3/// Core read-only tools exposed to IDE plan modes.
4/// Kept as a curated subset (not all readonly tools) to avoid
5/// overwhelming plan agents with architecture/debug tools.
6pub fn plan_mode_tools() -> &'static [&'static str] {
7    &[
8        "ctx_read",
9        "ctx_search",
10        "ctx_tree",
11        "ctx_overview",
12        "ctx_plan",
13        "ctx_metrics",
14        "ctx_compress",
15        "ctx_session",
16        "ctx_knowledge",
17        "ctx_graph",
18        "ctx_retrieve",
19        "ctx_provider",
20    ]
21}
22
23fn claude_plan_permissions() -> Vec<String> {
24    plan_mode_tools()
25        .iter()
26        .map(|tool| format!("mcp__lean-ctx__{tool}"))
27        .collect()
28}
29
30fn vscode_plan_tool_ids() -> Vec<String> {
31    plan_mode_tools()
32        .iter()
33        .map(|t| format!("lean-ctx_{t}"))
34        .collect()
35}
36
37pub fn vscode_settings_path() -> Option<std::path::PathBuf> {
38    #[cfg(target_os = "macos")]
39    {
40        if let Some(home) = dirs::home_dir() {
41            let p = home.join("Library/Application Support/Code/User/settings.json");
42            if p.parent().is_some_and(std::path::Path::exists) {
43                return Some(p);
44            }
45        }
46    }
47    #[cfg(target_os = "linux")]
48    {
49        if let Some(home) = dirs::home_dir() {
50            let paths = [
51                home.join(".config/Code/User/settings.json"),
52                home.join(".config/Code - Insiders/User/settings.json"),
53                home.join(".vscode-server/data/User/settings.json"),
54            ];
55            for p in paths {
56                if p.parent().is_some_and(std::path::Path::exists) {
57                    return Some(p);
58                }
59            }
60        }
61    }
62    #[cfg(target_os = "windows")]
63    {
64        if let Ok(appdata) = std::env::var("APPDATA") {
65            let p = std::path::PathBuf::from(appdata).join("Code/User/settings.json");
66            if p.parent().is_some_and(std::path::Path::exists) {
67                return Some(p);
68            }
69        }
70    }
71    None
72}
73
74pub fn write_vscode_plan_settings() -> Result<super::WriteResult, String> {
75    let path = vscode_settings_path().ok_or("VS Code settings.json directory not found")?;
76    write_vscode_plan_settings_to(&path)
77}
78
79pub fn write_vscode_plan_settings_to(path: &std::path::Path) -> Result<super::WriteResult, String> {
80    let desired_tools: Value = serde_json::json!(vscode_plan_tool_ids());
81
82    if path.exists() {
83        let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
84        let mut json = crate::core::jsonc::parse_jsonc(&content)
85            .map_err(|e| format!("VS Code settings.json parse error: {e}"))?;
86        let obj = json
87            .as_object_mut()
88            .ok_or("VS Code settings.json root must be an object")?;
89
90        let mut changed = false;
91
92        if obj.get("chat.mcp.enabled") != Some(&Value::Bool(true)) {
93            obj.insert("chat.mcp.enabled".to_string(), Value::Bool(true));
94            changed = true;
95        }
96
97        let key = "github.copilot.chat.planAgent.additionalTools";
98        let existing = obj.get(key);
99        if existing != Some(&desired_tools) {
100            if let Some(existing_arr) = existing.and_then(|v| v.as_array()) {
101                let merged = merge_tool_arrays(existing_arr, &desired_tools);
102                if obj.get(key) != Some(&merged) {
103                    obj.insert(key.to_string(), merged);
104                    changed = true;
105                }
106            } else {
107                obj.insert(key.to_string(), desired_tools);
108                changed = true;
109            }
110        }
111
112        if !changed {
113            return Ok(super::WriteResult {
114                action: super::WriteAction::Already,
115                note: Some("plan mode tools already configured".to_string()),
116            });
117        }
118
119        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
120        crate::config_io::write_atomic_with_backup(path, &formatted)?;
121        return Ok(super::WriteResult {
122            action: super::WriteAction::Updated,
123            note: Some("plan mode tools + chat.mcp.enabled".to_string()),
124        });
125    }
126
127    if let Some(parent) = path.parent() {
128        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
129    }
130    let content = serde_json::to_string_pretty(&serde_json::json!({
131        "chat.mcp.enabled": true,
132        "github.copilot.chat.planAgent.additionalTools": vscode_plan_tool_ids(),
133    }))
134    .map_err(|e| e.to_string())?;
135    crate::config_io::write_atomic_with_backup(path, &content)?;
136    Ok(super::WriteResult {
137        action: super::WriteAction::Created,
138        note: Some("plan mode tools + chat.mcp.enabled".to_string()),
139    })
140}
141
142fn merge_tool_arrays(existing: &[Value], desired: &Value) -> Value {
143    let mut merged: Vec<Value> = existing.to_vec();
144    if let Some(desired_arr) = desired.as_array() {
145        for tool in desired_arr {
146            if !merged.iter().any(|v| v == tool) {
147                merged.push(tool.clone());
148            }
149        }
150    }
151    Value::Array(merged)
152}
153
154fn claude_settings_path() -> Option<std::path::PathBuf> {
155    let home = dirs::home_dir()?;
156    let global = home.join(".claude/settings.json");
157    if global.parent().is_some_and(std::path::Path::exists) {
158        return Some(global);
159    }
160    None
161}
162
163pub fn write_claude_code_plan_permissions() -> Result<super::WriteResult, String> {
164    let path = claude_settings_path().ok_or("~/.claude/ directory not found")?;
165    write_claude_code_plan_permissions_to(&path)
166}
167
168pub fn write_claude_code_plan_permissions_to(
169    path: &std::path::Path,
170) -> Result<super::WriteResult, String> {
171    let plan_perms = claude_plan_permissions();
172
173    if path.exists() {
174        let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
175        let mut json = crate::core::jsonc::parse_jsonc(&content)
176            .map_err(|e| format!("~/.claude/settings.json parse error: {e}"))?;
177        let obj = json
178            .as_object_mut()
179            .ok_or("~/.claude/settings.json root must be an object")?;
180
181        let perms = obj
182            .entry("permissions")
183            .or_insert_with(|| serde_json::json!({}));
184        let perms_obj = perms
185            .as_object_mut()
186            .ok_or("\"permissions\" must be an object")?;
187        let allow = perms_obj
188            .entry("allow")
189            .or_insert_with(|| serde_json::json!([]));
190        let allow_arr = allow
191            .as_array_mut()
192            .ok_or("\"permissions.allow\" must be an array")?;
193
194        let mut changed = false;
195        for perm in &plan_perms {
196            let val = Value::String(perm.clone());
197            if !allow_arr.iter().any(|v| v == &val) {
198                allow_arr.push(val);
199                changed = true;
200            }
201        }
202
203        if !changed {
204            return Ok(super::WriteResult {
205                action: super::WriteAction::Already,
206                note: Some("plan mode permissions already present".to_string()),
207            });
208        }
209
210        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
211        crate::config_io::write_atomic_with_backup(path, &formatted)?;
212        return Ok(super::WriteResult {
213            action: super::WriteAction::Updated,
214            note: Some("plan mode permissions added".to_string()),
215        });
216    }
217
218    if let Some(parent) = path.parent() {
219        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
220    }
221    let content = serde_json::to_string_pretty(&serde_json::json!({
222        "permissions": {
223            "allow": plan_perms,
224        }
225    }))
226    .map_err(|e| e.to_string())?;
227    crate::config_io::write_atomic_with_backup(path, &content)?;
228    Ok(super::WriteResult {
229        action: super::WriteAction::Created,
230        note: Some("plan mode permissions created".to_string()),
231    })
232}
233
234#[derive(Debug)]
235pub struct PlanModeStatus {
236    pub vscode_configured: Option<bool>,
237    pub claude_configured: Option<bool>,
238}
239
240pub fn check_plan_mode_status() -> PlanModeStatus {
241    PlanModeStatus {
242        vscode_configured: check_vscode_plan_mode(),
243        claude_configured: check_claude_plan_mode(),
244    }
245}
246
247pub fn check_plan_mode_status_for_paths(
248    vscode_path: Option<&std::path::Path>,
249    claude_path: Option<&std::path::Path>,
250) -> PlanModeStatus {
251    PlanModeStatus {
252        vscode_configured: vscode_path.map(check_settings_file_vscode),
253        claude_configured: claude_path.map(check_settings_file_claude),
254    }
255}
256
257fn check_vscode_plan_mode() -> Option<bool> {
258    let path = vscode_settings_path()?;
259    Some(check_settings_file_vscode(&path))
260}
261
262fn check_settings_file_vscode(path: &std::path::Path) -> bool {
263    if !path.exists() {
264        return false;
265    }
266    let Ok(content) = std::fs::read_to_string(path) else {
267        return false;
268    };
269    let Ok(json) = crate::core::jsonc::parse_jsonc(&content) else {
270        return false;
271    };
272    let Some(obj) = json.as_object() else {
273        return false;
274    };
275
276    let mcp_enabled = obj
277        .get("chat.mcp.enabled")
278        .and_then(Value::as_bool)
279        .unwrap_or(false);
280    let has_tools = obj
281        .get("github.copilot.chat.planAgent.additionalTools")
282        .and_then(|v| v.as_array())
283        .is_some_and(|arr| {
284            arr.iter()
285                .any(|v| v.as_str().is_some_and(|s| s.starts_with("lean-ctx_")))
286        });
287
288    mcp_enabled && has_tools
289}
290
291fn check_claude_plan_mode() -> Option<bool> {
292    let path = claude_settings_path()?;
293    Some(check_settings_file_claude(&path))
294}
295
296fn check_settings_file_claude(path: &std::path::Path) -> bool {
297    if !path.exists() {
298        return false;
299    }
300    let Ok(content) = std::fs::read_to_string(path) else {
301        return false;
302    };
303    let Ok(json) = crate::core::jsonc::parse_jsonc(&content) else {
304        return false;
305    };
306    let Some(obj) = json.as_object() else {
307        return false;
308    };
309
310    obj.get("permissions")
311        .and_then(|v| v.as_object())
312        .and_then(|p| p.get("allow"))
313        .and_then(|v| v.as_array())
314        .is_some_and(|arr| {
315            arr.iter()
316                .any(|v| v.as_str().is_some_and(|s| s.starts_with("mcp__lean-ctx__")))
317        })
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::core::editor_registry::WriteAction;
324
325    #[test]
326    fn plan_mode_tools_are_readonly() {
327        let tools = plan_mode_tools();
328        assert!(tools.contains(&"ctx_read"));
329        assert!(tools.contains(&"ctx_search"));
330        assert!(tools.contains(&"ctx_tree"));
331        assert!(tools.contains(&"ctx_overview"));
332        assert!(tools.contains(&"ctx_plan"));
333
334        assert!(!tools.contains(&"ctx_edit"));
335        assert!(!tools.contains(&"ctx_shell"));
336        assert!(!tools.contains(&"ctx_compile"));
337    }
338
339    #[test]
340    fn claude_plan_permissions_include_all_curated_tools() {
341        let permissions = claude_plan_permissions();
342        for tool in plan_mode_tools() {
343            let expected = format!("mcp__lean-ctx__{tool}");
344            assert!(
345                permissions.contains(&expected),
346                "missing permission: {expected}"
347            );
348        }
349        assert_eq!(permissions.len(), plan_mode_tools().len());
350    }
351
352    #[test]
353    fn vscode_plan_tool_ids_have_prefix() {
354        let ids = vscode_plan_tool_ids();
355        assert!(ids.iter().all(|id| id.starts_with("lean-ctx_")));
356        assert!(ids.contains(&"lean-ctx_ctx_read".to_string()));
357    }
358
359    #[test]
360    fn merge_preserves_existing_and_adds_new() {
361        let existing = vec![
362            Value::String("other-server_tool".to_string()),
363            Value::String("lean-ctx_ctx_read".to_string()),
364        ];
365        let desired = serde_json::json!(["lean-ctx_ctx_read", "lean-ctx_ctx_search"]);
366        let merged = merge_tool_arrays(&existing, &desired);
367        let arr = merged.as_array().unwrap();
368        assert_eq!(arr.len(), 3);
369        assert!(arr.contains(&Value::String("other-server_tool".to_string())));
370        assert!(arr.contains(&Value::String("lean-ctx_ctx_read".to_string())));
371        assert!(arr.contains(&Value::String("lean-ctx_ctx_search".to_string())));
372    }
373
374    #[test]
375    fn vscode_fresh_write_creates_settings() {
376        let dir = tempfile::tempdir().unwrap();
377        let path = dir.path().join("settings.json");
378
379        let res = write_vscode_plan_settings_to(&path).unwrap();
380        assert!(matches!(res.action, WriteAction::Created));
381
382        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
383        assert_eq!(json["chat.mcp.enabled"], true);
384        let tools = json["github.copilot.chat.planAgent.additionalTools"]
385            .as_array()
386            .unwrap();
387        assert!(tools.len() >= plan_mode_tools().len());
388        assert!(tools.contains(&Value::String("lean-ctx_ctx_read".to_string())));
389    }
390
391    #[test]
392    fn vscode_merge_preserves_existing_settings() {
393        let dir = tempfile::tempdir().unwrap();
394        let path = dir.path().join("settings.json");
395
396        let initial = serde_json::json!({
397            "editor.fontSize": 14,
398            "workbench.colorTheme": "Monokai",
399        });
400        std::fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
401
402        let res = write_vscode_plan_settings_to(&path).unwrap();
403        assert!(matches!(res.action, WriteAction::Updated));
404
405        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
406        assert_eq!(json["editor.fontSize"], 14);
407        assert_eq!(json["workbench.colorTheme"], "Monokai");
408        assert_eq!(json["chat.mcp.enabled"], true);
409        assert!(
410            json["github.copilot.chat.planAgent.additionalTools"]
411                .as_array()
412                .unwrap()
413                .len()
414                > 5
415        );
416    }
417
418    #[test]
419    fn vscode_merge_preserves_foreign_tools() {
420        let dir = tempfile::tempdir().unwrap();
421        let path = dir.path().join("settings.json");
422
423        let initial = serde_json::json!({
424            "chat.mcp.enabled": true,
425            "github.copilot.chat.planAgent.additionalTools": [
426                "other-mcp_tool_a",
427                "other-mcp_tool_b",
428            ],
429        });
430        std::fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
431
432        let res = write_vscode_plan_settings_to(&path).unwrap();
433        assert!(matches!(res.action, WriteAction::Updated));
434
435        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
436        let tools = json["github.copilot.chat.planAgent.additionalTools"]
437            .as_array()
438            .unwrap();
439        assert!(tools.contains(&Value::String("other-mcp_tool_a".to_string())));
440        assert!(tools.contains(&Value::String("other-mcp_tool_b".to_string())));
441        assert!(tools.contains(&Value::String("lean-ctx_ctx_read".to_string())));
442    }
443
444    #[test]
445    fn vscode_idempotent_returns_already() {
446        let dir = tempfile::tempdir().unwrap();
447        let path = dir.path().join("settings.json");
448
449        let r1 = write_vscode_plan_settings_to(&path).unwrap();
450        assert!(matches!(r1.action, WriteAction::Created));
451
452        let r2 = write_vscode_plan_settings_to(&path).unwrap();
453        assert!(matches!(r2.action, WriteAction::Already));
454    }
455
456    #[test]
457    fn claude_fresh_write_creates_permissions() {
458        let dir = tempfile::tempdir().unwrap();
459        let path = dir.path().join("settings.json");
460
461        let res = write_claude_code_plan_permissions_to(&path).unwrap();
462        assert!(matches!(res.action, WriteAction::Created));
463
464        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
465        let allow = json["permissions"]["allow"].as_array().unwrap();
466        assert!(allow.contains(&Value::String("mcp__lean-ctx__ctx_read".to_string())));
467        assert_eq!(allow.len(), claude_plan_permissions().len());
468    }
469
470    #[test]
471    fn claude_merge_preserves_existing_permissions() {
472        let dir = tempfile::tempdir().unwrap();
473        let path = dir.path().join("settings.json");
474
475        let initial = serde_json::json!({
476            "permissions": {
477                "allow": ["Bash(git *)", "Read(~/projects/*)"],
478            }
479        });
480        std::fs::write(&path, serde_json::to_string_pretty(&initial).unwrap()).unwrap();
481
482        let res = write_claude_code_plan_permissions_to(&path).unwrap();
483        assert!(matches!(res.action, WriteAction::Updated));
484
485        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
486        let allow = json["permissions"]["allow"].as_array().unwrap();
487        assert!(allow.contains(&Value::String("Bash(git *)".to_string())));
488        assert!(allow.contains(&Value::String("Read(~/projects/*)".to_string())));
489        assert!(allow.contains(&Value::String("mcp__lean-ctx__ctx_read".to_string())));
490    }
491
492    #[test]
493    fn claude_idempotent_returns_already() {
494        let dir = tempfile::tempdir().unwrap();
495        let path = dir.path().join("settings.json");
496
497        let r1 = write_claude_code_plan_permissions_to(&path).unwrap();
498        assert!(matches!(r1.action, WriteAction::Created));
499
500        let r2 = write_claude_code_plan_permissions_to(&path).unwrap();
501        assert!(matches!(r2.action, WriteAction::Already));
502    }
503
504    #[test]
505    fn check_status_detects_configured_vscode() {
506        let dir = tempfile::tempdir().unwrap();
507        let path = dir.path().join("settings.json");
508
509        let status = check_plan_mode_status_for_paths(Some(&path), None);
510        assert_eq!(status.vscode_configured, Some(false));
511
512        write_vscode_plan_settings_to(&path).unwrap();
513        let status = check_plan_mode_status_for_paths(Some(&path), None);
514        assert_eq!(status.vscode_configured, Some(true));
515    }
516
517    #[test]
518    fn check_status_detects_configured_claude() {
519        let dir = tempfile::tempdir().unwrap();
520        let path = dir.path().join("settings.json");
521
522        let status = check_plan_mode_status_for_paths(None, Some(&path));
523        assert_eq!(status.claude_configured, Some(false));
524
525        write_claude_code_plan_permissions_to(&path).unwrap();
526        let status = check_plan_mode_status_for_paths(None, Some(&path));
527        assert_eq!(status.claude_configured, Some(true));
528    }
529}