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/skills.py ADDED
@@ -0,0 +1,858 @@
1
+ """Skills CLI commands.
2
+
3
+ This module provides CLI commands for managing skills:
4
+ - list: List all installed skills
5
+ - show: Show details of a specific skill
6
+ - install: Install a skill from a source
7
+ - remove: Remove an installed skill
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import click
16
+
17
+ from gobby.config.app import DaemonConfig
18
+ from gobby.storage.database import LocalDatabase
19
+ from gobby.storage.skills import LocalSkillManager
20
+ from gobby.utils.daemon_client import DaemonClient
21
+
22
+
23
+ def get_skill_storage() -> LocalSkillManager:
24
+ """Get skill storage manager."""
25
+ db = LocalDatabase()
26
+ return LocalSkillManager(db)
27
+
28
+
29
+ def get_daemon_client(ctx: click.Context) -> DaemonClient:
30
+ """Get daemon client from context config."""
31
+ if ctx.obj is None or "config" not in ctx.obj:
32
+ raise click.ClickException(
33
+ "Configuration not initialized. Ensure the CLI is invoked through the main entry point."
34
+ )
35
+ config = ctx.obj.get("config")
36
+ if not isinstance(config, DaemonConfig):
37
+ raise click.ClickException(
38
+ f"Invalid configuration type: expected DaemonConfig, got {type(config).__name__}"
39
+ )
40
+ return DaemonClient(host="localhost", port=config.daemon_port)
41
+
42
+
43
+ def call_skills_tool(
44
+ client: DaemonClient,
45
+ tool_name: str,
46
+ arguments: dict[str, Any],
47
+ timeout: float = 30.0,
48
+ ) -> dict[str, Any] | None:
49
+ """Call a gobby-skills MCP tool via the daemon.
50
+
51
+ Returns the inner result from the MCP response, or None on error.
52
+ """
53
+ try:
54
+ response = client.call_mcp_tool(
55
+ server_name="gobby-skills",
56
+ tool_name=tool_name,
57
+ arguments=arguments,
58
+ timeout=timeout,
59
+ )
60
+ # Response format is {"success": true, "result": {...}}
61
+ # Extract the inner result for the caller
62
+ if response.get("success") and "result" in response:
63
+ result = response["result"]
64
+ return dict(result) if isinstance(result, dict) else None
65
+ # If outer call failed, return None and log error
66
+ click.echo("Error: MCP call failed", err=True)
67
+ return None
68
+ except Exception as e:
69
+ click.echo(f"Error: {e}", err=True)
70
+ return None
71
+
72
+
73
+ def check_daemon(client: DaemonClient) -> bool:
74
+ """Check if daemon is running."""
75
+ is_healthy, error = client.check_health()
76
+ if not is_healthy:
77
+ click.echo("Error: Daemon not running. Start with: gobby start", err=True)
78
+ return False
79
+ return True
80
+
81
+
82
+ @click.group()
83
+ def skills() -> None:
84
+ """Manage Gobby skills."""
85
+ pass
86
+
87
+
88
+ @skills.command("list")
89
+ @click.option("--category", "-c", help="Filter by category")
90
+ @click.option("--tags", "-t", help="Filter by tags (comma-separated)")
91
+ @click.option("--enabled/--disabled", default=None, help="Filter by enabled status")
92
+ @click.option("--limit", "-n", default=50, help="Maximum skills to show")
93
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
94
+ @click.pass_context
95
+ def list_skills(
96
+ ctx: click.Context,
97
+ category: str | None,
98
+ tags: str | None,
99
+ enabled: bool | None,
100
+ limit: int,
101
+ json_output: bool,
102
+ ) -> None:
103
+ """List installed skills."""
104
+ storage = get_skill_storage()
105
+
106
+ # When filtering by tags, fetch all skills first, then filter and apply limit
107
+ # This ensures the limit applies to filtered results, not pre-filter
108
+ fetch_limit = 10000 if tags else limit
109
+
110
+ skills_list = storage.list_skills(
111
+ category=category,
112
+ enabled=enabled,
113
+ limit=fetch_limit,
114
+ include_global=True,
115
+ )
116
+
117
+ # Filter by tags if specified
118
+ if tags:
119
+ tags_list = [t.strip() for t in tags.split(",") if t.strip()]
120
+ if tags_list:
121
+ filtered_skills = []
122
+ for skill in skills_list:
123
+ skill_tags = _get_skill_tags(skill)
124
+ if any(tag in skill_tags for tag in tags_list):
125
+ filtered_skills.append(skill)
126
+ # Apply limit after tag filtering
127
+ skills_list = filtered_skills[:limit]
128
+
129
+ if json_output:
130
+ _output_json(skills_list)
131
+ return
132
+
133
+ if not skills_list:
134
+ click.echo("No skills found.")
135
+ return
136
+
137
+ for skill in skills_list:
138
+ # Get category from metadata if available
139
+ cat_str = ""
140
+ skill_category = _get_skill_category(skill)
141
+ if skill_category:
142
+ cat_str = f" [{skill_category}]"
143
+
144
+ status = "✓" if skill.enabled else "✗"
145
+ desc = skill.description[:60] if skill.description else ""
146
+ click.echo(f"{status} {skill.name}{cat_str} - {desc}")
147
+
148
+
149
+ def _get_skill_tags(skill: Any) -> list[str]:
150
+ """Extract tags from skill metadata."""
151
+ if skill.metadata and isinstance(skill.metadata, dict):
152
+ skillport = skill.metadata.get("skillport", {})
153
+ if isinstance(skillport, dict):
154
+ tags = skillport.get("tags", [])
155
+ return list(tags) if isinstance(tags, list) else []
156
+ return []
157
+
158
+
159
+ def _get_skill_category(skill: Any) -> str | None:
160
+ """Extract category from skill metadata."""
161
+ if skill.metadata and isinstance(skill.metadata, dict):
162
+ skillport = skill.metadata.get("skillport", {})
163
+ if isinstance(skillport, dict):
164
+ return skillport.get("category")
165
+ return None
166
+
167
+
168
+ def _output_json(skills_list: list[Any]) -> None:
169
+ """Output skills as JSON."""
170
+ output = []
171
+ for skill in skills_list:
172
+ item = {
173
+ "name": skill.name,
174
+ "description": skill.description,
175
+ "enabled": skill.enabled,
176
+ "version": skill.version,
177
+ "category": _get_skill_category(skill),
178
+ "tags": _get_skill_tags(skill),
179
+ }
180
+ output.append(item)
181
+ click.echo(json.dumps(output, indent=2))
182
+
183
+
184
+ @skills.command()
185
+ @click.argument("name")
186
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
187
+ @click.pass_context
188
+ def show(ctx: click.Context, name: str, json_output: bool) -> None:
189
+ """Show details of a specific skill."""
190
+ storage = get_skill_storage()
191
+ skill = storage.get_by_name(name)
192
+
193
+ if skill is None:
194
+ if json_output:
195
+ click.echo(json.dumps({"error": "Skill not found", "name": name}))
196
+ else:
197
+ click.echo(f"Skill not found: {name}")
198
+ sys.exit(1)
199
+
200
+ if json_output:
201
+ output = {
202
+ "name": skill.name,
203
+ "description": skill.description,
204
+ "version": skill.version,
205
+ "license": skill.license,
206
+ "enabled": skill.enabled,
207
+ "source_type": skill.source_type,
208
+ "source_path": skill.source_path,
209
+ "compatibility": skill.compatibility if hasattr(skill, "compatibility") else None,
210
+ "content": skill.content,
211
+ "category": _get_skill_category(skill),
212
+ "tags": _get_skill_tags(skill),
213
+ }
214
+ click.echo(json.dumps(output, indent=2))
215
+ return
216
+
217
+ click.echo(f"Name: {skill.name}")
218
+ click.echo(f"Description: {skill.description}")
219
+ if skill.version:
220
+ click.echo(f"Version: {skill.version}")
221
+ if skill.license:
222
+ click.echo(f"License: {skill.license}")
223
+ click.echo(f"Enabled: {skill.enabled}")
224
+ if skill.source_type:
225
+ click.echo(f"Source: {skill.source_type}")
226
+ if skill.source_path:
227
+ click.echo(f"Path: {skill.source_path}")
228
+ click.echo("")
229
+ click.echo("Content:")
230
+ click.echo("-" * 40)
231
+ click.echo(skill.content)
232
+
233
+
234
+ @skills.command()
235
+ @click.argument("source")
236
+ @click.option("--project", "-p", is_flag=True, help="Install scoped to project")
237
+ @click.pass_context
238
+ def install(ctx: click.Context, source: str, project: bool) -> None:
239
+ """Install a skill from a source.
240
+
241
+ SOURCE can be:
242
+ - A local directory path (e.g., ./my-skill or /path/to/skill)
243
+ - A path to a SKILL.md file (e.g., ./SKILL.md)
244
+ - A GitHub URL (owner/repo, github:owner/repo, https://github.com/owner/repo)
245
+ - A ZIP archive path (e.g., ./skills.zip)
246
+
247
+ Use --project to scope the skill to the current project.
248
+
249
+ Requires daemon to be running.
250
+ """
251
+ client = get_daemon_client(ctx)
252
+ if not check_daemon(client):
253
+ sys.exit(1)
254
+
255
+ result = call_skills_tool(
256
+ client,
257
+ "install_skill",
258
+ {
259
+ "source": source,
260
+ "project_scoped": project,
261
+ },
262
+ )
263
+
264
+ if result is None:
265
+ click.echo("Error: Failed to communicate with daemon", err=True)
266
+ sys.exit(1)
267
+ elif result.get("success"):
268
+ click.echo(
269
+ f"Installed skill: {result.get('skill_name', '<unknown>')} ({result.get('source_type', 'unknown')})"
270
+ )
271
+ else:
272
+ click.echo(f"Error: {result.get('error', 'Unknown error')}", err=True)
273
+ sys.exit(1)
274
+
275
+
276
+ @skills.command()
277
+ @click.argument("name")
278
+ @click.pass_context
279
+ def remove(ctx: click.Context, name: str) -> None:
280
+ """Remove an installed skill.
281
+
282
+ NAME is the skill name to remove (e.g., 'commit-message').
283
+
284
+ Requires daemon to be running.
285
+ """
286
+ client = get_daemon_client(ctx)
287
+ if not check_daemon(client):
288
+ sys.exit(1)
289
+
290
+ result = call_skills_tool(client, "remove_skill", {"name": name})
291
+
292
+ if result is None:
293
+ click.echo("Error: Failed to communicate with daemon", err=True)
294
+ sys.exit(1)
295
+ elif result.get("success"):
296
+ click.echo(f"Removed skill: {result.get('skill_name', name)}")
297
+ else:
298
+ click.echo(f"Error: {result.get('error', 'Unknown error')}", err=True)
299
+ sys.exit(1)
300
+
301
+
302
+ @skills.command()
303
+ @click.argument("name", required=False)
304
+ @click.option("--all", "update_all", is_flag=True, help="Update all installed skills")
305
+ @click.pass_context
306
+ def update(ctx: click.Context, name: str | None, update_all: bool) -> None:
307
+ """Update an installed skill from its source.
308
+
309
+ NAME is the skill name to update (e.g., 'commit-message').
310
+ Use --all to update all skills that have remote sources.
311
+
312
+ Only skills installed from GitHub can be updated (re-fetched from source).
313
+ Local skills are skipped.
314
+
315
+ Requires daemon to be running.
316
+ """
317
+ client = get_daemon_client(ctx)
318
+ if not check_daemon(client):
319
+ sys.exit(1)
320
+
321
+ if not name and not update_all:
322
+ click.echo("Error: Provide a skill name or use --all to update all skills")
323
+ sys.exit(1)
324
+
325
+ if update_all:
326
+ # Get all skills and update each via MCP
327
+ result = call_skills_tool(client, "list_skills", {"limit": 1000})
328
+ if not result or not result.get("success"):
329
+ click.echo(
330
+ f"Error: {result.get('error', 'Failed to list skills') if result else 'No response'}",
331
+ err=True,
332
+ )
333
+ sys.exit(1)
334
+
335
+ updated = 0
336
+ skipped = 0
337
+ for skill in result.get("skills", []):
338
+ update_result = call_skills_tool(client, "update_skill", {"name": skill["name"]})
339
+ if update_result and update_result.get("success"):
340
+ if update_result.get("updated"):
341
+ click.echo(f"Updated: {skill['name']}")
342
+ updated += 1
343
+ else:
344
+ click.echo(
345
+ f"Skipped: {skill['name']} ({update_result.get('skip_reason', 'up to date')})"
346
+ )
347
+ skipped += 1
348
+ else:
349
+ click.echo(f"Failed: {skill['name']}")
350
+ skipped += 1
351
+
352
+ click.echo(f"\nUpdated {updated} skill(s), skipped {skipped}")
353
+ return
354
+
355
+ # Single skill update
356
+ result = call_skills_tool(client, "update_skill", {"name": name})
357
+
358
+ if result is None:
359
+ click.echo("Error: Failed to communicate with daemon", err=True)
360
+ sys.exit(1)
361
+ elif result.get("success"):
362
+ if result.get("updated"):
363
+ click.echo(f"Updated skill: {name}")
364
+ else:
365
+ click.echo(f"Skipped: {result.get('skip_reason', 'already up to date')}")
366
+ else:
367
+ click.echo(f"Error: {result.get('error', 'Unknown error')}", err=True)
368
+ sys.exit(1)
369
+
370
+
371
+ @skills.command()
372
+ @click.argument("path")
373
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
374
+ @click.pass_context
375
+ def validate(ctx: click.Context, path: str, json_output: bool) -> None:
376
+ """Validate a SKILL.md file against the Agent Skills specification.
377
+
378
+ PATH is the path to a SKILL.md file or directory containing one.
379
+
380
+ Validates:
381
+ - name: max 64 chars, lowercase + hyphens only
382
+ - description: max 1024 chars, non-empty
383
+ - version: semver pattern (if provided)
384
+ - category: lowercase alphanumeric + hyphens (if provided)
385
+ - tags: list of strings, each max 64 chars (if provided)
386
+ """
387
+ from gobby.skills.loader import SkillLoader, SkillLoadError
388
+ from gobby.skills.validator import SkillValidator
389
+
390
+ source_path = Path(path)
391
+
392
+ if not source_path.exists():
393
+ if json_output:
394
+ click.echo(json.dumps({"error": "Path not found", "path": path}))
395
+ else:
396
+ click.echo(f"Error: Path not found: {path}")
397
+ sys.exit(1)
398
+
399
+ # Load the skill
400
+ loader = SkillLoader()
401
+ try:
402
+ # Don't validate during load - we want to do it ourselves
403
+ parsed_skill = loader.load_skill(source_path, validate=False, check_dir_name=False)
404
+ except SkillLoadError as e:
405
+ if json_output:
406
+ click.echo(json.dumps({"error": str(e), "path": path}))
407
+ else:
408
+ click.echo(f"Error loading skill: {e}")
409
+ sys.exit(1)
410
+
411
+ # Validate the skill
412
+ validator = SkillValidator()
413
+ result = validator.validate(parsed_skill)
414
+
415
+ if json_output:
416
+ output = result.to_dict()
417
+ output["path"] = path
418
+ output["skill_name"] = parsed_skill.name
419
+ click.echo(json.dumps(output, indent=2))
420
+ if not result.valid:
421
+ sys.exit(1)
422
+ return
423
+
424
+ # Human-readable output
425
+ if result.valid:
426
+ click.echo(f"✓ Valid: {parsed_skill.name}")
427
+ if result.warnings:
428
+ click.echo("\nWarnings:")
429
+ for warning in result.warnings:
430
+ click.echo(f" - {warning}")
431
+ else:
432
+ click.echo(f"✗ Invalid: {parsed_skill.name}")
433
+ click.echo("\nErrors:")
434
+ for error in result.errors:
435
+ click.echo(f" - {error}")
436
+ if result.warnings:
437
+ click.echo("\nWarnings:")
438
+ for warning in result.warnings:
439
+ click.echo(f" - {warning}")
440
+ sys.exit(1)
441
+
442
+
443
+ # Meta subcommand group
444
+ @skills.group()
445
+ def meta() -> None:
446
+ """Manage skill metadata fields."""
447
+ pass
448
+
449
+
450
+ def _get_nested_value(data: dict[str, Any], key: str) -> Any:
451
+ """Get a nested value from a dict using dot notation."""
452
+ keys = key.split(".")
453
+ current = data
454
+ for k in keys:
455
+ if not isinstance(current, dict) or k not in current:
456
+ return None
457
+ current = current[k]
458
+ return current
459
+
460
+
461
+ def _set_nested_value(data: dict[str, Any], key: str, value: Any) -> dict[str, Any]:
462
+ """Set a nested value in a dict using dot notation."""
463
+ keys = key.split(".")
464
+ result = data.copy() if data else {}
465
+ current = result
466
+
467
+ # Navigate to parent, creating dicts as needed
468
+ for k in keys[:-1]:
469
+ if k not in current or not isinstance(current[k], dict):
470
+ current[k] = {}
471
+ else:
472
+ current[k] = current[k].copy()
473
+ current = current[k]
474
+
475
+ # Set the final key
476
+ current[keys[-1]] = value
477
+ return result
478
+
479
+
480
+ def _unset_nested_value(data: dict[str, Any], key: str) -> dict[str, Any]:
481
+ """Remove a nested value from a dict using dot notation."""
482
+ if not data:
483
+ return {}
484
+
485
+ keys = key.split(".")
486
+ result = data.copy()
487
+
488
+ if len(keys) == 1:
489
+ # Simple key
490
+ result.pop(keys[0], None)
491
+ return result
492
+
493
+ # Navigate to parent
494
+ current = result
495
+ parents: list[tuple[dict[str, Any], str]] = []
496
+
497
+ for k in keys[:-1]:
498
+ if not isinstance(current, dict) or k not in current:
499
+ return result # Key doesn't exist, nothing to do
500
+ parents.append((current, k))
501
+ if isinstance(current[k], dict):
502
+ current[k] = current[k].copy()
503
+ current = current[k]
504
+
505
+ # Remove the final key
506
+ if isinstance(current, dict) and keys[-1] in current:
507
+ del current[keys[-1]]
508
+
509
+ return result
510
+
511
+
512
+ @meta.command("get")
513
+ @click.argument("name")
514
+ @click.argument("key")
515
+ @click.pass_context
516
+ def meta_get(ctx: click.Context, name: str, key: str) -> None:
517
+ """Get a metadata field value.
518
+
519
+ NAME is the skill name.
520
+ KEY is the metadata field (supports dot notation for nested keys).
521
+
522
+ Examples:
523
+ gobby skills meta get my-skill author
524
+ gobby skills meta get my-skill skillport.category
525
+ """
526
+ storage = get_skill_storage()
527
+ skill = storage.get_by_name(name)
528
+
529
+ if skill is None:
530
+ click.echo(f"Skill not found: {name}", err=True)
531
+ sys.exit(1)
532
+
533
+ if not skill.metadata:
534
+ click.echo("null")
535
+ return
536
+
537
+ value = _get_nested_value(skill.metadata, key)
538
+ if value is None:
539
+ click.echo(f"Key not found: {key}")
540
+ sys.exit(1)
541
+ elif isinstance(value, (dict, list)):
542
+ click.echo(json.dumps(value, indent=2))
543
+ else:
544
+ click.echo(str(value))
545
+
546
+
547
+ @meta.command("set")
548
+ @click.argument("name")
549
+ @click.argument("key")
550
+ @click.argument("value")
551
+ @click.pass_context
552
+ def meta_set(ctx: click.Context, name: str, key: str, value: str) -> None:
553
+ """Set a metadata field value.
554
+
555
+ NAME is the skill name.
556
+ KEY is the metadata field (supports dot notation for nested keys).
557
+ VALUE is the value to set.
558
+
559
+ Examples:
560
+ gobby skills meta set my-skill author "John Doe"
561
+ gobby skills meta set my-skill skillport.category git
562
+ """
563
+ storage = get_skill_storage()
564
+ skill = storage.get_by_name(name)
565
+
566
+ if skill is None:
567
+ click.echo(f"Skill not found: {name}", err=True)
568
+ sys.exit(1)
569
+
570
+ # Try to parse value as JSON for complex types
571
+ try:
572
+ parsed_value = json.loads(value)
573
+ except json.JSONDecodeError:
574
+ parsed_value = value
575
+
576
+ new_metadata = _set_nested_value(skill.metadata or {}, key, parsed_value)
577
+ try:
578
+ storage.update_skill(skill.id, metadata=new_metadata)
579
+ except Exception as e:
580
+ click.echo(f"Error updating skill metadata: {e}", err=True)
581
+ sys.exit(1)
582
+ click.echo(f"Set {key} = {value}")
583
+
584
+
585
+ @meta.command("unset")
586
+ @click.argument("name")
587
+ @click.argument("key")
588
+ @click.pass_context
589
+ def meta_unset(ctx: click.Context, name: str, key: str) -> None:
590
+ """Remove a metadata field.
591
+
592
+ NAME is the skill name.
593
+ KEY is the metadata field (supports dot notation for nested keys).
594
+
595
+ Examples:
596
+ gobby skills meta unset my-skill author
597
+ gobby skills meta unset my-skill skillport.tags
598
+ """
599
+ storage = get_skill_storage()
600
+ skill = storage.get_by_name(name)
601
+
602
+ if skill is None:
603
+ click.echo(f"Skill not found: {name}", err=True)
604
+ sys.exit(1)
605
+
606
+ if not skill.metadata:
607
+ click.echo(f"Key not found: {key}")
608
+ return
609
+
610
+ new_metadata = _unset_nested_value(skill.metadata, key)
611
+ try:
612
+ storage.update_skill(skill.id, metadata=new_metadata)
613
+ except Exception as e:
614
+ click.echo(f"Error updating skill metadata: {e}", err=True)
615
+ sys.exit(1)
616
+ click.echo(f"Unset {key}")
617
+
618
+
619
+ @skills.command()
620
+ @click.pass_context
621
+ def init(ctx: click.Context) -> None:
622
+ """Initialize skills directory for the current project.
623
+
624
+ Creates .gobby/skills/ directory and config file for local skill management.
625
+ This is idempotent - running init multiple times is safe.
626
+ """
627
+ import yaml
628
+
629
+ skills_dir = Path(".gobby/skills")
630
+ config_file = skills_dir / "config.yaml"
631
+
632
+ # Create .gobby directory if needed
633
+ gobby_dir = Path(".gobby")
634
+ if not gobby_dir.exists():
635
+ gobby_dir.mkdir(parents=True)
636
+
637
+ # Create skills directory
638
+ if not skills_dir.exists():
639
+ skills_dir.mkdir(parents=True)
640
+ click.echo(f"Created {skills_dir}/")
641
+ else:
642
+ click.echo(f"Skills directory already exists: {skills_dir}/")
643
+
644
+ # Create config file if it doesn't exist
645
+ if not config_file.exists():
646
+ default_config = {
647
+ "version": "1.0",
648
+ "skills": {
649
+ "enabled": True,
650
+ "auto_discover": True,
651
+ "search_paths": ["./skills", "./.gobby/skills"],
652
+ },
653
+ }
654
+ with open(config_file, "w", encoding="utf-8") as f:
655
+ yaml.dump(default_config, f, default_flow_style=False)
656
+ click.echo(f"Created {config_file}")
657
+ else:
658
+ click.echo(f"Config already exists: {config_file}")
659
+
660
+ click.echo("\nSkills initialized successfully!")
661
+
662
+
663
+ @skills.command()
664
+ @click.argument("name")
665
+ @click.option("--description", "-d", default=None, help="Skill description")
666
+ @click.pass_context
667
+ def new(ctx: click.Context, name: str, description: str | None) -> None:
668
+ """Create a new skill scaffold.
669
+
670
+ NAME is the skill name (lowercase, hyphens allowed).
671
+
672
+ Creates a new skill directory with:
673
+ - SKILL.md with frontmatter template
674
+ - scripts/ directory for helper scripts
675
+ - assets/ directory for images and files
676
+ - references/ directory for documentation
677
+ """
678
+ import re
679
+
680
+ # Validate skill name format: lowercase letters, digits, hyphens only
681
+ # No leading/trailing hyphens, no spaces, no consecutive hyphens
682
+ name_pattern = re.compile(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$")
683
+ if not name_pattern.match(name):
684
+ click.echo(
685
+ f"Error: Invalid skill name '{name}'. "
686
+ "Name must be lowercase letters, digits, and hyphens only. "
687
+ "Must start with a letter and cannot have leading/trailing or consecutive hyphens.",
688
+ err=True,
689
+ )
690
+ sys.exit(1)
691
+
692
+ skill_dir = Path(name)
693
+
694
+ # Check if directory already exists
695
+ if skill_dir.exists():
696
+ click.echo(f"Directory already exists: {name}", err=True)
697
+ sys.exit(1)
698
+
699
+ # Create skill directory structure
700
+ skill_dir.mkdir(parents=True)
701
+ (skill_dir / "scripts").mkdir()
702
+ (skill_dir / "assets").mkdir()
703
+ (skill_dir / "references").mkdir()
704
+
705
+ # Default description if not provided
706
+ if description is None:
707
+ description = f"Description for {name}"
708
+
709
+ # Create SKILL.md with template
710
+ skill_template = f"""---
711
+ name: {name}
712
+ description: {description}
713
+ version: "1.0.0"
714
+ metadata:
715
+ skillport:
716
+ category: general
717
+ tags: []
718
+ alwaysApply: false
719
+ gobby:
720
+ triggers: []
721
+ ---
722
+
723
+ # {name.replace("-", " ").title()}
724
+
725
+ ## Overview
726
+
727
+ {description}
728
+
729
+ ## Instructions
730
+
731
+ Add your skill instructions here.
732
+
733
+ ## Examples
734
+
735
+ Provide usage examples here.
736
+ """
737
+
738
+ with open(skill_dir / "SKILL.md", "w", encoding="utf-8") as f:
739
+ f.write(skill_template)
740
+
741
+ click.echo(f"Created skill scaffold: {name}/")
742
+ click.echo(f" - {name}/SKILL.md")
743
+ click.echo(f" - {name}/scripts/")
744
+ click.echo(f" - {name}/assets/")
745
+ click.echo(f" - {name}/references/")
746
+
747
+
748
+ @skills.command()
749
+ @click.option("--output", "-o", default=None, help="Output file path")
750
+ @click.option(
751
+ "--format",
752
+ "output_format",
753
+ type=click.Choice(["markdown", "json"]),
754
+ default="markdown",
755
+ help="Output format",
756
+ )
757
+ @click.pass_context
758
+ def doc(ctx: click.Context, output: str | None, output_format: str) -> None:
759
+ """Generate documentation for installed skills.
760
+
761
+ Creates a markdown table or JSON list of all installed skills.
762
+ Use --output to write to a file instead of stdout.
763
+ """
764
+ storage = get_skill_storage()
765
+ skills_list = storage.list_skills(include_global=True)
766
+
767
+ if not skills_list:
768
+ click.echo("No skills installed.")
769
+ return
770
+
771
+ if output_format == "json":
772
+ # JSON output
773
+ output_data = []
774
+ for skill in skills_list:
775
+ item = {
776
+ "name": skill.name,
777
+ "description": skill.description,
778
+ "enabled": skill.enabled,
779
+ "version": skill.version,
780
+ "category": _get_skill_category(skill),
781
+ "tags": _get_skill_tags(skill),
782
+ }
783
+ output_data.append(item)
784
+
785
+ content = json.dumps(output_data, indent=2)
786
+ else:
787
+ # Markdown table output
788
+ lines = [
789
+ "# Installed Skills",
790
+ "",
791
+ "| Name | Description | Category | Enabled |",
792
+ "|------|-------------|----------|---------|",
793
+ ]
794
+
795
+ for skill in skills_list:
796
+ category = (_get_skill_category(skill) or "-").replace("|", "\\|")
797
+ enabled = "✓" if skill.enabled else "✗"
798
+ desc_full = skill.description or ""
799
+ desc = desc_full[:50] + "..." if len(desc_full) > 50 else desc_full
800
+ # Escape pipe characters for valid markdown table
801
+ name_safe = skill.name.replace("|", "\\|")
802
+ desc_safe = desc.replace("|", "\\|")
803
+ lines.append(f"| {name_safe} | {desc_safe} | {category} | {enabled} |")
804
+
805
+ content = "\n".join(lines)
806
+
807
+ if output:
808
+ with open(output, "w", encoding="utf-8") as f:
809
+ f.write(content)
810
+ click.echo(f"Written to {output}")
811
+ else:
812
+ click.echo(content)
813
+
814
+
815
+ @skills.command()
816
+ @click.argument("name")
817
+ @click.pass_context
818
+ def enable(ctx: click.Context, name: str) -> None:
819
+ """Enable a skill.
820
+
821
+ NAME is the skill name to enable.
822
+ """
823
+ storage = get_skill_storage()
824
+ skill = storage.get_by_name(name)
825
+
826
+ if skill is None:
827
+ click.echo(f"Skill not found: {name}", err=True)
828
+ sys.exit(1)
829
+
830
+ try:
831
+ storage.update_skill(skill.id, enabled=True)
832
+ except Exception as e:
833
+ click.echo(f"Error enabling skill: {e}", err=True)
834
+ sys.exit(1)
835
+ click.echo(f"Enabled skill: {name}")
836
+
837
+
838
+ @skills.command()
839
+ @click.argument("name")
840
+ @click.pass_context
841
+ def disable(ctx: click.Context, name: str) -> None:
842
+ """Disable a skill.
843
+
844
+ NAME is the skill name to disable.
845
+ """
846
+ storage = get_skill_storage()
847
+ skill = storage.get_by_name(name)
848
+
849
+ if skill is None:
850
+ click.echo(f"Skill not found: {name}", err=True)
851
+ sys.exit(1)
852
+
853
+ try:
854
+ storage.update_skill(skill.id, enabled=False)
855
+ except Exception as e:
856
+ click.echo(f"Error disabling skill: {e}", err=True)
857
+ sys.exit(1)
858
+ click.echo(f"Disabled skill: {name}")