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