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