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_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}