lean_ctx/core/editor_registry/
plan_mode.rs1use serde_json::Value;
2
3pub 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}