gobby 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,737 @@
1
+ """
2
+ Shared content installation for Gobby hooks.
3
+
4
+ This module handles installing shared workflows and plugins
5
+ that are used across all CLI integrations (Claude, Gemini, Codex, etc.).
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import shutil
12
+ import time
13
+ from pathlib import Path
14
+ from shutil import copy2, copytree
15
+ from typing import Any
16
+
17
+ from gobby.cli.utils import get_install_dir
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def install_shared_content(cli_path: Path, project_path: Path) -> dict[str, list[str]]:
23
+ """Install shared content from src/install/shared/.
24
+
25
+ Workflows are cross-CLI and go to {project_path}/.gobby/workflows/.
26
+ Plugins are global and go to ~/.gobby/plugins/.
27
+ Docs are project-local and go to {project_path}/.gobby/docs/.
28
+
29
+ Args:
30
+ cli_path: Path to CLI config directory (e.g., .claude, .gemini)
31
+ project_path: Path to project root
32
+
33
+ Returns:
34
+ Dict with lists of installed items by type
35
+ """
36
+ shared_dir = get_install_dir() / "shared"
37
+ installed: dict[str, list[str]] = {"workflows": [], "plugins": [], "docs": []}
38
+
39
+ # Install shared workflows to .gobby/workflows/ (cross-CLI)
40
+ shared_workflows = shared_dir / "workflows"
41
+ if shared_workflows.exists():
42
+ target_workflows = project_path / ".gobby" / "workflows"
43
+ target_workflows.mkdir(parents=True, exist_ok=True)
44
+ for workflow_file in shared_workflows.iterdir():
45
+ if workflow_file.is_file():
46
+ copy2(workflow_file, target_workflows / workflow_file.name)
47
+ installed["workflows"].append(workflow_file.name)
48
+
49
+ # Install shared plugins to ~/.gobby/plugins/ (global)
50
+ shared_plugins = shared_dir / "plugins"
51
+ if shared_plugins.exists():
52
+ target_plugins = Path("~/.gobby/plugins").expanduser()
53
+ target_plugins.mkdir(parents=True, exist_ok=True)
54
+ for plugin_file in shared_plugins.iterdir():
55
+ if plugin_file.is_file() and plugin_file.suffix == ".py":
56
+ copy2(plugin_file, target_plugins / plugin_file.name)
57
+ installed["plugins"].append(plugin_file.name)
58
+
59
+ # Install shared docs to .gobby/docs/ (project-local)
60
+ shared_docs = shared_dir / "docs"
61
+ if shared_docs.exists():
62
+ target_docs = project_path / ".gobby" / "docs"
63
+ target_docs.mkdir(parents=True, exist_ok=True)
64
+ for doc_file in shared_docs.iterdir():
65
+ if doc_file.is_file():
66
+ copy2(doc_file, target_docs / doc_file.name)
67
+ installed["docs"].append(doc_file.name)
68
+
69
+ return installed
70
+
71
+
72
+ def install_shared_skills(target_dir: Path) -> list[str]:
73
+ """Install shared SKILL.md files to target directory.
74
+
75
+ Copies skills from src/gobby/install/shared/skills/ to target_dir.
76
+ Backs up existing SKILL.md if content differs.
77
+
78
+ Args:
79
+ target_dir: Directory where skills should be installed (e.g. .claude/skills)
80
+
81
+ Returns:
82
+ List of installed skill names
83
+ """
84
+ shared_skills_dir = get_install_dir() / "shared" / "skills"
85
+ installed: list[str] = []
86
+
87
+ if not shared_skills_dir.exists():
88
+ return installed
89
+
90
+ target_dir.mkdir(parents=True, exist_ok=True)
91
+
92
+ for skill_path in shared_skills_dir.iterdir():
93
+ if not skill_path.is_dir():
94
+ continue
95
+
96
+ skill_name = skill_path.name
97
+ source_skill_md = skill_path / "SKILL.md"
98
+
99
+ if not source_skill_md.exists():
100
+ continue
101
+
102
+ # Target: target_dir/skill_name/SKILL.md
103
+ target_skill_dir = target_dir / skill_name
104
+ target_skill_dir.mkdir(parents=True, exist_ok=True)
105
+ target_skill_md = target_skill_dir / "SKILL.md"
106
+
107
+ # Backup if exists and differs
108
+ if target_skill_md.exists():
109
+ try:
110
+ # Read both to compare
111
+ source_content = source_skill_md.read_text(encoding="utf-8")
112
+ target_content = target_skill_md.read_text(encoding="utf-8")
113
+
114
+ if source_content != target_content:
115
+ timestamp = int(time.time())
116
+ backup_path = target_skill_md.with_suffix(f".md.{timestamp}.backup")
117
+ target_skill_md.rename(backup_path)
118
+ except OSError as e:
119
+ logger.warning(f"Failed to backup/read skill {skill_name}: {e}")
120
+ continue
121
+
122
+ # Copy new file
123
+ try:
124
+ copy2(source_skill_md, target_skill_md)
125
+ installed.append(skill_name)
126
+ except OSError as e:
127
+ logger.error(f"Failed to copy skill {skill_name}: {e}")
128
+
129
+ return installed
130
+
131
+
132
+ def install_cli_content(cli_name: str, target_path: Path) -> dict[str, list[str]]:
133
+ """Install CLI-specific workflows/commands (layered on top of shared).
134
+
135
+ CLI-specific content can add to or override shared content.
136
+
137
+ Args:
138
+ cli_name: Name of the CLI (e.g., "claude", "gemini", "codex")
139
+ target_path: Path to CLI config directory
140
+
141
+ Returns:
142
+ Dict with lists of installed items by type
143
+ """
144
+ cli_dir = get_install_dir() / cli_name
145
+ installed: dict[str, list[str]] = {"workflows": [], "commands": []}
146
+
147
+ # CLI-specific workflows
148
+ cli_workflows = cli_dir / "workflows"
149
+ if cli_workflows.exists():
150
+ target_workflows = target_path / "workflows"
151
+ target_workflows.mkdir(parents=True, exist_ok=True)
152
+ for workflow_file in cli_workflows.iterdir():
153
+ if workflow_file.is_file():
154
+ copy2(workflow_file, target_workflows / workflow_file.name)
155
+ installed["workflows"].append(workflow_file.name)
156
+
157
+ # CLI-specific commands (slash commands)
158
+ # Claude/Gemini: commands/, Codex: prompts/
159
+ for cmd_dir_name in ["commands", "prompts"]:
160
+ cli_commands = cli_dir / cmd_dir_name
161
+ if cli_commands.exists():
162
+ target_commands = target_path / cmd_dir_name
163
+ target_commands.mkdir(parents=True, exist_ok=True)
164
+ for item in cli_commands.iterdir():
165
+ if item.is_dir():
166
+ # Directory of commands (e.g., memory/)
167
+ target_subdir = target_commands / item.name
168
+ if target_subdir.exists():
169
+ shutil.rmtree(target_subdir)
170
+ copytree(item, target_subdir)
171
+ installed["commands"].append(f"{item.name}/")
172
+ elif item.is_file():
173
+ # Single command file
174
+ copy2(item, target_commands / item.name)
175
+ installed["commands"].append(item.name)
176
+
177
+ return installed
178
+
179
+
180
+ def configure_project_mcp_server(project_path: Path, server_name: str = "gobby") -> dict[str, Any]:
181
+ """Add Gobby MCP server to project-specific config in ~/.claude.json.
182
+
183
+ Claude Code stores project-specific MCP servers in:
184
+ {
185
+ "projects": {
186
+ "/path/to/project": {
187
+ "mcpServers": { "gobby": { ... } }
188
+ }
189
+ }
190
+ }
191
+
192
+ Args:
193
+ project_path: Path to the project root
194
+ server_name: Name for the MCP server entry (default: "gobby")
195
+
196
+ Returns:
197
+ Dict with 'success', 'added', 'already_configured', 'backup_path', and 'error' keys
198
+ """
199
+ result: dict[str, Any] = {
200
+ "success": False,
201
+ "added": False,
202
+ "already_configured": False,
203
+ "backup_path": None,
204
+ "error": None,
205
+ }
206
+
207
+ settings_path = Path.home() / ".claude.json"
208
+ abs_project_path = str(project_path.resolve())
209
+
210
+ # Load existing settings or create empty
211
+ existing_settings: dict[str, Any] = {}
212
+ if settings_path.exists():
213
+ try:
214
+ with open(settings_path) as f:
215
+ existing_settings = json.load(f)
216
+ except json.JSONDecodeError as e:
217
+ result["error"] = f"Failed to parse {settings_path}: {e}"
218
+ return result
219
+ except OSError as e:
220
+ result["error"] = f"Failed to read {settings_path}: {e}"
221
+ return result
222
+
223
+ # Ensure projects section exists
224
+ if "projects" not in existing_settings:
225
+ existing_settings["projects"] = {}
226
+
227
+ # Ensure project entry exists
228
+ if abs_project_path not in existing_settings["projects"]:
229
+ existing_settings["projects"][abs_project_path] = {}
230
+
231
+ project_settings = existing_settings["projects"][abs_project_path]
232
+
233
+ # Ensure mcpServers section exists in project
234
+ if "mcpServers" not in project_settings:
235
+ project_settings["mcpServers"] = {}
236
+
237
+ # Check if already configured
238
+ if server_name in project_settings["mcpServers"]:
239
+ result["success"] = True
240
+ result["already_configured"] = True
241
+ return result
242
+
243
+ # Create backup if file exists
244
+ if settings_path.exists():
245
+ timestamp = int(time.time())
246
+ backup_path = settings_path.parent / f".claude.json.{timestamp}.backup"
247
+ try:
248
+ copy2(settings_path, backup_path)
249
+ result["backup_path"] = str(backup_path)
250
+ except OSError as e:
251
+ result["error"] = f"Failed to create backup: {e}"
252
+ return result
253
+
254
+ # Add gobby MCP server config
255
+ project_settings["mcpServers"][server_name] = {
256
+ "type": "stdio",
257
+ "command": "uv",
258
+ "args": ["run", "gobby", "mcp-server"],
259
+ }
260
+
261
+ # Write updated settings
262
+ try:
263
+ with open(settings_path, "w") as f:
264
+ json.dump(existing_settings, f, indent=2)
265
+ except OSError as e:
266
+ result["error"] = f"Failed to write {settings_path}: {e}"
267
+ return result
268
+
269
+ result["success"] = True
270
+ result["added"] = True
271
+ return result
272
+
273
+
274
+ def remove_project_mcp_server(project_path: Path, server_name: str = "gobby") -> dict[str, Any]:
275
+ """Remove Gobby MCP server from project-specific config in ~/.claude.json.
276
+
277
+ Args:
278
+ project_path: Path to the project root
279
+ server_name: Name of the MCP server entry to remove
280
+
281
+ Returns:
282
+ Dict with 'success', 'removed', 'backup_path', and 'error' keys
283
+ """
284
+ result: dict[str, Any] = {
285
+ "success": False,
286
+ "removed": False,
287
+ "backup_path": None,
288
+ "error": None,
289
+ }
290
+
291
+ settings_path = Path.home() / ".claude.json"
292
+ abs_project_path = str(project_path.resolve())
293
+
294
+ if not settings_path.exists():
295
+ result["success"] = True
296
+ return result
297
+
298
+ try:
299
+ with open(settings_path) as f:
300
+ settings = json.load(f)
301
+ except (json.JSONDecodeError, OSError) as e:
302
+ result["error"] = f"Failed to read {settings_path}: {e}"
303
+ return result
304
+
305
+ # Check if project and server exist
306
+ projects = settings.get("projects", {})
307
+ project_settings = projects.get(abs_project_path, {})
308
+ mcp_servers = project_settings.get("mcpServers", {})
309
+
310
+ if server_name not in mcp_servers:
311
+ result["success"] = True
312
+ return result
313
+
314
+ # Create backup
315
+ timestamp = int(time.time())
316
+ backup_path = settings_path.parent / f".claude.json.{timestamp}.backup"
317
+ try:
318
+ copy2(settings_path, backup_path)
319
+ result["backup_path"] = str(backup_path)
320
+ except OSError as e:
321
+ result["error"] = f"Failed to create backup: {e}"
322
+ return result
323
+
324
+ # Remove the server
325
+ del mcp_servers[server_name]
326
+
327
+ # Write updated settings
328
+ try:
329
+ with open(settings_path, "w") as f:
330
+ json.dump(settings, f, indent=2)
331
+ except OSError as e:
332
+ result["error"] = f"Failed to write {settings_path}: {e}"
333
+ return result
334
+
335
+ result["success"] = True
336
+ result["removed"] = True
337
+ return result
338
+
339
+
340
+ def configure_mcp_server_json(settings_path: Path, server_name: str = "gobby") -> dict[str, Any]:
341
+ """Add Gobby MCP server to a JSON settings file (Claude, Gemini, Antigravity).
342
+
343
+ Merges the gobby MCP server config into the existing mcpServers section,
344
+ preserving all other servers. Creates a timestamped backup before modifying.
345
+
346
+ Args:
347
+ settings_path: Path to the settings.json file (e.g., ~/.claude/settings.json)
348
+ server_name: Name for the MCP server entry (default: "gobby")
349
+
350
+ Returns:
351
+ Dict with 'success', 'added', 'backup_path', and 'error' keys
352
+ """
353
+ result: dict[str, Any] = {
354
+ "success": False,
355
+ "added": False,
356
+ "already_configured": False,
357
+ "backup_path": None,
358
+ "error": None,
359
+ }
360
+
361
+ # Ensure parent directory exists
362
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
363
+
364
+ # Load existing settings or create empty
365
+ existing_settings: dict[str, Any] = {}
366
+ if settings_path.exists():
367
+ try:
368
+ with open(settings_path) as f:
369
+ existing_settings = json.load(f)
370
+ except json.JSONDecodeError as e:
371
+ result["error"] = f"Failed to parse {settings_path}: {e}"
372
+ return result
373
+ except OSError as e:
374
+ result["error"] = f"Failed to read {settings_path}: {e}"
375
+ return result
376
+
377
+ # Check if already configured
378
+ if "mcpServers" in existing_settings and server_name in existing_settings["mcpServers"]:
379
+ result["success"] = True
380
+ result["already_configured"] = True
381
+ return result
382
+
383
+ # Create backup if file exists
384
+ if settings_path.exists():
385
+ timestamp = int(time.time())
386
+ backup_path = settings_path.parent / f"{settings_path.name}.{timestamp}.backup"
387
+ try:
388
+ copy2(settings_path, backup_path)
389
+ result["backup_path"] = str(backup_path)
390
+ except OSError as e:
391
+ result["error"] = f"Failed to create backup: {e}"
392
+ return result
393
+
394
+ # Ensure mcpServers section exists
395
+ if "mcpServers" not in existing_settings:
396
+ existing_settings["mcpServers"] = {}
397
+
398
+ # Add gobby MCP server config
399
+ # Use 'uv run gobby' since most users won't have gobby installed globally
400
+ existing_settings["mcpServers"][server_name] = {
401
+ "command": "uv",
402
+ "args": ["run", "gobby", "mcp-server"],
403
+ }
404
+
405
+ # Write updated settings
406
+ try:
407
+ with open(settings_path, "w") as f:
408
+ json.dump(existing_settings, f, indent=2)
409
+ except OSError as e:
410
+ result["error"] = f"Failed to write {settings_path}: {e}"
411
+ return result
412
+
413
+ result["success"] = True
414
+ result["added"] = True
415
+ return result
416
+
417
+
418
+ def remove_mcp_server_json(settings_path: Path, server_name: str = "gobby") -> dict[str, Any]:
419
+ """Remove Gobby MCP server from a JSON settings file.
420
+
421
+ Args:
422
+ settings_path: Path to the settings.json file
423
+ server_name: Name of the MCP server entry to remove
424
+
425
+ Returns:
426
+ Dict with 'success', 'removed', 'backup_path', and 'error' keys
427
+ """
428
+ result: dict[str, Any] = {
429
+ "success": False,
430
+ "removed": False,
431
+ "backup_path": None,
432
+ "error": None,
433
+ }
434
+
435
+ if not settings_path.exists():
436
+ result["success"] = True
437
+ return result
438
+
439
+ try:
440
+ with open(settings_path) as f:
441
+ settings = json.load(f)
442
+ except (json.JSONDecodeError, OSError) as e:
443
+ result["error"] = f"Failed to read {settings_path}: {e}"
444
+ return result
445
+
446
+ # Check if server exists
447
+ if "mcpServers" not in settings or server_name not in settings["mcpServers"]:
448
+ result["success"] = True
449
+ return result
450
+
451
+ # Create backup
452
+ timestamp = int(time.time())
453
+ backup_path = settings_path.parent / f"{settings_path.name}.{timestamp}.backup"
454
+ try:
455
+ copy2(settings_path, backup_path)
456
+ result["backup_path"] = str(backup_path)
457
+ except OSError as e:
458
+ result["error"] = f"Failed to create backup: {e}"
459
+ return result
460
+
461
+ # Remove the server
462
+ del settings["mcpServers"][server_name]
463
+
464
+ # Clean up empty mcpServers section
465
+ if not settings["mcpServers"]:
466
+ del settings["mcpServers"]
467
+
468
+ # Write updated settings
469
+ try:
470
+ with open(settings_path, "w") as f:
471
+ json.dump(settings, f, indent=2)
472
+ except OSError as e:
473
+ result["error"] = f"Failed to write {settings_path}: {e}"
474
+ return result
475
+
476
+ result["success"] = True
477
+ result["removed"] = True
478
+ return result
479
+
480
+
481
+ def configure_mcp_server_toml(config_path: Path, server_name: str = "gobby") -> dict[str, Any]:
482
+ """Add Gobby MCP server to a TOML config file (Codex).
483
+
484
+ Adds [mcp_servers.gobby] section with command and args.
485
+ Creates a timestamped backup before modifying.
486
+
487
+ Args:
488
+ config_path: Path to the config.toml file (e.g., ~/.codex/config.toml)
489
+ server_name: Name for the MCP server entry (default: "gobby")
490
+
491
+ Returns:
492
+ Dict with 'success', 'added', 'backup_path', and 'error' keys
493
+ """
494
+ import re
495
+
496
+ result: dict[str, Any] = {
497
+ "success": False,
498
+ "added": False,
499
+ "already_configured": False,
500
+ "backup_path": None,
501
+ "error": None,
502
+ }
503
+
504
+ # Ensure parent directory exists
505
+ config_path.parent.mkdir(parents=True, exist_ok=True)
506
+
507
+ # Read existing config
508
+ existing = ""
509
+ if config_path.exists():
510
+ try:
511
+ existing = config_path.read_text(encoding="utf-8")
512
+ except OSError as e:
513
+ result["error"] = f"Failed to read {config_path}: {e}"
514
+ return result
515
+
516
+ # Check if already configured
517
+ pattern = re.compile(rf"^\s*\[mcp_servers\.{re.escape(server_name)}\]", re.MULTILINE)
518
+ if pattern.search(existing):
519
+ result["success"] = True
520
+ result["already_configured"] = True
521
+ return result
522
+
523
+ # Create backup if file exists
524
+ if config_path.exists():
525
+ timestamp = int(time.time())
526
+ backup_path = config_path.with_suffix(f".toml.{timestamp}.backup")
527
+ try:
528
+ backup_path.write_text(existing, encoding="utf-8")
529
+ result["backup_path"] = str(backup_path)
530
+ except OSError as e:
531
+ result["error"] = f"Failed to create backup: {e}"
532
+ return result
533
+
534
+ # Add MCP server config
535
+ # Use 'uv run gobby' since most users won't have gobby installed globally
536
+ mcp_config = f"""
537
+ [mcp_servers.{server_name}]
538
+ command = "uv"
539
+ args = ["run", "gobby", "mcp-server"]
540
+ """
541
+ updated = (existing.rstrip() + "\n" if existing.strip() else "") + mcp_config
542
+
543
+ try:
544
+ config_path.write_text(updated, encoding="utf-8")
545
+ except OSError as e:
546
+ result["error"] = f"Failed to write {config_path}: {e}"
547
+ return result
548
+
549
+ result["success"] = True
550
+ result["added"] = True
551
+ return result
552
+
553
+
554
+ def remove_mcp_server_toml(config_path: Path, server_name: str = "gobby") -> dict[str, Any]:
555
+ """Remove Gobby MCP server from a TOML config file.
556
+
557
+ Uses tomllib (stdlib) for reading and tomli_w for writing to properly
558
+ handle TOML syntax including multi-line strings.
559
+
560
+ Args:
561
+ config_path: Path to the config.toml file
562
+ server_name: Name of the MCP server entry to remove
563
+
564
+ Returns:
565
+ Dict with 'success', 'removed', 'backup_path', and 'error' keys
566
+ """
567
+ import tomllib
568
+
569
+ import tomli_w
570
+
571
+ result: dict[str, Any] = {
572
+ "success": False,
573
+ "removed": False,
574
+ "backup_path": None,
575
+ "error": None,
576
+ }
577
+
578
+ if not config_path.exists():
579
+ result["success"] = True
580
+ return result
581
+
582
+ # Read existing TOML file
583
+ try:
584
+ existing_text = config_path.read_text(encoding="utf-8")
585
+ with open(config_path, "rb") as f:
586
+ config = tomllib.load(f)
587
+ except tomllib.TOMLDecodeError as e:
588
+ result["error"] = f"Failed to parse TOML {config_path}: {e}"
589
+ return result
590
+ except OSError as e:
591
+ result["error"] = f"Failed to read {config_path}: {e}"
592
+ return result
593
+
594
+ # Check if server exists in mcp_servers section
595
+ mcp_servers = config.get("mcp_servers", {})
596
+ if server_name not in mcp_servers:
597
+ result["success"] = True
598
+ return result
599
+
600
+ # Create backup
601
+ timestamp = int(time.time())
602
+ backup_path = config_path.with_suffix(f".toml.{timestamp}.backup")
603
+ try:
604
+ backup_path.write_text(existing_text, encoding="utf-8")
605
+ result["backup_path"] = str(backup_path)
606
+ except OSError as e:
607
+ result["error"] = f"Failed to create backup: {e}"
608
+ return result
609
+
610
+ # Remove the server from config
611
+ del mcp_servers[server_name]
612
+
613
+ # Clean up empty mcp_servers section
614
+ if not mcp_servers:
615
+ del config["mcp_servers"]
616
+ else:
617
+ config["mcp_servers"] = mcp_servers
618
+
619
+ # Write updated config using tomli_w
620
+ try:
621
+ with open(config_path, "wb") as f:
622
+ tomli_w.dump(config, f, multiline_strings=True)
623
+ except OSError as e:
624
+ result["error"] = f"Failed to write {config_path}: {e}"
625
+ return result
626
+
627
+ result["success"] = True
628
+ result["removed"] = True
629
+ return result
630
+
631
+
632
+ # Default external MCP servers to install
633
+ DEFAULT_MCP_SERVERS: list[dict[str, Any]] = [
634
+ {
635
+ "name": "github",
636
+ "transport": "stdio",
637
+ "command": "npx",
638
+ "args": ["-y", "@modelcontextprotocol/server-github"],
639
+ "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"}, # nosec B105 - env var placeholder
640
+ "description": "GitHub API integration for issues, PRs, repos, and code search",
641
+ },
642
+ {
643
+ "name": "linear",
644
+ "transport": "stdio",
645
+ "command": "npx",
646
+ "args": ["-y", "mcp-linear"],
647
+ "env": {"LINEAR_API_KEY": "${LINEAR_API_KEY}"},
648
+ "description": "Linear issue tracking integration",
649
+ },
650
+ {
651
+ "name": "context7",
652
+ "transport": "stdio",
653
+ "command": "npx",
654
+ "args": ["-y", "@upstash/context7-mcp"],
655
+ # API key args added dynamically if CONTEXT7_API_KEY is set
656
+ "optional_env_args": {"CONTEXT7_API_KEY": ["--api-key", "${CONTEXT7_API_KEY}"]},
657
+ "description": "Context7 library documentation lookup (set CONTEXT7_API_KEY for private repos)",
658
+ },
659
+ ]
660
+
661
+
662
+ def install_default_mcp_servers() -> dict[str, Any]:
663
+ """Install default external MCP servers to ~/.gobby/.mcp.json.
664
+
665
+ Adds GitHub, Linear, and context7 MCP servers if not already configured.
666
+ These servers pull API keys from environment variables.
667
+
668
+ Returns:
669
+ Dict with 'success', 'servers_added', 'servers_skipped', and 'error' keys
670
+ """
671
+ result: dict[str, Any] = {
672
+ "success": False,
673
+ "servers_added": [],
674
+ "servers_skipped": [],
675
+ "error": None,
676
+ }
677
+
678
+ mcp_config_path = Path("~/.gobby/.mcp.json").expanduser()
679
+
680
+ # Ensure parent directory exists
681
+ mcp_config_path.parent.mkdir(parents=True, exist_ok=True)
682
+
683
+ # Load existing config or create empty
684
+ existing_config: dict[str, Any] = {"servers": []}
685
+ if mcp_config_path.exists():
686
+ try:
687
+ with open(mcp_config_path) as f:
688
+ content = f.read()
689
+ if content.strip():
690
+ existing_config = json.loads(content)
691
+ if "servers" not in existing_config:
692
+ existing_config["servers"] = []
693
+ except (json.JSONDecodeError, OSError) as e:
694
+ result["error"] = f"Failed to read MCP config: {e}"
695
+ return result
696
+
697
+ # Get existing server names
698
+ existing_names = {s.get("name") for s in existing_config["servers"]}
699
+
700
+ # Add default servers if not already present
701
+ for server in DEFAULT_MCP_SERVERS:
702
+ if server["name"] in existing_names:
703
+ result["servers_skipped"].append(server["name"])
704
+ else:
705
+ # Build args list, adding optional env-dependent args
706
+ args = list(server.get("args") or [])
707
+ optional_env_args = server.get("optional_env_args", {})
708
+ for env_var, extra_args in optional_env_args.items():
709
+ if os.environ.get(env_var):
710
+ args.extend(extra_args)
711
+
712
+ existing_config["servers"].append(
713
+ {
714
+ "name": server["name"],
715
+ "enabled": True,
716
+ "transport": server["transport"],
717
+ "command": server.get("command"),
718
+ "args": args if args else None,
719
+ "env": server.get("env"),
720
+ "description": server.get("description"),
721
+ }
722
+ )
723
+ result["servers_added"].append(server["name"])
724
+
725
+ # Write updated config if any servers were added
726
+ if result["servers_added"]:
727
+ try:
728
+ with open(mcp_config_path, "w") as f:
729
+ json.dump(existing_config, f, indent=2)
730
+ # Set restrictive permissions
731
+ mcp_config_path.chmod(0o600)
732
+ except OSError as e:
733
+ result["error"] = f"Failed to write MCP config: {e}"
734
+ return result
735
+
736
+ result["success"] = True
737
+ return result