gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  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/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from typing import TYPE_CHECKING
9
9
 
10
10
  from gobby.agents.constants import get_terminal_env_vars
11
+ from gobby.agents.sandbox import SandboxConfig, compute_sandbox_paths, get_sandbox_resolver
11
12
  from gobby.agents.spawners.base import EmbeddedPTYResult
12
13
 
13
14
  # pty is only available on Unix-like systems
@@ -37,11 +38,11 @@ def _get_spawn_utils() -> tuple[
37
38
  MAX_ENV_PROMPT_LENGTH as _MAX_ENV_PROMPT_LENGTH,
38
39
  )
39
40
  from gobby.agents.spawn import (
40
- _create_prompt_file,
41
41
  build_cli_command,
42
+ create_prompt_file,
42
43
  )
43
44
 
44
- return build_cli_command, _create_prompt_file, _MAX_ENV_PROMPT_LENGTH
45
+ return build_cli_command, create_prompt_file, _MAX_ENV_PROMPT_LENGTH
45
46
 
46
47
 
47
48
  class EmbeddedSpawner:
@@ -169,6 +170,7 @@ class EmbeddedSpawner:
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
  ) -> EmbeddedPTYResult:
173
175
  """
174
176
  Spawn a CLI agent with embedded PTY.
@@ -184,12 +186,24 @@ class EmbeddedSpawner:
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
  EmbeddedPTYResult with PTY info
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,
@@ -197,6 +211,7 @@ class EmbeddedSpawner:
197
211
  session_id=session_id,
198
212
  auto_approve=True, # Subagents need to work autonomously
199
213
  working_directory=str(cwd) if cli == "codex" else None,
214
+ sandbox_args=sandbox_args,
200
215
  )
201
216
 
202
217
  # Handle prompt for environment variables (backup for hooks/context)
@@ -222,4 +237,8 @@ class EmbeddedSpawner:
222
237
  prompt_file=prompt_file,
223
238
  )
224
239
 
240
+ # Merge sandbox environment variables if present
241
+ if sandbox_env:
242
+ env.update(sandbox_env)
243
+
225
244
  return self.spawn(command, cwd, env)
@@ -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)
@@ -31,6 +31,24 @@ class GhosttySpawner(TerminalSpawnerBase):
31
31
  def terminal_type(self) -> TerminalType:
32
32
  return TerminalType.GHOSTTY
33
33
 
34
+ def _is_ghostty_running(self) -> bool:
35
+ """Check if Ghostty is currently running on macOS."""
36
+ try:
37
+ result = subprocess.run( # nosec B603, B607 - osascript is safe
38
+ [
39
+ "/usr/bin/osascript",
40
+ "-e",
41
+ 'tell application "System Events" to (name of processes) contains "Ghostty"',
42
+ ],
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=5,
46
+ )
47
+ return result.stdout.strip().lower() == "true"
48
+ except Exception:
49
+ # If we can't determine, assume running to use safer -n behavior
50
+ return True
51
+
34
52
  def is_available(self) -> bool:
35
53
  config = get_tty_config().get_terminal_config("ghostty")
36
54
  if not config.enabled:
@@ -66,7 +84,14 @@ class GhosttySpawner(TerminalSpawnerBase):
66
84
  ghostty_args.extend(tty_config.options)
67
85
  ghostty_args.extend(["-e"] + command)
68
86
 
69
- args = ["open", "-na", app_path, "--args"] + ghostty_args
87
+ # Check if Ghostty is already running
88
+ # If running: use -n to open a new window
89
+ # If not running: omit -n to avoid double window on first launch
90
+ ghostty_running = self._is_ghostty_running()
91
+ if ghostty_running:
92
+ args = ["open", "-na", app_path, "--args"] + ghostty_args
93
+ else:
94
+ args = ["open", "-a", app_path, "--args"] + ghostty_args
70
95
  else:
71
96
  # On Linux/other platforms, use ghostty CLI directly
72
97
  cli_command = tty_config.command or "ghostty"
@@ -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
@@ -24,7 +24,6 @@ from .projects import projects
24
24
  from .sessions import sessions
25
25
  from .skills import skills
26
26
  from .tasks import tasks
27
- from .tui import ui
28
27
  from .workflows import workflows
29
28
  from .worktrees import worktrees
30
29
 
@@ -70,4 +69,3 @@ cli.add_command(conductor)
70
69
  cli.add_command(hooks)
71
70
  cli.add_command(plugins)
72
71
  cli.add_command(webhooks)
73
- cli.add_command(ui)
gobby/cli/install.py CHANGED
@@ -250,7 +250,7 @@ def install(
250
250
  click.echo(f" - {cmd}")
251
251
  if result.get("plugins_installed"):
252
252
  click.echo(
253
- f"Installed {len(result['plugins_installed'])} plugins to ~/.gobby/plugins/"
253
+ f"Installed {len(result['plugins_installed'])} plugins to .gobby/plugins/"
254
254
  )
255
255
  for plugin in result["plugins_installed"]:
256
256
  click.echo(f" - {plugin}")
@@ -287,7 +287,7 @@ def install(
287
287
  click.echo(f" - {cmd}")
288
288
  if result.get("plugins_installed"):
289
289
  click.echo(
290
- f"Installed {len(result['plugins_installed'])} plugins to ~/.gobby/plugins/"
290
+ f"Installed {len(result['plugins_installed'])} plugins to .gobby/plugins/"
291
291
  )
292
292
  for plugin in result["plugins_installed"]:
293
293
  click.echo(f" - {plugin}")
@@ -334,7 +334,7 @@ def install(
334
334
  click.echo(f" - {cmd}")
335
335
  if result.get("plugins_installed"):
336
336
  click.echo(
337
- f"Installed {len(result['plugins_installed'])} plugins to ~/.gobby/plugins/"
337
+ f"Installed {len(result['plugins_installed'])} plugins to .gobby/plugins/"
338
338
  )
339
339
  for plugin in result["plugins_installed"]:
340
340
  click.echo(f" - {plugin}")
@@ -395,7 +395,7 @@ def install(
395
395
  click.echo(f" - {cmd}")
396
396
  if result.get("plugins_installed"):
397
397
  click.echo(
398
- f"Installed {len(result['plugins_installed'])} plugins to ~/.gobby/plugins/"
398
+ f"Installed {len(result['plugins_installed'])} plugins to .gobby/plugins/"
399
399
  )
400
400
  for plugin in result["plugins_installed"]:
401
401
  click.echo(f" - {plugin}")
@@ -20,6 +20,7 @@ from .shared import (
20
20
  backup_gobby_skills,
21
21
  configure_mcp_server_json,
22
22
  install_cli_content,
23
+ install_router_skills_as_commands,
23
24
  install_shared_content,
24
25
  remove_mcp_server_json,
25
26
  )
@@ -125,6 +126,11 @@ def install_claude(project_path: Path) -> dict[str, Any]:
125
126
  result["commands_installed"] = cli.get("commands", [])
126
127
  result["plugins_installed"] = shared.get("plugins", [])
127
128
 
129
+ # Install router skills (gobby, g) as flattened commands
130
+ commands_dir = claude_path / "commands"
131
+ router_commands = install_router_skills_as_commands(commands_dir)
132
+ result["commands_installed"].extend(router_commands)
133
+
128
134
  # Skills are now auto-synced to database on daemon startup (sync_bundled_skills)
129
135
  # No longer need to copy to .claude/skills/
130
136
 
@@ -17,6 +17,7 @@ from gobby.cli.utils import get_install_dir
17
17
  from .shared import (
18
18
  configure_mcp_server_json,
19
19
  install_cli_content,
20
+ install_router_skills_as_gemini_skills,
20
21
  install_shared_content,
21
22
  remove_mcp_server_json,
22
23
  )
@@ -87,6 +88,11 @@ def install_gemini(project_path: Path) -> dict[str, Any]:
87
88
  result["commands_installed"] = cli.get("commands", [])
88
89
  result["plugins_installed"] = shared.get("plugins", [])
89
90
 
91
+ # Install router skills (gobby, g) as Gemini skills
92
+ skills_dir = gemini_path / "skills"
93
+ router_skills = install_router_skills_as_gemini_skills(skills_dir)
94
+ result["commands_installed"].extend(router_skills)
95
+
90
96
  # Backup existing settings.json if it exists
91
97
  if settings_file.exists():
92
98
  timestamp = int(time.time())
@@ -23,7 +23,8 @@ def install_shared_content(cli_path: Path, project_path: Path) -> dict[str, list
23
23
  """Install shared content from src/install/shared/.
