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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/runner.py +8 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +15 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +8 -8
- gobby/cli/installers/shared.py +175 -13
- gobby/cli/sessions.py +1 -1
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +12 -5
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +69 -91
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +9 -41
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +188 -2
- gobby/hooks/hook_manager.py +50 -4
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/skill_manager.py +130 -0
- gobby/hooks/webhooks.py +1 -1
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +98 -35
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +56 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -8
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +518 -0
- gobby/mcp_proxy/tools/memory.py +3 -26
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +616 -0
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -338
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +73 -285
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +24 -12
- gobby/servers/routes/admin.py +294 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1317
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +2 -0
- gobby/sessions/transcripts/claude.py +79 -10
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +286 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +162 -201
- gobby/storage/sessions.py +116 -7
- gobby/storage/skills.py +782 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +57 -7
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +40 -5
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +46 -35
- gobby/tools/summarizer.py +91 -10
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1135
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +93 -1
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
- gobby/workflows/engine.py +13 -2
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/loader.py +19 -6
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +154 -0
- gobby/workflows/safe_evaluator.py +183 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +111 -1
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1292
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/session_messages.py +0 -1056
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {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,
|
|
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
|