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