gobby 0.2.5__py3-none-any.whl → 0.2.6__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 (148) hide show
  1. gobby/adapters/claude_code.py +13 -4
  2. gobby/adapters/codex.py +43 -3
  3. gobby/agents/runner.py +8 -0
  4. gobby/cli/__init__.py +6 -0
  5. gobby/cli/clones.py +419 -0
  6. gobby/cli/conductor.py +266 -0
  7. gobby/cli/installers/antigravity.py +3 -9
  8. gobby/cli/installers/claude.py +9 -9
  9. gobby/cli/installers/codex.py +2 -8
  10. gobby/cli/installers/gemini.py +2 -8
  11. gobby/cli/installers/shared.py +71 -8
  12. gobby/cli/skills.py +858 -0
  13. gobby/cli/tasks/ai.py +0 -440
  14. gobby/cli/tasks/crud.py +44 -6
  15. gobby/cli/tasks/main.py +0 -4
  16. gobby/cli/tui.py +2 -2
  17. gobby/cli/utils.py +3 -3
  18. gobby/clones/__init__.py +13 -0
  19. gobby/clones/git.py +547 -0
  20. gobby/conductor/__init__.py +16 -0
  21. gobby/conductor/alerts.py +135 -0
  22. gobby/conductor/loop.py +164 -0
  23. gobby/conductor/monitors/__init__.py +11 -0
  24. gobby/conductor/monitors/agents.py +116 -0
  25. gobby/conductor/monitors/tasks.py +155 -0
  26. gobby/conductor/pricing.py +234 -0
  27. gobby/conductor/token_tracker.py +160 -0
  28. gobby/config/app.py +63 -1
  29. gobby/config/search.py +110 -0
  30. gobby/config/servers.py +1 -1
  31. gobby/config/skills.py +43 -0
  32. gobby/config/tasks.py +6 -14
  33. gobby/hooks/event_handlers.py +145 -2
  34. gobby/hooks/hook_manager.py +48 -2
  35. gobby/hooks/skill_manager.py +130 -0
  36. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  37. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  38. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  39. gobby/llm/claude.py +22 -34
  40. gobby/llm/claude_executor.py +46 -256
  41. gobby/llm/codex_executor.py +59 -291
  42. gobby/llm/executor.py +21 -0
  43. gobby/llm/gemini.py +134 -110
  44. gobby/llm/litellm_executor.py +143 -6
  45. gobby/llm/resolver.py +95 -33
  46. gobby/mcp_proxy/instructions.py +54 -0
  47. gobby/mcp_proxy/models.py +15 -0
  48. gobby/mcp_proxy/registries.py +68 -5
  49. gobby/mcp_proxy/server.py +33 -3
  50. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  51. gobby/mcp_proxy/stdio.py +2 -1
  52. gobby/mcp_proxy/tools/__init__.py +0 -2
  53. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  54. gobby/mcp_proxy/tools/clones.py +903 -0
  55. gobby/mcp_proxy/tools/memory.py +1 -24
  56. gobby/mcp_proxy/tools/metrics.py +65 -1
  57. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  59. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  60. gobby/mcp_proxy/tools/session_messages.py +1 -2
  61. gobby/mcp_proxy/tools/skills/__init__.py +631 -0
  62. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  63. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  64. gobby/mcp_proxy/tools/task_sync.py +1 -1
  65. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  66. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  67. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  68. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  69. gobby/mcp_proxy/tools/tasks/_lifecycle.py +60 -29
  70. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  71. gobby/mcp_proxy/tools/workflows.py +1 -1
  72. gobby/mcp_proxy/tools/worktrees.py +5 -0
  73. gobby/memory/backends/__init__.py +6 -1
  74. gobby/memory/backends/mem0.py +6 -1
  75. gobby/memory/extractor.py +477 -0
  76. gobby/memory/manager.py +11 -2
  77. gobby/prompts/defaults/handoff/compact.md +63 -0
  78. gobby/prompts/defaults/handoff/session_end.md +57 -0
  79. gobby/prompts/defaults/memory/extract.md +61 -0
  80. gobby/runner.py +37 -16
  81. gobby/search/__init__.py +48 -6
  82. gobby/search/backends/__init__.py +159 -0
  83. gobby/search/backends/embedding.py +225 -0
  84. gobby/search/embeddings.py +238 -0
  85. gobby/search/models.py +148 -0
  86. gobby/search/unified.py +496 -0
  87. gobby/servers/http.py +23 -8
  88. gobby/servers/routes/admin.py +280 -0
  89. gobby/servers/routes/mcp/tools.py +241 -52
  90. gobby/servers/websocket.py +2 -2
  91. gobby/sessions/analyzer.py +2 -0
  92. gobby/sessions/transcripts/base.py +1 -0
  93. gobby/sessions/transcripts/claude.py +64 -5
  94. gobby/skills/__init__.py +91 -0
  95. gobby/skills/loader.py +685 -0
  96. gobby/skills/manager.py +384 -0
  97. gobby/skills/parser.py +258 -0
  98. gobby/skills/search.py +463 -0
  99. gobby/skills/sync.py +119 -0
  100. gobby/skills/updater.py +385 -0
  101. gobby/skills/validator.py +368 -0
  102. gobby/storage/clones.py +378 -0
  103. gobby/storage/database.py +1 -1
  104. gobby/storage/memories.py +43 -13
  105. gobby/storage/migrations.py +180 -6
  106. gobby/storage/sessions.py +73 -0
  107. gobby/storage/skills.py +749 -0
  108. gobby/storage/tasks/_crud.py +4 -4
  109. gobby/storage/tasks/_lifecycle.py +41 -6
  110. gobby/storage/tasks/_manager.py +14 -5
  111. gobby/storage/tasks/_models.py +8 -3
  112. gobby/sync/memories.py +39 -4
  113. gobby/sync/tasks.py +83 -6
  114. gobby/tasks/__init__.py +1 -2
  115. gobby/tasks/validation.py +24 -15
  116. gobby/tui/api_client.py +4 -7
  117. gobby/tui/app.py +5 -3
  118. gobby/tui/screens/orchestrator.py +1 -2
  119. gobby/tui/screens/tasks.py +2 -4
  120. gobby/tui/ws_client.py +1 -1
  121. gobby/utils/daemon_client.py +2 -2
  122. gobby/workflows/actions.py +84 -2
  123. gobby/workflows/context_actions.py +43 -0
  124. gobby/workflows/detection_helpers.py +115 -31
  125. gobby/workflows/engine.py +13 -2
  126. gobby/workflows/lifecycle_evaluator.py +29 -1
  127. gobby/workflows/loader.py +19 -6
  128. gobby/workflows/memory_actions.py +74 -0
  129. gobby/workflows/summary_actions.py +17 -0
  130. gobby/workflows/task_enforcement_actions.py +448 -6
  131. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
  132. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
  133. gobby/install/codex/prompts/forget.md +0 -7
  134. gobby/install/codex/prompts/memories.md +0 -7
  135. gobby/install/codex/prompts/recall.md +0 -7
  136. gobby/install/codex/prompts/remember.md +0 -13
  137. gobby/llm/gemini_executor.py +0 -339
  138. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  139. gobby/tasks/context.py +0 -747
  140. gobby/tasks/criteria.py +0 -342
  141. gobby/tasks/expansion.py +0 -626
  142. gobby/tasks/prompts/expand.py +0 -327
  143. gobby/tasks/research.py +0 -421
  144. gobby/tasks/tdd.py +0 -352
  145. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/WHEEL +0 -0
  146. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
  147. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
  148. {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/top_level.txt +0 -0
@@ -191,11 +191,17 @@ class ClaudeCodeAdapter(BaseAdapter):
191
191
  additional_context_parts.append(response.context)
192
192
 
193
193
  # Add session identifiers from metadata
194
+ # Note: "session_id" in metadata is Gobby's internal platform session ID
195
+ # "external_id" in metadata is the CLI's session UUID
194
196
  if response.metadata:
195
- session_id = response.metadata.get("session_id")
196
- if session_id:
197
+ gobby_session_id = response.metadata.get("session_id")
198
+ external_id = response.metadata.get("external_id")
199
+ if gobby_session_id:
197
200
  # Build context with all available identifiers
198
- context_lines = [f"session_id: {session_id}"]
201
+ # Use clear naming: Gobby Session ID for MCP calls, External ID for transcripts
202
+ context_lines = [f"Gobby Session ID: {gobby_session_id}"]
203
+ if external_id:
204
+ context_lines.append(f"External ID: {external_id}")
199
205
  if response.metadata.get("parent_session_id"):
200
206
  context_lines.append(
201
207
  f"parent_session_id: {response.metadata['parent_session_id']}"
@@ -227,7 +233,10 @@ class ClaudeCodeAdapter(BaseAdapter):
227
233
  additional_context_parts.append("\n".join(context_lines))
228
234
 
229
235
  # Build hookSpecificOutput if we have any context to inject
230
- if additional_context_parts:
236
+ # Only include hookSpecificOutput for hook types that Claude Code's schema accepts
237
+ # Valid hookEventName values: PreToolUse, UserPromptSubmit, PostToolUse
238
+ valid_hook_event_names = {"PreToolUse", "UserPromptSubmit", "PostToolUse"}
239
+ if additional_context_parts and hook_event_name in valid_hook_event_names:
231
240
  result["hookSpecificOutput"] = {
232
241
  "hookEventName": hook_event_name,
233
242
  "additionalContext": "\n\n".join(additional_context_parts),
gobby/adapters/codex.py CHANGED
@@ -775,6 +775,28 @@ class CodexAdapter(BaseAdapter):
775
775
  "item/completed": HookEventType.AFTER_TOOL,
776
776
  }
777
777
 
778
+ # Tool name mapping: Codex tool names -> canonical CC-style names
779
+ # Codex uses different tool names - normalize to Claude Code conventions
780
+ # so block_tools rules work across CLIs
781
+ TOOL_MAP: dict[str, str] = {
782
+ # File operations
783
+ "read_file": "Read",
784
+ "ReadFile": "Read",
785
+ "write_file": "Write",
786
+ "WriteFile": "Write",
787
+ "edit_file": "Edit",
788
+ "EditFile": "Edit",
789
+ # Shell
790
+ "run_shell_command": "Bash",
791
+ "RunShellCommand": "Bash",
792
+ "commandExecution": "Bash",
793
+ # Search
794
+ "glob": "Glob",
795
+ "grep": "Grep",
796
+ "GlobTool": "Glob",
797
+ "GrepTool": "Grep",
798
+ }
799
+
778
800
  # Item types that represent tool operations
779
801
  TOOL_ITEM_TYPES = {"commandExecution", "fileChange", "mcpToolCall"}
780
802
 
@@ -814,6 +836,19 @@ class CodexAdapter(BaseAdapter):
814
836
  self._machine_id = _get_machine_id()
815
837
  return self._machine_id
816
838
 
839
+ def normalize_tool_name(self, codex_tool_name: str) -> str:
840
+ """Normalize Codex tool name to canonical CC-style format.
841
+
842
+ This ensures block_tools rules work consistently across CLIs.
843
+
844
+ Args:
845
+ codex_tool_name: Tool name from Codex CLI.
846
+
847
+ Returns:
848
+ Normalized tool name (e.g., "Bash", "Read", "Write", "Edit").
849
+ """
850
+ return self.TOOL_MAP.get(codex_tool_name, codex_tool_name)
851
+
817
852
  def attach_to_client(self, codex_client: CodexAppServerClient) -> None:
818
853
  """Attach to an existing CodexAppServerClient for event handling.
819
854
 
@@ -876,14 +911,17 @@ class CodexAdapter(BaseAdapter):
876
911
  thread_id = params.get("threadId", "")
877
912
  item_id = params.get("itemId", "")
878
913
 
879
- # Determine tool name from method
914
+ # Determine tool name from method and normalize to CC-style
880
915
  if "commandExecution" in method:
881
- tool_name = "Bash"
916
+ original_tool = "commandExecution"
917
+ tool_name = self.normalize_tool_name(original_tool) # -> "Bash"
882
918
  tool_input = params.get("parsedCmd", params.get("command", ""))
883
919
  elif "fileChange" in method:
884
- tool_name = "Write"
920
+ original_tool = "fileChange"
921
+ tool_name = "Write" # File changes are writes
885
922
  tool_input = params.get("changes", [])
886
923
  else:
924
+ original_tool = "unknown"
887
925
  tool_name = "unknown"
888
926
  tool_input = params
889
927
 
@@ -905,6 +943,8 @@ class CodexAdapter(BaseAdapter):
905
943
  "requires_response": True,
906
944
  "item_id": item_id,
907
945
  "approval_method": method,
946
+ "original_tool_name": original_tool,
947
+ "normalized_tool_name": tool_name,
908
948
  },
909
949
  )
910
950
 
gobby/agents/runner.py CHANGED
@@ -607,6 +607,14 @@ class AgentRunner:
607
607
  else:
608
608
  self._session_storage.update_status(child_session.id, "failed")
609
609
 
610
+ # Persist cost to session storage for budget tracking
611
+ if result.cost_info and result.cost_info.total_cost > 0:
612
+ self._session_storage.add_cost(child_session.id, result.cost_info.total_cost)
613
+ self.logger.debug(
614
+ f"Persisted cost ${result.cost_info.total_cost:.4f} "
615
+ f"for session {child_session.id}"
616
+ )
617
+
610
618
  # Remove from in-memory tracking
611
619
  self._untrack_running_agent(agent_run.id)
612
620
 
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