gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  from gobby.agents.constants import get_terminal_env_vars
12
+ from gobby.agents.sandbox import SandboxConfig, compute_sandbox_paths, get_sandbox_resolver
12
13
  from gobby.agents.spawners.base import HeadlessResult
13
14
 
14
15
  if TYPE_CHECKING:
@@ -26,11 +27,11 @@ def _get_spawn_utils() -> tuple[
26
27
  """Lazy import to avoid circular dependencies."""
27
28
  from gobby.agents.spawn import (
28
29
  MAX_ENV_PROMPT_LENGTH,
29
- _create_prompt_file,
30
30
  build_cli_command,
31
+ create_prompt_file,
31
32
  )
32
33
 
33
- return build_cli_command, _create_prompt_file, MAX_ENV_PROMPT_LENGTH
34
+ return build_cli_command, create_prompt_file, MAX_ENV_PROMPT_LENGTH
34
35
 
35
36
 
36
37
  class HeadlessSpawner:
@@ -169,6 +170,7 @@ class HeadlessSpawner:
169
170
  agent_depth: int = 1,
170
171
  max_agent_depth: int = 3,
171
172
  prompt: str | None = None,
173
+ sandbox_config: SandboxConfig | None = None,
172
174
  ) -> HeadlessResult:
173
175
  """
174
176
  Spawn a CLI agent in headless mode.
@@ -184,12 +186,24 @@ class HeadlessSpawner:
184
186
  agent_depth: Current nesting depth
185
187
  max_agent_depth: Maximum allowed depth
186
188
  prompt: Optional initial prompt
189
+ sandbox_config: Optional sandbox configuration
187
190
 
188
191
  Returns:
189
192
  HeadlessResult with process handle
190
193
  """
191
194
  build_cli_command, _create_prompt_file, max_env_prompt_length = _get_spawn_utils()
192
195
 
196
+ # Resolve sandbox configuration if enabled
197
+ sandbox_args: list[str] | None = None
198
+ sandbox_env: dict[str, str] = {}
199
+
200
+ if sandbox_config and sandbox_config.enabled:
201
+ # Compute sandbox paths based on cwd (workspace)
202
+ resolved_paths = compute_sandbox_paths(sandbox_config, str(cwd))
203
+ # Get CLI-specific resolver and generate args/env
204
+ resolver = get_sandbox_resolver(cli)
205
+ sandbox_args, sandbox_env = resolver.resolve(sandbox_config, resolved_paths)
206
+
193
207
  # Build command with prompt as CLI argument and auto-approve for autonomous work
194
208
  command = build_cli_command(
195
209
  cli,
@@ -198,6 +212,7 @@ class HeadlessSpawner:
198
212
  auto_approve=True, # Subagents need to work autonomously
199
213
  working_directory=str(cwd) if cli == "codex" else None,
200
214
  mode="headless", # Non-interactive headless mode
215
+ sandbox_args=sandbox_args,
201
216
  )
202
217
 
203
218
  # Handle prompt for environment variables (backup for hooks/context)
@@ -223,4 +238,8 @@ class HeadlessSpawner:
223
238
  prompt_file=prompt_file,
224
239
  )
225
240
 
241
+ # Merge sandbox environment variables if present
242
+ if sandbox_env:
243
+ env.update(sandbox_env)
244
+
226
245
  return self.spawn(command, cwd, env)
