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
gobby/cli/conductor.py ADDED
@@ -0,0 +1,266 @@
1
+ """
2
+ Conductor management CLI commands.
3
+
4
+ Commands for managing the conductor loop:
5
+ - start: Start the conductor loop
6
+ - stop: Stop the conductor loop
7
+ - restart: Restart the conductor loop
8
+ - status: Show conductor status
9
+ - chat: Send a message to the conductor
10
+ """
11
+
12
+ import json
13
+
14
+ import click
15
+ import httpx
16
+
17
+
18
+ def get_daemon_url() -> str:
19
+ """Get daemon URL from config."""
20
+ from gobby.config.app import load_config
21
+
22
+ config = load_config()
23
+ return f"http://localhost:{config.daemon_port}"
24
+
25
+
26
+ @click.group()
27
+ def conductor() -> None:
28
+ """Manage the conductor orchestration loop."""
29
+ pass
30
+
31
+
32
+ @conductor.command("start")
33
+ @click.option("--interval", "-i", type=int, default=30, help="Check interval in seconds")
34
+ @click.option("--autonomous", "-a", is_flag=True, help="Enable autonomous agent spawning")
35
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
36
+ def start_conductor(interval: int, autonomous: bool, json_format: bool) -> None:
37
+ """Start the conductor loop.
38
+
39
+ Examples:
40
+
41
+ gobby conductor start
42
+
43
+ gobby conductor start --interval 60
44
+
45
+ gobby conductor start --autonomous
46
+ """
47
+ daemon_url = get_daemon_url()
48
+
49
+ try:
50
+ response = httpx.post(
51
+ f"{daemon_url}/conductor/start",
52
+ json={"interval": interval, "autonomous": autonomous},
53
+ timeout=10.0,
54
+ )
55
+ response.raise_for_status()
56
+ result = response.json()
57
+ except httpx.ConnectError:
58
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
59
+ return
60
+ except httpx.HTTPStatusError as e:
61
+ click.echo(f"Error: HTTP {e.response.status_code}: {e.response.text}", err=True)
62
+ return
63
+ except ValueError as e:
64
+ click.echo(f"Error: Invalid JSON response: {e}", err=True)
65
+ return
66
+ except Exception as e:
67
+ click.echo(f"Error: {e}", err=True)
68
+ return
69
+
70
+ if json_format:
71
+ click.echo(json.dumps(result, indent=2, default=str))
72
+ return
73
+
74
+ if result.get("success"):
75
+ click.echo("Conductor started")
76
+ click.echo(f" Interval: {interval}s")
77
+ if autonomous:
78
+ click.echo(" Autonomous mode: enabled")
79
+ else:
80
+ click.echo(f"Failed to start conductor: {result.get('error')}", err=True)
81
+
82
+
83
+ @conductor.command("stop")
84
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
85
+ def stop_conductor(json_format: bool) -> None:
86
+ """Stop the conductor loop.
87
+
88
+ Examples:
89
+
90
+ gobby conductor stop
91
+ """
92
+ daemon_url = get_daemon_url()
93
+
94
+ try:
95
+ response = httpx.post(
96
+ f"{daemon_url}/conductor/stop",
97
+ json={},
98
+ timeout=10.0,
99
+ )
100
+ response.raise_for_status()
101
+ result = response.json()
102
+ except httpx.ConnectError:
103
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
104
+ return
105
+ except httpx.HTTPStatusError as e:
106
+ click.echo(f"Error: HTTP {e.response.status_code}: {e.response.text}", err=True)
107
+ return
108
+ except ValueError as e:
109
+ click.echo(f"Error: Invalid JSON response: {e}", err=True)
110
+ return
111
+ except Exception as e:
112
+ click.echo(f"Error: {e}", err=True)
113
+ return
114
+
115
+ if json_format:
116
+ click.echo(json.dumps(result, indent=2, default=str))
117
+ return
118
+
119
+ if result.get("success"):
120
+ click.echo("Conductor stopped")
121
+ else:
122
+ click.echo(f"Failed to stop conductor: {result.get('error')}", err=True)
123
+
124
+
125
+ @conductor.command("restart")
126
+ @click.option("--interval", "-i", type=int, default=30, help="Check interval in seconds")
127
+ @click.option("--autonomous", "-a", is_flag=True, help="Enable autonomous agent spawning")
128
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
129
+ def restart_conductor(interval: int, autonomous: bool, json_format: bool) -> None:
130
+ """Restart the conductor loop.
131
+
132
+ Examples:
133
+
134
+ gobby conductor restart
135
+
136
+ gobby conductor restart --interval 60
137
+ """
138
+ daemon_url = get_daemon_url()
139
+
140
+ try:
141
+ response = httpx.post(
142
+ f"{daemon_url}/conductor/restart",
143
+ json={"interval": interval, "autonomous": autonomous},
144
+ timeout=10.0,
145
+ )
146
+ response.raise_for_status()
147
+ result = response.json()
148
+ except httpx.ConnectError:
149
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
150
+ return
151
+ except httpx.HTTPStatusError as e:
152
+ click.echo(f"Error: HTTP {e.response.status_code}: {e.response.text}", err=True)
153
+ return
154
+ except ValueError as e:
155
+ click.echo(f"Error: Invalid JSON response: {e}", err=True)
156
+ return
157
+ except Exception as e:
158
+ click.echo(f"Error: {e}", err=True)
159
+ return
160
+
161
+ if json_format:
162
+ click.echo(json.dumps(result, indent=2, default=str))
163
+ return
164
+
165
+ if result.get("success"):
166
+ click.echo("Conductor restarted")
167
+ else:
168
+ click.echo(f"Failed to restart conductor: {result.get('error')}", err=True)
169
+
170
+
171
+ @conductor.command("status")
172
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
173
+ def status_conductor(json_format: bool) -> None:
174
+ """Show conductor status.
175
+
176
+ Examples:
177
+
178
+ gobby conductor status
179
+
180
+ gobby conductor status --json
181
+ """
182
+ daemon_url = get_daemon_url()
183
+
184
+ try:
185
+ response = httpx.get(
186
+ f"{daemon_url}/conductor/status",
187
+ timeout=10.0,
188
+ )
189
+ response.raise_for_status()
190
+ result = response.json()
191
+ except httpx.ConnectError:
192
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
193
+ return
194
+ except httpx.HTTPStatusError as e:
195
+ click.echo(f"Error: HTTP {e.response.status_code}: {e.response.text}", err=True)
196
+ return
197
+ except ValueError as e:
198
+ click.echo(f"Error: Invalid JSON response: {e}", err=True)
199
+ return
200
+ except Exception as e:
201
+ click.echo(f"Error: {e}", err=True)
202
+ return
203
+
204
+ if json_format:
205
+ click.echo(json.dumps(result, indent=2, default=str))
206
+ return
207
+
208
+ running = result.get("running", False)
209
+ if running:
210
+ click.echo("Conductor: running")
211
+ click.echo(f" Interval: {result.get('interval', 'unknown')}s")
212
+ click.echo(f" Autonomous: {result.get('autonomous', False)}")
213
+ if result.get("last_tick"):
214
+ click.echo(f" Last tick: {result['last_tick']}")
215
+ else:
216
+ click.echo("Conductor: not running")
217
+
218
+
219
+ @conductor.command("chat")
220
+ @click.argument("message")
221
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
222
+ def chat_conductor(message: str, json_format: bool) -> None:
223
+ """Send a message to the conductor.
224
+
225
+ The conductor can process commands like status checks, task queries,
226
+ or trigger manual actions.
227
+
228
+ Examples:
229
+
230
+ gobby conductor chat "Check all tasks"
231
+
232
+ gobby conductor chat "spawn agent for task-123"
233
+
234
+ gobby conductor chat --json "status check"
235
+ """
236
+ daemon_url = get_daemon_url()
237
+
238
+ try:
239
+ response = httpx.post(
240
+ f"{daemon_url}/conductor/chat",
241
+ json={"message": message},
242
+ timeout=30.0,
243
+ )
244
+ response.raise_for_status()
245
+ result = response.json()
246
+ except httpx.ConnectError:
247
+ click.echo("Error: Cannot connect to Gobby daemon. Is it running?", err=True)
248
+ return
249
+ except httpx.HTTPStatusError as e:
250
+ click.echo(f"Error: HTTP {e.response.status_code}: {e.response.text}", err=True)
251
+ return
252
+ except ValueError as e:
253
+ click.echo(f"Error: Invalid JSON response: {e}", 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(result.get("response", "OK"))
265
+ else:
266
+ click.echo(f"Error: {result.get('error', 'Unknown error')}", err=True)
@@ -12,7 +12,7 @@ import logging
12
12
  from pathlib import Path
13
13
  from typing import Any
14
14
 
15
- from .shared import configure_mcp_server_json, install_shared_skills
15
+ from .shared import configure_mcp_server_json
16
16
 
17
17
  logger = logging.getLogger(__name__)
18
18
 
@@ -43,14 +43,8 @@ def install_antigravity(project_path: Path) -> dict[str, Any]:
43
43
  # Configure MCP server in Antigravity's MCP config (~/.gemini/antigravity/mcp_config.json)
44
44
  mcp_config = Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
45
45
 
46
- # Install shared skills to ~/.antigravity/skills/ (Standard Antigravity location)
47
- try:
48
- skills_path = Path.home() / ".antigravity" / "skills"
49
- skills = install_shared_skills(skills_path)
50
- result["commands_installed"].extend([f"{s} (skill)" for s in skills])
51
- except Exception as e:
52
- logger.error(f"Failed to install shared skills: {e}")
53
- # Proceeding despite skill install failure
46
+ # Skills are now auto-synced to database on daemon startup (sync_bundled_skills)
47
+ # No longer need to copy to .antigravity/skills/
54
48
 
55
49
  mcp_result = configure_mcp_server_json(mcp_config)
56
50
 
@@ -17,10 +17,10 @@ from typing import Any
17
17
  from gobby.cli.utils import get_install_dir
18
18
 
19
19
  from .shared import (
20
+ backup_gobby_skills,
20
21
  configure_mcp_server_json,
21
22
  install_cli_content,
22
23
  install_shared_content,
23
- install_shared_skills,
24
24
  remove_mcp_server_json,
25
25
  )
26
26
 
@@ -55,6 +55,12 @@ def install_claude(project_path: Path) -> dict[str, Any]:
55
55
  hooks_dir = claude_path / "hooks"
56
56
  hooks_dir.mkdir(parents=True, exist_ok=True)
57
57
 
58
+ # Backup existing gobby skills (now auto-synced from database)
59
+ skills_dir = claude_path / "skills"
60
+ backup_result = backup_gobby_skills(skills_dir)
61
+ if backup_result["backed_up"] > 0:
62
+ logger.info(f"Backed up {backup_result['backed_up']} existing gobby skills")
63
+
58
64
  # Get source files
59
65
  install_dir = get_install_dir()
60
66
  claude_install_dir = install_dir / "claude"
@@ -119,14 +125,8 @@ def install_claude(project_path: Path) -> dict[str, Any]:
119
125
  result["commands_installed"] = cli.get("commands", [])
120
126
  result["plugins_installed"] = shared.get("plugins", [])
121
127
 
122
- # Install shared skills (SKILL.md)
123
- try:
124
- skills = install_shared_skills(claude_path / "skills")
125
- result["commands_installed"].extend([f"{s} (skill)" for s in skills])
126
- except Exception as e:
127
- logger.error(f"Failed to install shared skills: {e}")
128
- result["error"] = f"Failed to install shared skills: {e}"
129
- # Proceeding despite skill install failure
128
+ # Skills are now auto-synced to database on daemon startup (sync_bundled_skills)
129
+ # No longer need to copy to .claude/skills/
130
130
 
131
131
  # Backup existing settings.json if it exists
132
132
  backup_file = None
@@ -18,7 +18,6 @@ from .shared import (
18
18
  configure_mcp_server_toml,
19
19
  install_cli_content,
20
20
  install_shared_content,
21
- install_shared_skills,
22
21
  remove_mcp_server_toml,
23
22
  )
24
23
 
@@ -69,13 +68,8 @@ def install_codex_notify() -> dict[str, Any]:
69
68
  # Install CLI-specific content (can override shared)
70
69
  cli = install_cli_content("codex", codex_home)
71
70
 
72
- # Install shared skills (SKILL.md)
73
- try:
74
- skills = install_shared_skills(codex_home / "skills")
75
- result["commands_installed"].extend([f"{s} (skill)" for s in skills])
76
- except Exception as e:
77
- logger.error(f"Failed to install shared skills: {e}")
78
- # Proceeding despite skill install failure
71
+ # Skills are now auto-synced to database on daemon startup (sync_bundled_skills)
72
+ # No longer need to copy to .codex/skills/
79
73
 
80
74
  result["workflows_installed"] = shared["workflows"] + cli["workflows"]
81
75
  result["commands_installed"] = cli.get("commands", [])
@@ -18,7 +18,6 @@ from .shared import (
18
18
  configure_mcp_server_json,
19
19
  install_cli_content,
20
20
  install_shared_content,
21
- install_shared_skills,
22
21
  remove_mcp_server_json,
23
22
  )
24
23
 
@@ -81,13 +80,8 @@ def install_gemini(project_path: Path) -> dict[str, Any]:
81
80
  # Install CLI-specific content (can override shared)
82
81
  cli = install_cli_content("gemini", gemini_path)
83
82
 
84
- # Install shared skills (SKILL.md)
85
- try:
86
- skills = install_shared_skills(gemini_path / "skills")
87
- result["commands_installed"].extend([f"{s} (skill)" for s in skills])
88
- except Exception as e:
89
- logger.error(f"Failed to install shared skills: {e}")
90
- # Proceeding despite skill install failure
83
+ # Skills are now auto-synced to database on daemon startup (sync_bundled_skills)
84
+ # No longer need to copy to .gemini/skills/
91
85
 
92
86
  result["workflows_installed"] = shared["workflows"] + cli["workflows"]
93
87
  result["commands_installed"] = cli.get("commands", [])
@@ -41,10 +41,17 @@ def install_shared_content(cli_path: Path, project_path: Path) -> dict[str, list
41
41
  if shared_workflows.exists():
42
42
  target_workflows = project_path / ".gobby" / "workflows"
43
43
  target_workflows.mkdir(parents=True, exist_ok=True)
44
- for workflow_file in shared_workflows.iterdir():
45
- if workflow_file.is_file():
46
- copy2(workflow_file, target_workflows / workflow_file.name)
47
- installed["workflows"].append(workflow_file.name)
44
+ for item in shared_workflows.iterdir():
45
+ if item.is_file():
46
+ copy2(item, target_workflows / item.name)
47
+ installed["workflows"].append(item.name)
48
+ elif item.is_dir():
49
+ # Copy subdirectories (e.g., lifecycle/)
50
+ target_subdir = target_workflows / item.name
51
+ if target_subdir.exists():
52
+ shutil.rmtree(target_subdir)
53
+ copytree(item, target_subdir)
54
+ installed["workflows"].append(f"{item.name}/")
48
55
 
49
56
  # Install shared plugins to ~/.gobby/plugins/ (global)
50
57
  shared_plugins = shared_dir / "plugins"
@@ -69,6 +76,55 @@ def install_shared_content(cli_path: Path, project_path: Path) -> dict[str, list
69
76
  return installed
70
77
 
71
78
 
79
+ def backup_gobby_skills(skills_dir: Path) -> dict[str, Any]:
80
+ """Move gobby-prefixed skill directories to a backup location.
81
+
82
+ This function is called during installation to preserve existing gobby skills
83
+ before they are replaced by database-synced skills. User custom skills
84
+ (non-gobby prefixed) are not touched.
85
+
86
+ Args:
87
+ skills_dir: Path to skills directory (e.g., .claude/skills)
88
+
89
+ Returns:
90
+ Dict with:
91
+ - success: bool
92
+ - backed_up: int - number of skills moved to backup
93
+ - skipped: str (optional) - reason for skipping
94
+ """
95
+ result: dict[str, Any] = {
96
+ "success": True,
97
+ "backed_up": 0,
98
+ }
99
+
100
+ if not skills_dir.exists():
101
+ result["skipped"] = "skills directory does not exist"
102
+ return result
103
+
104
+ # Find gobby-prefixed skill directories
105
+ gobby_skills = [d for d in skills_dir.iterdir() if d.is_dir() and d.name.startswith("gobby-")]
106
+
107
+ if not gobby_skills:
108
+ return result
109
+
110
+ # Create backup directory (sibling to skills/)
111
+ backup_dir = skills_dir.parent / "skills.backup"
112
+ backup_dir.mkdir(parents=True, exist_ok=True)
113
+
114
+ # Move each gobby skill to backup
115
+ import shutil
116
+
117
+ for skill_dir in gobby_skills:
118
+ target = backup_dir / skill_dir.name
119
+ # If already exists in backup, remove it first (replace with newer)
120
+ if target.exists():
121
+ shutil.rmtree(target)
122
+ shutil.move(str(skill_dir), str(target))
123
+ result["backed_up"] += 1
124
+
125
+ return result
126
+
127
+
72
128
  def install_shared_skills(target_dir: Path) -> list[str]:
73
129
  """Install shared SKILL.md files to target directory.
74
130
 
@@ -149,10 +205,17 @@ def install_cli_content(cli_name: str, target_path: Path) -> dict[str, list[str]
149
205
  if cli_workflows.exists():
150
206
  target_workflows = target_path / "workflows"
151
207
  target_workflows.mkdir(parents=True, exist_ok=True)
152
- for workflow_file in cli_workflows.iterdir():
153
- if workflow_file.is_file():
154
- copy2(workflow_file, target_workflows / workflow_file.name)
155
- installed["workflows"].append(workflow_file.name)
208
+ for item in cli_workflows.iterdir():
209
+ if item.is_file():
210
+ copy2(item, target_workflows / item.name)
211
+ installed["workflows"].append(item.name)
212
+ elif item.is_dir():
213
+ # Copy subdirectories
214
+ target_subdir = target_workflows / item.name
215
+ if target_subdir.exists():
216
+ shutil.rmtree(target_subdir)
217
+ copytree(item, target_subdir)
218
+ installed["workflows"].append(f"{item.name}/")
156
219
 
157
220
  # CLI-specific commands (slash commands)
158
221
  # Claude/Gemini: commands/, Codex: prompts/