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