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