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.
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex.py +43 -3
- gobby/agents/runner.py +8 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +9 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +2 -8
- gobby/cli/installers/shared.py +71 -8
- 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 +3 -3
- 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/app.py +63 -1
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +6 -14
- gobby/hooks/event_handlers.py +145 -2
- gobby/hooks/hook_manager.py +48 -2
- gobby/hooks/skill_manager.py +130 -0
- 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 +95 -33
- gobby/mcp_proxy/instructions.py +54 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -5
- gobby/mcp_proxy/server.py +33 -3
- 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/clones.py +903 -0
- gobby/mcp_proxy/tools/memory.py +1 -24
- 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/session_messages.py +1 -2
- gobby/mcp_proxy/tools/skills/__init__.py +631 -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 +60 -29
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +5 -0
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/manager.py +11 -2
- gobby/prompts/defaults/handoff/compact.md +63 -0
- gobby/prompts/defaults/handoff/session_end.md +57 -0
- gobby/prompts/defaults/memory/extract.md +61 -0
- 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 +23 -8
- gobby/servers/routes/admin.py +280 -0
- gobby/servers/routes/mcp/tools.py +241 -52
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +64 -5
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +258 -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 +180 -6
- gobby/storage/sessions.py +73 -0
- gobby/storage/skills.py +749 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +41 -6
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +39 -4
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/validation.py +24 -15
- 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/workflows/actions.py +84 -2
- gobby/workflows/context_actions.py +43 -0
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/engine.py +13 -2
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/loader.py +19 -6
- gobby/workflows/memory_actions.py +74 -0
- gobby/workflows/summary_actions.py +17 -0
- gobby/workflows/task_enforcement_actions.py +448 -6
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/METADATA +82 -21
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/RECORD +136 -107
- 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/task_expansion.py +0 -591
- 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.6.dist-info}/WHEEL +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.6.dist-info}/licenses/LICENSE.md +0 -0
- {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}")
|