24
24
 
25
25
  Workflows are cross-CLI and go to {project_path}/.gobby/workflows/.
26
- Plugins are global and go to ~/.gobby/plugins/.
26
+ Plugins are project-scoped and go to {project_path}/.gobby/plugins/.
27
+ Prompts are project-scoped and go to {project_path}/.gobby/prompts/.
27
28
  Docs are project-local and go to {project_path}/.gobby/docs/.
28
29
 
29
30
  Args:
@@ -34,7 +35,7 @@ def install_shared_content(cli_path: Path, project_path: Path) -> dict[str, list
34
35
  Dict with lists of installed items by type
35
36
  """
36
37
  shared_dir = get_install_dir() / "shared"
37
- installed: dict[str, list[str]] = {"workflows": [], "plugins": [], "docs": []}
38
+ installed: dict[str, list[str]] = {"workflows": [], "plugins": [], "prompts": [], "docs": []}
38
39
 
39
40
  # Install shared workflows to .gobby/workflows/ (cross-CLI)
40
41
  shared_workflows = shared_dir / "workflows"
@@ -53,16 +54,33 @@ def install_shared_content(cli_path: Path, project_path: Path) -> dict[str, list
53
54
  copytree(item, target_subdir)
54
55
  installed["workflows"].append(f"{item.name}/")
55
56
 
56
- # Install shared plugins to ~/.gobby/plugins/ (global)
57
+ # Install shared plugins to .gobby/plugins/ (project-scoped)
57
58
  shared_plugins = shared_dir / "plugins"
58
59
  if shared_plugins.exists():
59
- target_plugins = Path("~/.gobby/plugins").expanduser()
60
+ target_plugins = project_path / ".gobby" / "plugins"
60
61
  target_plugins.mkdir(parents=True, exist_ok=True)
61
62
  for plugin_file in shared_plugins.iterdir():
62
63
  if plugin_file.is_file() and plugin_file.suffix == ".py":
63
64
  copy2(plugin_file, target_plugins / plugin_file.name)
64
65
  installed["plugins"].append(plugin_file.name)
65
66
 
67
+ # Install shared prompts to .gobby/prompts/ (project-scoped)
68
+ shared_prompts = shared_dir / "prompts"
69
+ if shared_prompts.exists():
70
+ target_prompts = project_path / ".gobby" / "prompts"
71
+ target_prompts.mkdir(parents=True, exist_ok=True)
72
+ for item in shared_prompts.iterdir():
73
+ if item.is_file():
74
+ copy2(item, target_prompts / item.name)
75
+ installed["prompts"].append(item.name)
76
+ elif item.is_dir():
77
+ # Copy subdirectories (e.g., expansion/, validation/)
78
+ target_subdir = target_prompts / item.name
79
+ if target_subdir.exists():
80
+ shutil.rmtree(target_subdir)
81
+ copytree(item, target_subdir)
82
+ installed["prompts"].append(f"{item.name}/")
83
+
66
84
  # Install shared docs to .gobby/docs/ (project-local)
67
85
  shared_docs = shared_dir / "docs"
68
86
  if shared_docs.exists():
@@ -185,6 +203,87 @@ def install_shared_skills(target_dir: Path) -> list[str]:
185
203
  return installed
186
204
 
187
205
 
206
+ def install_router_skills_as_commands(target_commands_dir: Path) -> list[str]:
207
+ """Install router skills (gobby, g) as flattened Claude commands.
208
+
209
+ Claude Code uses .claude/commands/name.md format for slash commands.
210
+ This function copies the gobby router skills from shared/skills/ to
211
+ commands/ as flattened .md files.
212
+
213
+ Args:
214
+ target_commands_dir: Path to commands directory (e.g., .claude/commands)
215
+
216
+ Returns:
217
+ List of installed command names
218
+ """
219
+ shared_skills_dir = get_install_dir() / "shared" / "skills"
220
+ installed: list[str] = []
221
+
222
+ # Router skills to install as commands
223
+ router_skills = ["gobby", "g"]
224
+
225
+ target_commands_dir.mkdir(parents=True, exist_ok=True)
226
+
227
+ for skill_name in router_skills:
228
+ source_skill_md = shared_skills_dir / skill_name / "SKILL.md"
229
+ if not source_skill_md.exists():
230
+ logger.warning(f"Router skill not found: {source_skill_md}")
231
+ continue
232
+
233
+ # Flatten: copy SKILL.md to commands/name.md
234
+ target_cmd = target_commands_dir / f"{skill_name}.md"
235
+
236
+ try:
237
+ copy2(source_skill_md, target_cmd)
238
+ installed.append(f"{skill_name}.md")
239
+ except OSError as e:
240
+ logger.error(f"Failed to copy router skill {skill_name}: {e}")
241
+
242
+ return installed
243
+
244
+
245
+ def install_router_skills_as_gemini_skills(target_skills_dir: Path) -> list[str]:
246
+ """Install router skills (gobby, g) as Gemini skills (directory structure).
247
+
248
+ Gemini CLI uses .gemini/skills/name/SKILL.md format for skills.
249
+ This function copies the gobby router skills from shared/skills/ to
250
+ the target skills directory preserving the directory structure.
251
+
252
+ Args:
253
+ target_skills_dir: Path to skills directory (e.g., .gemini/skills)
254
+
255
+ Returns:
256
+ List of installed skill names
257
+ """
258
+ shared_skills_dir = get_install_dir() / "shared" / "skills"
259
+ installed: list[str] = []
260
+
261
+ # Router skills to install
262
+ router_skills = ["gobby", "g"]
263
+
264
+ target_skills_dir.mkdir(parents=True, exist_ok=True)
265
+
266
+ for skill_name in router_skills:
267
+ source_skill_dir = shared_skills_dir / skill_name
268
+ source_skill_md = source_skill_dir / "SKILL.md"
269
+ if not source_skill_md.exists():
270
+ logger.warning(f"Router skill not found: {source_skill_md}")
271
+ continue
272
+
273
+ # Create skill directory and copy SKILL.md
274
+ target_skill_dir = target_skills_dir / skill_name
275
+ target_skill_dir.mkdir(parents=True, exist_ok=True)
276
+ target_skill_md = target_skill_dir / "SKILL.md"
277
+
278
+ try:
279
+ copy2(source_skill_md, target_skill_md)
280
+ installed.append(f"{skill_name}/")
281
+ except OSError as e:
282
+ logger.error(f"Failed to copy router skill {skill_name}: {e}")
283
+
284
+ return installed
285
+
286
+
188
287
  def install_cli_content(cli_name: str, target_path: Path) -> dict[str, list[str]]:
189
288
  """Install CLI-specific workflows/commands (layered on top of shared).
190
289