@@ -0,0 +1,125 @@
1
+ """Prompt file management for agent spawning.
2
+
3
+ Handles creation, cleanup, and tracking of temporary prompt files
4
+ used to pass long prompts to spawned CLI agents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import atexit
10
+ import logging
11
+ import os
12
+ import re
13
+ import tempfile
14
+ import threading
15
+ import uuid
16
+ from pathlib import Path
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Maximum prompt length to pass via environment variable
21
+ # Longer prompts will be written to a temp file
22
+ MAX_ENV_PROMPT_LENGTH = 4096
23
+
24
+ # Module-level set for tracking prompt files to clean up on exit
25
+ # This avoids registering a new atexit handler for each prompt file
26
+ _prompt_files_to_cleanup: set[Path] = set()
27
+ _atexit_registered = False
28
+ _atexit_lock = threading.Lock()
29
+
30
+
31
+ def cleanup_all_prompt_files() -> None:
32
+ """Clean up all tracked prompt files on process exit."""
33
+ with _atexit_lock:
34
+ files_to_cleanup = list(_prompt_files_to_cleanup)
35
+ _prompt_files_to_cleanup.clear()
36
+ for prompt_path in files_to_cleanup:
37
+ try:
38
+ if prompt_path.exists():
39
+ prompt_path.unlink()
40
+ except OSError:
41
+ pass
42
+
43
+
44
+ def create_prompt_file(prompt: str, session_id: str) -> str:
45
+ """
46
+ Create a prompt file with secure permissions.
47
+
48
+ The file is created in the system temp directory with restrictive
49
+ permissions (owner read/write only) and tracked for cleanup on exit.
50
+
51
+ Args:
52
+ prompt: The prompt content to write
53
+ session_id: Session ID for naming the file
54
+
55
+ Returns:
56
+ Path to the created temp file
57
+ """
58
+ global _atexit_registered
59
+
60
+ # Create temp directory with restrictive permissions
61
+ temp_dir = Path(tempfile.gettempdir()) / "gobby-prompts"
62
+ temp_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
63
+
64
+ # Sanitize session_id to prevent path traversal attacks
65
+ # Strip path separators and limit to alphanumeric, hyphens, underscores
66
+ safe_session_id = re.sub(r"[^a-zA-Z0-9_-]", "", session_id)
67
+ if not safe_session_id or len(safe_session_id) > 128:
68
+ safe_session_id = str(uuid.uuid4())
69
+
70
+ # Create the prompt file path
71
+ prompt_path = temp_dir / f"prompt-{safe_session_id}.txt"
72
+
73
+ # Write with secure permissions atomically - create with mode 0o600 from the start
74
+ # This avoids the TOCTOU window between write_text and chmod
75
+ fd = os.open(str(prompt_path), os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
76
+ try:
77
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
78
+ f.write(prompt)
79
+ f.flush()
80
+ os.fsync(f.fileno())
81
+ except Exception:
82
+ # fd is closed by fdopen, but if fdopen fails we need to close it
83
+ try:
84
+ os.close(fd)
85
+ except OSError:
86
+ pass
87
+ raise
88
+
89
+ # Track for cleanup and register handler (thread-safe)
90
+ with _atexit_lock:
91
+ _prompt_files_to_cleanup.add(prompt_path)
92
+ if not _atexit_registered:
93
+ atexit.register(cleanup_all_prompt_files)
94
+ _atexit_registered = True
95
+
96
+ logger.debug(f"Created secure prompt file: {prompt_path}")
97
+ return str(prompt_path)
98
+
99
+
100
+ def read_prompt_from_env() -> str | None:
101
+ """
102
+ Read initial prompt from environment variables.
103
+
104
+ Checks GOBBY_PROMPT_FILE first (for long prompts),
105
+ then falls back to GOBBY_PROMPT (for short prompts).
106
+
107
+ Returns:
108
+ Prompt string or None if not set
109
+ """
110
+ from gobby.agents.constants import GOBBY_PROMPT, GOBBY_PROMPT_FILE
111
+
112
+ # Check for prompt file first
113
+ prompt_file = os.environ.get(GOBBY_PROMPT_FILE)
114
+ if prompt_file:
115
+ try:
116
+ prompt_path = Path(prompt_file)
117
+ if prompt_path.exists():
118
+ return prompt_path.read_text(encoding="utf-8")
119
+ else:
120
+ logger.warning(f"Prompt file not found: {prompt_file}")
121
+ except Exception as e:
122
+ logger.error(f"Error reading prompt file: {e}")
123
+
124
+ # Fall back to inline prompt
125
+ return os.environ.get(GOBBY_PROMPT)
gobby/cli/__init__.py CHANGED
@@ -8,6 +8,8 @@ from gobby.config.app import load_config
8
8
 
9
9
  from .agents import agents
10
10
  from .artifacts import artifacts
11
+ from .clones import clones
12
+ from .conductor import conductor
11
13
  from .daemon import restart, start, status, stop
12
14
  from .extensions import hooks, plugins, webhooks
13
15
  from .github import github
@@ -20,6 +22,7 @@ from .memory import memory
20
22
  from .merge import merge
21
23
  from .projects import projects
22
24
  from .sessions import sessions
25
+ from .skills import skills
23
26
  from .tasks import tasks
24
27
  from .tui import ui
25
28
  from .workflows import workflows
@@ -52,6 +55,7 @@ cli.add_command(uninstall)
52
55
  cli.add_command(tasks)
53
56
  cli.add_command(memory)
54
57
  cli.add_command(sessions)
58
+ cli.add_command(skills)
55
59
  cli.add_command(agents)
56
60
  cli.add_command(worktrees)
57
61
  cli.add_command(mcp_proxy)
@@ -61,6 +65,8 @@ cli.add_command(merge)
61
65
  cli.add_command(artifacts)
62
66
  cli.add_command(github)
63
67
  cli.add_command(linear)
68
+ cli.add_command(clones)
69
+ cli.add_command(conductor)
64
70
  cli.add_command(hooks)
65
71
  cli.add_command(plugins)
66
72
  cli.add_command(webhooks)
gobby/cli/clones.py ADDED
@@ -0,0 +1,419 @@
1
+ """
2
+ Clone management CLI commands.
3
+
4
+ Commands for managing git clones:
5
+ - create: Create a new clone
6
+ - list: List clones
7
+ - spawn: Spawn an agent in a clone
8
+ - sync: Sync a clone with remote
9
+ - merge: Merge clone branch to target
10
+ - delete: Delete a clone
11
+ """
12
+
13
+ import json
14
+
15
+ import click
16
+ import httpx
17
+
18
+ from gobby.storage.clones import LocalCloneManager
19
+ from gobby.storage.database import LocalDatabase
20
+
21
+
22
+ def get_clone_manager() -> LocalCloneManager:
23
+ """Get initialized clone manager."""
24
+ db = LocalDatabase()
25
+ return LocalCloneManager(db)
26
+
27
+
28
+ def get_daemon_url() -> str:
29
+ """Get daemon URL from config."""
30
+ from gobby.config.app import load_config
31
+
32
+ config = load_config()
33
+ return f"http://localhost:{config.daemon_port}"
34
+
35
+
36
+ @click.group()
37
+ def clones() -> None:
38
+ """Manage git clones for parallel development."""
39
+ pass
40
+
41
+
42
+ @clones.command("list")
43
+ @click.option("--status", "-s", help="Filter by status (active, stale, syncing, cleanup)")
44
+ @click.option("--project", "-p", "project_id", help="Filter by project ID")
45
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
46
+ def list_clones(
47
+ status: str | None,
48
+ project_id: str | None,
49
+ json_format: bool,
50
+ ) -> None:
51
+ """List clones."""
52
+ manager = get_clone_manager()
53
+
54
+ clones_list = manager.list_clones(status=status, project_id=project_id)
55
+
56
+ if json_format:
57
+ click.echo(json.dumps([c.to_dict() for c in clones_list], indent=2, default=str))
58
+ return
59
+
60
+ if not clones_list:
61
+ click.echo("No clones found.")
62
+ return
63
+
64
+ click.echo(f"Found {len(clones_list)} clone(s):\n")
65
+ for clone in clones_list:
66
+ status_icon = {
67
+ "active": "●",
68
+ "syncing": "↻",
69
+ "stale": "○",
70
+ "cleanup": "✗",
71
+ }.get(clone.status, "?")
72
+
73
+ session_info = f" (session: {clone.agent_session_id[:8]})" if clone.agent_session_id else ""
74
+ click.echo(
75
+ f"{status_icon} {clone.id} {clone.branch_name:<30} {clone.status:<10}{session_info}"
76
+ )
77
+
78
+
79
+ @clones.command("create")
80
+ @click.argument("branch_name")
81
+ @click.argument("clone_path")
82
+ @click.option("--base", "-b", "base_branch", default="main", help="Base branch to clone from")
83
+ @click.option("--task", "-t", "task_id", help="Link clone to a task")
84
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
85
+ def create_clone(
86
+ branch_name: str,
87
+ clone_path: str,
88
+ base_branch: str,
89
+ task_id: str | None,
90
+ json_format: bool,
91
+ ) -> None:
92
+ """Create a new clone for parallel development.
93
+
94
+ Examples:
95
+
96
+ gobby clones create feature/my-feature /path/to/clone
97
+
98
+ gobby clones create bugfix/fix-123 /tmp/fix --base develop --task #47
99
+ """
100
+ daemon_url = get_daemon_url()
101
+
102
+ arguments = {
103
+ "branch_name": branch_name,
104
+ "clone_path": clone_path,
105
+ "base_branch": base_branch,
106
+ }
107
+
108
+ if task_id:
109
+ arguments["task_id"] = task_id
110
+
111
+ try:
112
+ response = httpx.post(
113
+ f"{daemon_url}/mcp/gobby-clones/tools/create_clone",
114
+ json=arguments,
115
+ timeout=300.0, # Clone can take a while
116
+ )
117
+ response.raise_for_status()
118
+ result = response.json()
119
+ except httpx.ConnectError:
120
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
121
+ return
122
+ except httpx.HTTPStatusError as e:
123
+ click.echo(f"HTTP Error {e.response.status_code}: {e.response.text}", err=True)
124
+ return
125
+ except Exception as e:
126
+ click.echo(f"Error: {e}", err=True)
127
+ return
128
+
129
+ if json_format:
130
+ click.echo(json.dumps(result, indent=2, default=str))
131
+ return
132
+
133
+ if result.get("success"):
134
+ clone_info = result.get("clone", {})
135
+ click.echo(f"Created clone: {clone_info.get('id', 'unknown')}")
136
+ click.echo(f" Branch: {clone_info.get('branch_name', 'unknown')}")
137
+ else:
138
+ click.echo(f"Failed to create clone: {result.get('error')}", err=True)
139
+
140
+
141
+ @clones.command("spawn")
142
+ @click.argument("clone_ref")
143
+ @click.argument("prompt")
144
+ @click.option(
145
+ "--parent-session-id",
146
+ "-p",
147
+ "parent_session_id",
148
+ required=True,
149
+ help="Parent session ID (required)",
150
+ )
151
+ @click.option("--mode", "-m", default="terminal", help="Agent mode (terminal, embedded, headless)")
152
+ @click.option("--workflow", "-w", help="Workflow to activate")
153
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
154
+ def spawn_agent(
155
+ clone_ref: str,
156
+ prompt: str,
157
+ parent_session_id: str,
158
+ mode: str,
159
+ workflow: str | None,
160
+ json_format: bool,
161
+ ) -> None:
162
+ """Spawn an agent to work in a clone.
163
+
164
+ Examples:
165
+
166
+ gobby clones spawn clone-123 "Fix the authentication bug"
167
+
168
+ gobby clones spawn clone-123 "Implement feature" --mode headless
169
+ """
170
+ manager = get_clone_manager()
171
+ clone_id = resolve_clone_id(manager, clone_ref)
172
+
173
+ if not clone_id:
174
+ click.echo(f"Clone not found: {clone_ref}", err=True)
175
+ return
176
+
177
+ daemon_url = get_daemon_url()
178
+
179
+ arguments = {
180
+ "clone_id": clone_id,
181
+ "prompt": prompt,
182
+ "parent_session_id": parent_session_id,
183
+ "mode": mode,
184
+ }
185
+
186
+ if workflow:
187
+ arguments["workflow"] = workflow
188
+
189
+ try:
190
+ response = httpx.post(
191
+ f"{daemon_url}/mcp/gobby-clones/tools/spawn_agent_in_clone",
192
+ json=arguments,
193
+ timeout=60.0,
194
+ )
195
+ response.raise_for_status()
196
+ result = response.json()
197
+ except httpx.ConnectError:
198
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
199
+ return
200
+ except httpx.HTTPStatusError as e:
201
+ click.echo(f"HTTP Error {e.response.status_code}: {e.response.text}", err=True)
202
+ return
203
+ except Exception as e:
204
+ click.echo(f"Error: {e}", err=True)
205
+ return
206
+
207
+ if json_format:
208
+ click.echo(json.dumps(result, indent=2, default=str))
209
+ return
210
+
211
+ if result.get("success"):
212
+ session_id = result.get("session_id", "unknown")
213
+ click.echo(f"Spawned agent in clone {clone_id}")
214
+ click.echo(f" Session: {session_id}")
215
+ else:
216
+ click.echo(f"Failed to spawn agent: {result.get('error')}", err=True)
217
+
218
+
219
+ @clones.command("sync")
220
+ @click.argument("clone_ref")
221
+ @click.option("--direction", "-d", default="pull", help="Sync direction (pull, push, both)")
222
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
223
+ def sync_clone(clone_ref: str, direction: str, json_format: bool) -> None:
224
+ """Sync clone with remote.
225
+
226
+ Examples:
227
+
228
+ gobby clones sync clone-123
229
+
230
+ gobby clones sync clone-123 --direction push
231
+ """
232
+ manager = get_clone_manager()
233
+ clone_id = resolve_clone_id(manager, clone_ref)
234
+
235
+ if not clone_id:
236
+ click.echo(f"Clone not found: {clone_ref}", err=True)
237
+ return
238
+
239
+ daemon_url = get_daemon_url()
240
+
241
+ try:
242
+ response = httpx.post(
243
+ f"{daemon_url}/mcp/gobby-clones/tools/sync_clone",
244
+ json={"clone_id": clone_id, "direction": direction},
245
+ timeout=120.0,
246
+ )
247
+ response.raise_for_status()
248
+ result = response.json()
249
+ except httpx.ConnectError:
250
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
251
+ return
252
+ except httpx.HTTPStatusError as e:
253
+ click.echo(f"HTTP Error {e.response.status_code}: {e.response.text}", err=True)
254
+ return
255
+ except Exception as e:
256
+ click.echo(f"Error: {e}", err=True)
257
+ return
258
+
259
+ if json_format:
260
+ click.echo(json.dumps(result, indent=2, default=str))
261
+ return
262
+
263
+ if result.get("success"):
264
+ click.echo(f"Synced clone {clone_id}")
265
+ else:
266
+ click.echo(f"Failed to sync clone: {result.get('error')}", err=True)
267
+
268
+
269
+ @clones.command("merge")
270
+ @click.argument("clone_ref")
271
+ @click.option("--target", "-t", "target_branch", default="main", help="Target branch to merge into")
272
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
273
+ def merge_clone(clone_ref: str, target_branch: str, json_format: bool) -> None:
274
+ """Merge clone branch to target branch.
275
+
276
+ Examples:
277
+
278
+ gobby clones merge clone-123
279
+
280
+ gobby clones merge clone-123 --target develop
281
+ """
282
+ manager = get_clone_manager()
283
+ clone_id = resolve_clone_id(manager, clone_ref)
284
+
285
+ if not clone_id:
286
+ click.echo(f"Clone not found: {clone_ref}", err=True)
287
+ return
288
+
289
+ daemon_url = get_daemon_url()
290
+
291
+ try:
292
+ response = httpx.post(
293
+ f"{daemon_url}/mcp/gobby-clones/tools/merge_clone_to_target",
294
+ json={"clone_id": clone_id, "target_branch": target_branch},
295
+ timeout=120.0,
296
+ )
297
+ response.raise_for_status()
298
+ result = response.json()
299
+ except httpx.ConnectError:
300
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
301
+ return
302
+ except httpx.HTTPStatusError as e:
303
+ click.echo(f"HTTP Error {e.response.status_code}: {e.response.text}", err=True)
304
+ return
305
+ except Exception as e:
306
+ click.echo(f"Error: {e}", err=True)
307
+ return
308
+
309
+ if json_format:
310
+ click.echo(json.dumps(result, indent=2, default=str))
311
+ return
312
+
313
+ if result.get("success"):
314
+ click.echo(f"Merged clone {clone_id} to {target_branch}")
315
+ else:
316
+ # Check for merge conflicts
317
+ if result.get("has_conflicts"):
318
+ conflicted = result.get("conflicted_files", [])
319
+ click.echo(f"Merge conflict in {len(conflicted)} file(s):", err=True)
320
+ for f in conflicted:
321
+ click.echo(f" {f}", err=True)
322
+ else:
323
+ click.echo(f"Failed to merge clone: {result.get('error')}", err=True)
324
+
325
+
326
+ @clones.command("delete")
327
+ @click.argument("clone_ref")
328
+ @click.option("--force", "-f", is_flag=True, help="Force delete even if active")
329
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
330
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
331
+ def delete_clone(clone_ref: str, force: bool, yes: bool, json_format: bool) -> None:
332
+ """Delete a clone.
333
+
334
+ Examples:
335
+
336
+ gobby clones delete clone-123 --yes
337
+
338
+ gobby clones delete clone-123 --force --yes
339
+ """
340
+ manager = get_clone_manager()
341
+ clone_id = resolve_clone_id(manager, clone_ref)
342
+
343
+ if not clone_id:
344
+ if json_format:
345
+ click.echo(json.dumps({"success": False, "error": f"Clone not found: {clone_ref}"}))
346
+ else:
347
+ click.echo(f"Clone not found: {clone_ref}", err=True)
348
+ return
349
+
350
+ if not yes and not json_format:
351
+ click.confirm("Are you sure you want to delete this clone?", abort=True)
352
+
353
+ daemon_url = get_daemon_url()
354
+
355
+ try:
356
+ response = httpx.post(
357
+ f"{daemon_url}/mcp/gobby-clones/tools/delete_clone",
358
+ json={"clone_id": clone_id, "force": force},
359
+ timeout=60.0,
360
+ )
361
+ response.raise_for_status()
362
+ result = response.json()
363
+ except httpx.ConnectError:
364
+ if json_format:
365
+ click.echo(json.dumps({"success": False, "error": "Cannot connect to Gobby daemon"}))
366
+ else:
367
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
368
+ return
369
+ except httpx.HTTPStatusError as e:
370
+ if json_format:
371
+ click.echo(
372
+ json.dumps(
373
+ {
374
+ "success": False,
375
+ "error": f"HTTP Error {e.response.status_code}",
376
+ "detail": e.response.text,
377
+ }
378
+ )
379
+ )
380
+ else:
381
+ click.echo(f"HTTP Error {e.response.status_code}: {e.response.text}", err=True)
382
+ return
383
+ except Exception as e:
384
+ if json_format:
385
+ click.echo(json.dumps({"success": False, "error": str(e)}))
386
+ else:
387
+ click.echo(f"Error: {e}", err=True)
388
+ return
389
+
390
+ if json_format:
391
+ click.echo(json.dumps(result, indent=2, default=str))
392
+ return
393
+
394
+ if result.get("success"):
395
+ click.echo(f"Deleted clone: {clone_id}")
396
+ else:
397
+ click.echo(f"Failed to delete clone: {result.get('error')}", err=True)
398
+
399
+
400
+ def resolve_clone_id(manager: LocalCloneManager, clone_ref: str) -> str | None:
401
+ """Resolve clone reference (UUID or prefix) to full ID."""
402
+ # Check for exact match first
403
+ if manager.get(clone_ref):
404
+ return clone_ref
405
+
406
+ # Try prefix match
407
+ all_clones = manager.list_clones()
408
+ matches = [c for c in all_clones if c.id.startswith(clone_ref)]
409
+
410
+ if not matches:
411
+ return None
412
+
413
+ if len(matches) > 1:
414
+ click.echo(f"Ambiguous clone reference '{clone_ref}' matches:", err=True)
415
+ for c in matches:
416
+ click.echo(f" {c.id[:8]} ({c.branch_name})", err=True)
417
+ return None
418
+
419
+ return matches[0].id