specify-cli 0.0.22__py3-none-any.whl → 0.9.5.dev0__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.
- specify_cli/__init__.py +3273 -1189
- specify_cli/_agent_config.py +45 -0
- specify_cli/_assets.py +121 -0
- specify_cli/_console.py +245 -0
- specify_cli/_github_http.py +93 -0
- specify_cli/_init_options.py +36 -0
- specify_cli/_utils.py +282 -0
- specify_cli/_version.py +1429 -0
- specify_cli/agents.py +1082 -0
- specify_cli/authentication/__init__.py +50 -0
- specify_cli/authentication/azure_devops.py +117 -0
- specify_cli/authentication/base.py +57 -0
- specify_cli/authentication/config.py +209 -0
- specify_cli/authentication/github.py +24 -0
- specify_cli/authentication/http.py +149 -0
- specify_cli/catalogs.py +180 -0
- specify_cli/commands/__init__.py +7 -0
- specify_cli/commands/init.py +786 -0
- specify_cli/core_pack/commands/analyze.md +252 -0
- specify_cli/core_pack/commands/checklist.md +366 -0
- specify_cli/core_pack/commands/clarify.md +282 -0
- specify_cli/core_pack/commands/constitution.md +150 -0
- specify_cli/core_pack/commands/implement.md +216 -0
- specify_cli/core_pack/commands/plan.md +171 -0
- specify_cli/core_pack/commands/specify.md +342 -0
- specify_cli/core_pack/commands/tasks.md +216 -0
- specify_cli/core_pack/commands/taskstoissues.md +100 -0
- specify_cli/core_pack/extensions/agent-context/README.md +57 -0
- specify_cli/core_pack/extensions/agent-context/agent-context-config.yml +15 -0
- specify_cli/core_pack/extensions/agent-context/commands/speckit.agent-context.update.md +26 -0
- specify_cli/core_pack/extensions/agent-context/extension.yml +34 -0
- specify_cli/core_pack/extensions/agent-context/scripts/bash/update-agent-context.sh +200 -0
- specify_cli/core_pack/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +237 -0
- specify_cli/core_pack/extensions/git/README.md +100 -0
- specify_cli/core_pack/extensions/git/commands/speckit.git.commit.md +48 -0
- specify_cli/core_pack/extensions/git/commands/speckit.git.feature.md +67 -0
- specify_cli/core_pack/extensions/git/commands/speckit.git.initialize.md +49 -0
- specify_cli/core_pack/extensions/git/commands/speckit.git.remote.md +45 -0
- specify_cli/core_pack/extensions/git/commands/speckit.git.validate.md +49 -0
- specify_cli/core_pack/extensions/git/config-template.yml +62 -0
- specify_cli/core_pack/extensions/git/extension.yml +140 -0
- specify_cli/core_pack/extensions/git/git-config.yml +62 -0
- specify_cli/core_pack/extensions/git/scripts/bash/auto-commit.sh +140 -0
- specify_cli/core_pack/extensions/git/scripts/bash/create-new-feature.sh +453 -0
- specify_cli/core_pack/extensions/git/scripts/bash/git-common.sh +54 -0
- specify_cli/core_pack/extensions/git/scripts/bash/initialize-repo.sh +54 -0
- specify_cli/core_pack/extensions/git/scripts/powershell/auto-commit.ps1 +169 -0
- specify_cli/core_pack/extensions/git/scripts/powershell/create-new-feature.ps1 +403 -0
- specify_cli/core_pack/extensions/git/scripts/powershell/git-common.ps1 +51 -0
- specify_cli/core_pack/extensions/git/scripts/powershell/initialize-repo.ps1 +69 -0
- specify_cli/core_pack/presets/lean/README.md +45 -0
- specify_cli/core_pack/presets/lean/commands/speckit.constitution.md +15 -0
- specify_cli/core_pack/presets/lean/commands/speckit.implement.md +22 -0
- specify_cli/core_pack/presets/lean/commands/speckit.plan.md +19 -0
- specify_cli/core_pack/presets/lean/commands/speckit.specify.md +23 -0
- specify_cli/core_pack/presets/lean/commands/speckit.tasks.md +19 -0
- specify_cli/core_pack/presets/lean/preset.yml +51 -0
- specify_cli/core_pack/scripts/bash/check-prerequisites.sh +192 -0
- specify_cli/core_pack/scripts/bash/common.sh +721 -0
- specify_cli/core_pack/scripts/bash/create-new-feature.sh +413 -0
- specify_cli/core_pack/scripts/bash/setup-plan.sh +91 -0
- specify_cli/core_pack/scripts/bash/setup-tasks.sh +96 -0
- specify_cli/core_pack/scripts/powershell/check-prerequisites.ps1 +152 -0
- specify_cli/core_pack/scripts/powershell/common.ps1 +695 -0
- specify_cli/core_pack/scripts/powershell/create-new-feature.ps1 +385 -0
- specify_cli/core_pack/scripts/powershell/setup-plan.ps1 +73 -0
- specify_cli/core_pack/scripts/powershell/setup-tasks.ps1 +76 -0
- specify_cli/core_pack/templates/checklist-template.md +40 -0
- specify_cli/core_pack/templates/constitution-template.md +50 -0
- specify_cli/core_pack/templates/plan-template.md +113 -0
- specify_cli/core_pack/templates/spec-template.md +131 -0
- specify_cli/core_pack/templates/tasks-template.md +252 -0
- specify_cli/core_pack/templates/vscode-settings.json +14 -0
- specify_cli/core_pack/workflows/speckit/workflow.yml +77 -0
- specify_cli/extensions.py +3125 -0
- specify_cli/integration_runtime.py +90 -0
- specify_cli/integration_state.py +223 -0
- specify_cli/integrations/__init__.py +118 -0
- specify_cli/integrations/_commands.py +34 -0
- specify_cli/integrations/_helpers.py +402 -0
- specify_cli/integrations/_install_commands.py +309 -0
- specify_cli/integrations/_migrate_commands.py +490 -0
- specify_cli/integrations/_query_commands.py +464 -0
- specify_cli/integrations/agy/__init__.py +127 -0
- specify_cli/integrations/amp/__init__.py +21 -0
- specify_cli/integrations/auggie/__init__.py +22 -0
- specify_cli/integrations/base.py +1772 -0
- specify_cli/integrations/bob/__init__.py +21 -0
- specify_cli/integrations/catalog.py +815 -0
- specify_cli/integrations/claude/__init__.py +198 -0
- specify_cli/integrations/cline/__init__.py +162 -0
- specify_cli/integrations/codebuddy/__init__.py +22 -0
- specify_cli/integrations/codex/__init__.py +59 -0
- specify_cli/integrations/copilot/__init__.py +483 -0
- specify_cli/integrations/cursor_agent/__init__.py +95 -0
- specify_cli/integrations/devin/__init__.py +66 -0
- specify_cli/integrations/forge/__init__.py +209 -0
- specify_cli/integrations/gemini/__init__.py +22 -0
- specify_cli/integrations/generic/__init__.py +138 -0
- specify_cli/integrations/goose/__init__.py +21 -0
- specify_cli/integrations/hermes/__init__.py +280 -0
- specify_cli/integrations/iflow/__init__.py +22 -0
- specify_cli/integrations/junie/__init__.py +22 -0
- specify_cli/integrations/kilocode/__init__.py +22 -0
- specify_cli/integrations/kimi/__init__.py +125 -0
- specify_cli/integrations/kiro_cli/__init__.py +29 -0
- specify_cli/integrations/lingma/__init__.py +41 -0
- specify_cli/integrations/manifest.py +444 -0
- specify_cli/integrations/opencode/__init__.py +51 -0
- specify_cli/integrations/pi/__init__.py +21 -0
- specify_cli/integrations/qodercli/__init__.py +22 -0
- specify_cli/integrations/qwen/__init__.py +22 -0
- specify_cli/integrations/roo/__init__.py +22 -0
- specify_cli/integrations/shai/__init__.py +22 -0
- specify_cli/integrations/tabnine/__init__.py +22 -0
- specify_cli/integrations/trae/__init__.py +41 -0
- specify_cli/integrations/vibe/__init__.py +110 -0
- specify_cli/integrations/windsurf/__init__.py +22 -0
- specify_cli/presets.py +3125 -0
- specify_cli/shared_infra.py +523 -0
- specify_cli/workflows/__init__.py +68 -0
- specify_cli/workflows/base.py +132 -0
- specify_cli/workflows/catalog.py +540 -0
- specify_cli/workflows/engine.py +1016 -0
- specify_cli/workflows/expressions.py +309 -0
- specify_cli/workflows/steps/__init__.py +1 -0
- specify_cli/workflows/steps/command/__init__.py +155 -0
- specify_cli/workflows/steps/do_while/__init__.py +61 -0
- specify_cli/workflows/steps/fan_in/__init__.py +61 -0
- specify_cli/workflows/steps/fan_out/__init__.py +58 -0
- specify_cli/workflows/steps/gate/__init__.py +121 -0
- specify_cli/workflows/steps/if_then/__init__.py +55 -0
- specify_cli/workflows/steps/prompt/__init__.py +156 -0
- specify_cli/workflows/steps/shell/__init__.py +75 -0
- specify_cli/workflows/steps/switch/__init__.py +70 -0
- specify_cli/workflows/steps/while_loop/__init__.py +68 -0
- specify_cli-0.9.5.dev0.dist-info/METADATA +18 -0
- specify_cli-0.9.5.dev0.dist-info/RECORD +141 -0
- {specify_cli-0.0.22.dist-info → specify_cli-0.9.5.dev0.dist-info}/WHEEL +1 -1
- specify_cli-0.0.22.dist-info/METADATA +0 -12
- specify_cli-0.0.22.dist-info/RECORD +0 -6
- {specify_cli-0.0.22.dist-info → specify_cli-0.9.5.dev0.dist-info}/entry_points.txt +0 -0
- {specify_cli-0.0.22.dist-info → specify_cli-0.9.5.dev0.dist-info}/licenses/LICENSE +0 -0
specify_cli/__init__.py
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
# "rich",
|
|
7
7
|
# "platformdirs",
|
|
8
8
|
# "readchar",
|
|
9
|
-
# "
|
|
9
|
+
# "json5",
|
|
10
|
+
# "pyyaml",
|
|
11
|
+
# "packaging",
|
|
10
12
|
# ]
|
|
11
13
|
# ///
|
|
12
14
|
"""
|
|
@@ -24,402 +26,73 @@ Or install globally:
|
|
|
24
26
|
specify init --here
|
|
25
27
|
"""
|
|
26
28
|
|
|
29
|
+
import contextlib
|
|
27
30
|
import os
|
|
28
|
-
import subprocess
|
|
29
31
|
import sys
|
|
30
32
|
import zipfile
|
|
31
|
-
import tempfile
|
|
32
|
-
import shutil
|
|
33
|
-
import shlex
|
|
34
33
|
import json
|
|
34
|
+
import yaml
|
|
35
35
|
from pathlib import Path
|
|
36
|
-
|
|
36
|
+
|
|
37
|
+
from typing import Any, Optional
|
|
37
38
|
|
|
38
39
|
import typer
|
|
39
|
-
import httpx
|
|
40
|
-
from rich.console import Console
|
|
41
40
|
from rich.panel import Panel
|
|
42
|
-
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
43
|
-
from rich.text import Text
|
|
44
|
-
from rich.live import Live
|
|
45
41
|
from rich.align import Align
|
|
46
42
|
from rich.table import Table
|
|
47
|
-
from
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
import readchar
|
|
52
|
-
import ssl
|
|
53
|
-
import truststore
|
|
54
|
-
from datetime import datetime, timezone
|
|
55
|
-
|
|
56
|
-
ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
57
|
-
client = httpx.Client(verify=ssl_context)
|
|
58
|
-
|
|
59
|
-
def _github_token(cli_token: str | None = None) -> str | None:
|
|
60
|
-
"""Return sanitized GitHub token (cli arg takes precedence) or None."""
|
|
61
|
-
return ((cli_token or os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN") or "").strip()) or None
|
|
62
|
-
|
|
63
|
-
def _github_auth_headers(cli_token: str | None = None) -> dict:
|
|
64
|
-
"""Return Authorization header dict only when a non-empty token exists."""
|
|
65
|
-
token = _github_token(cli_token)
|
|
66
|
-
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
67
|
-
|
|
68
|
-
def _parse_rate_limit_headers(headers: httpx.Headers) -> dict:
|
|
69
|
-
"""Extract and parse GitHub rate-limit headers."""
|
|
70
|
-
info = {}
|
|
71
|
-
|
|
72
|
-
# Standard GitHub rate-limit headers
|
|
73
|
-
if "X-RateLimit-Limit" in headers:
|
|
74
|
-
info["limit"] = headers.get("X-RateLimit-Limit")
|
|
75
|
-
if "X-RateLimit-Remaining" in headers:
|
|
76
|
-
info["remaining"] = headers.get("X-RateLimit-Remaining")
|
|
77
|
-
if "X-RateLimit-Reset" in headers:
|
|
78
|
-
reset_epoch = int(headers.get("X-RateLimit-Reset", "0"))
|
|
79
|
-
if reset_epoch:
|
|
80
|
-
reset_time = datetime.fromtimestamp(reset_epoch, tz=timezone.utc)
|
|
81
|
-
info["reset_epoch"] = reset_epoch
|
|
82
|
-
info["reset_time"] = reset_time
|
|
83
|
-
info["reset_local"] = reset_time.astimezone()
|
|
84
|
-
|
|
85
|
-
# Retry-After header (seconds or HTTP-date)
|
|
86
|
-
if "Retry-After" in headers:
|
|
87
|
-
retry_after = headers.get("Retry-After")
|
|
88
|
-
try:
|
|
89
|
-
info["retry_after_seconds"] = int(retry_after)
|
|
90
|
-
except ValueError:
|
|
91
|
-
# HTTP-date format - not implemented, just store as string
|
|
92
|
-
info["retry_after"] = retry_after
|
|
93
|
-
|
|
94
|
-
return info
|
|
95
|
-
|
|
96
|
-
def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) -> str:
|
|
97
|
-
"""Format a user-friendly error message with rate-limit information."""
|
|
98
|
-
rate_info = _parse_rate_limit_headers(headers)
|
|
99
|
-
|
|
100
|
-
lines = [f"GitHub API returned status {status_code} for {url}"]
|
|
101
|
-
lines.append("")
|
|
102
|
-
|
|
103
|
-
if rate_info:
|
|
104
|
-
lines.append("[bold]Rate Limit Information:[/bold]")
|
|
105
|
-
if "limit" in rate_info:
|
|
106
|
-
lines.append(f" • Rate Limit: {rate_info['limit']} requests/hour")
|
|
107
|
-
if "remaining" in rate_info:
|
|
108
|
-
lines.append(f" • Remaining: {rate_info['remaining']}")
|
|
109
|
-
if "reset_local" in rate_info:
|
|
110
|
-
reset_str = rate_info["reset_local"].strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
111
|
-
lines.append(f" • Resets at: {reset_str}")
|
|
112
|
-
if "retry_after_seconds" in rate_info:
|
|
113
|
-
lines.append(f" • Retry after: {rate_info['retry_after_seconds']} seconds")
|
|
114
|
-
lines.append("")
|
|
115
|
-
|
|
116
|
-
# Add troubleshooting guidance
|
|
117
|
-
lines.append("[bold]Troubleshooting Tips:[/bold]")
|
|
118
|
-
lines.append(" • If you're on a shared CI or corporate environment, you may be rate-limited.")
|
|
119
|
-
lines.append(" • Consider using a GitHub token via --github-token or the GH_TOKEN/GITHUB_TOKEN")
|
|
120
|
-
lines.append(" environment variable to increase rate limits.")
|
|
121
|
-
lines.append(" • Authenticated requests have a limit of 5,000/hour vs 60/hour for unauthenticated.")
|
|
122
|
-
|
|
123
|
-
return "\n".join(lines)
|
|
124
|
-
|
|
125
|
-
# Agent configuration with name, folder, install URL, and CLI tool requirement
|
|
126
|
-
AGENT_CONFIG = {
|
|
127
|
-
"copilot": {
|
|
128
|
-
"name": "GitHub Copilot",
|
|
129
|
-
"folder": ".github/",
|
|
130
|
-
"install_url": None, # IDE-based, no CLI check needed
|
|
131
|
-
"requires_cli": False,
|
|
132
|
-
},
|
|
133
|
-
"claude": {
|
|
134
|
-
"name": "Claude Code",
|
|
135
|
-
"folder": ".claude/",
|
|
136
|
-
"install_url": "https://docs.anthropic.com/en/docs/claude-code/setup",
|
|
137
|
-
"requires_cli": True,
|
|
138
|
-
},
|
|
139
|
-
"gemini": {
|
|
140
|
-
"name": "Gemini CLI",
|
|
141
|
-
"folder": ".gemini/",
|
|
142
|
-
"install_url": "https://github.com/google-gemini/gemini-cli",
|
|
143
|
-
"requires_cli": True,
|
|
144
|
-
},
|
|
145
|
-
"cursor-agent": {
|
|
146
|
-
"name": "Cursor",
|
|
147
|
-
"folder": ".cursor/",
|
|
148
|
-
"install_url": None, # IDE-based
|
|
149
|
-
"requires_cli": False,
|
|
150
|
-
},
|
|
151
|
-
"qwen": {
|
|
152
|
-
"name": "Qwen Code",
|
|
153
|
-
"folder": ".qwen/",
|
|
154
|
-
"install_url": "https://github.com/QwenLM/qwen-code",
|
|
155
|
-
"requires_cli": True,
|
|
156
|
-
},
|
|
157
|
-
"opencode": {
|
|
158
|
-
"name": "opencode",
|
|
159
|
-
"folder": ".opencode/",
|
|
160
|
-
"install_url": "https://opencode.ai",
|
|
161
|
-
"requires_cli": True,
|
|
162
|
-
},
|
|
163
|
-
"codex": {
|
|
164
|
-
"name": "Codex CLI",
|
|
165
|
-
"folder": ".codex/",
|
|
166
|
-
"install_url": "https://github.com/openai/codex",
|
|
167
|
-
"requires_cli": True,
|
|
168
|
-
},
|
|
169
|
-
"windsurf": {
|
|
170
|
-
"name": "Windsurf",
|
|
171
|
-
"folder": ".windsurf/",
|
|
172
|
-
"install_url": None, # IDE-based
|
|
173
|
-
"requires_cli": False,
|
|
174
|
-
},
|
|
175
|
-
"kilocode": {
|
|
176
|
-
"name": "Kilo Code",
|
|
177
|
-
"folder": ".kilocode/",
|
|
178
|
-
"install_url": None, # IDE-based
|
|
179
|
-
"requires_cli": False,
|
|
180
|
-
},
|
|
181
|
-
"auggie": {
|
|
182
|
-
"name": "Auggie CLI",
|
|
183
|
-
"folder": ".augment/",
|
|
184
|
-
"install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli",
|
|
185
|
-
"requires_cli": True,
|
|
186
|
-
},
|
|
187
|
-
"codebuddy": {
|
|
188
|
-
"name": "CodeBuddy",
|
|
189
|
-
"folder": ".codebuddy/",
|
|
190
|
-
"install_url": "https://www.codebuddy.ai/cli",
|
|
191
|
-
"requires_cli": True,
|
|
192
|
-
},
|
|
193
|
-
"roo": {
|
|
194
|
-
"name": "Roo Code",
|
|
195
|
-
"folder": ".roo/",
|
|
196
|
-
"install_url": None, # IDE-based
|
|
197
|
-
"requires_cli": False,
|
|
198
|
-
},
|
|
199
|
-
"q": {
|
|
200
|
-
"name": "Amazon Q Developer CLI",
|
|
201
|
-
"folder": ".amazonq/",
|
|
202
|
-
"install_url": "https://aws.amazon.com/developer/learning/q-developer-cli/",
|
|
203
|
-
"requires_cli": True,
|
|
204
|
-
},
|
|
205
|
-
"amp": {
|
|
206
|
-
"name": "Amp",
|
|
207
|
-
"folder": ".agents/",
|
|
208
|
-
"install_url": "https://ampcode.com/manual#install",
|
|
209
|
-
"requires_cli": True,
|
|
210
|
-
},
|
|
211
|
-
"shai": {
|
|
212
|
-
"name": "SHAI",
|
|
213
|
-
"folder": ".shai/",
|
|
214
|
-
"install_url": "https://github.com/ovh/shai",
|
|
215
|
-
"requires_cli": True,
|
|
216
|
-
},
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
|
220
|
-
|
|
221
|
-
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
|
|
222
|
-
|
|
223
|
-
BANNER = """
|
|
224
|
-
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
|
|
225
|
-
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
|
|
226
|
-
███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝
|
|
227
|
-
╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝
|
|
228
|
-
███████║██║ ███████╗╚██████╗██║██║ ██║
|
|
229
|
-
╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝
|
|
230
|
-
"""
|
|
231
|
-
|
|
232
|
-
TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit"
|
|
233
|
-
class StepTracker:
|
|
234
|
-
"""Track and render hierarchical steps without emojis, similar to Claude Code tree output.
|
|
235
|
-
Supports live auto-refresh via an attached refresh callback.
|
|
236
|
-
"""
|
|
237
|
-
def __init__(self, title: str):
|
|
238
|
-
self.title = title
|
|
239
|
-
self.steps = [] # list of dicts: {key, label, status, detail}
|
|
240
|
-
self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4}
|
|
241
|
-
self._refresh_cb = None # callable to trigger UI refresh
|
|
242
|
-
|
|
243
|
-
def attach_refresh(self, cb):
|
|
244
|
-
self._refresh_cb = cb
|
|
245
|
-
|
|
246
|
-
def add(self, key: str, label: str):
|
|
247
|
-
if key not in [s["key"] for s in self.steps]:
|
|
248
|
-
self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""})
|
|
249
|
-
self._maybe_refresh()
|
|
250
|
-
|
|
251
|
-
def start(self, key: str, detail: str = ""):
|
|
252
|
-
self._update(key, status="running", detail=detail)
|
|
253
|
-
|
|
254
|
-
def complete(self, key: str, detail: str = ""):
|
|
255
|
-
self._update(key, status="done", detail=detail)
|
|
256
|
-
|
|
257
|
-
def error(self, key: str, detail: str = ""):
|
|
258
|
-
self._update(key, status="error", detail=detail)
|
|
259
|
-
|
|
260
|
-
def skip(self, key: str, detail: str = ""):
|
|
261
|
-
self._update(key, status="skipped", detail=detail)
|
|
262
|
-
|
|
263
|
-
def _update(self, key: str, status: str, detail: str):
|
|
264
|
-
for s in self.steps:
|
|
265
|
-
if s["key"] == key:
|
|
266
|
-
s["status"] = status
|
|
267
|
-
if detail:
|
|
268
|
-
s["detail"] = detail
|
|
269
|
-
self._maybe_refresh()
|
|
270
|
-
return
|
|
271
|
-
|
|
272
|
-
self.steps.append({"key": key, "label": key, "status": status, "detail": detail})
|
|
273
|
-
self._maybe_refresh()
|
|
274
|
-
|
|
275
|
-
def _maybe_refresh(self):
|
|
276
|
-
if self._refresh_cb:
|
|
277
|
-
try:
|
|
278
|
-
self._refresh_cb()
|
|
279
|
-
except Exception:
|
|
280
|
-
pass
|
|
281
|
-
|
|
282
|
-
def render(self):
|
|
283
|
-
tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50")
|
|
284
|
-
for step in self.steps:
|
|
285
|
-
label = step["label"]
|
|
286
|
-
detail_text = step["detail"].strip() if step["detail"] else ""
|
|
287
|
-
|
|
288
|
-
status = step["status"]
|
|
289
|
-
if status == "done":
|
|
290
|
-
symbol = "[green]●[/green]"
|
|
291
|
-
elif status == "pending":
|
|
292
|
-
symbol = "[green dim]○[/green dim]"
|
|
293
|
-
elif status == "running":
|
|
294
|
-
symbol = "[cyan]○[/cyan]"
|
|
295
|
-
elif status == "error":
|
|
296
|
-
symbol = "[red]●[/red]"
|
|
297
|
-
elif status == "skipped":
|
|
298
|
-
symbol = "[yellow]○[/yellow]"
|
|
299
|
-
else:
|
|
300
|
-
symbol = " "
|
|
301
|
-
|
|
302
|
-
if status == "pending":
|
|
303
|
-
# Entire line light gray (pending)
|
|
304
|
-
if detail_text:
|
|
305
|
-
line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]"
|
|
306
|
-
else:
|
|
307
|
-
line = f"{symbol} [bright_black]{label}[/bright_black]"
|
|
308
|
-
else:
|
|
309
|
-
# Label white, detail (if any) light gray in parentheses
|
|
310
|
-
if detail_text:
|
|
311
|
-
line = f"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]"
|
|
312
|
-
else:
|
|
313
|
-
line = f"{symbol} [white]{label}[/white]"
|
|
314
|
-
|
|
315
|
-
tree.add(line)
|
|
316
|
-
return tree
|
|
317
|
-
|
|
318
|
-
def get_key():
|
|
319
|
-
"""Get a single keypress in a cross-platform way using readchar."""
|
|
320
|
-
key = readchar.readkey()
|
|
321
|
-
|
|
322
|
-
if key == readchar.key.UP or key == readchar.key.CTRL_P:
|
|
323
|
-
return 'up'
|
|
324
|
-
if key == readchar.key.DOWN or key == readchar.key.CTRL_N:
|
|
325
|
-
return 'down'
|
|
326
|
-
|
|
327
|
-
if key == readchar.key.ENTER:
|
|
328
|
-
return 'enter'
|
|
329
|
-
|
|
330
|
-
if key == readchar.key.ESC:
|
|
331
|
-
return 'escape'
|
|
332
|
-
|
|
333
|
-
if key == readchar.key.CTRL_C:
|
|
334
|
-
raise KeyboardInterrupt
|
|
335
|
-
|
|
336
|
-
return key
|
|
337
|
-
|
|
338
|
-
def select_with_arrows(options: dict, prompt_text: str = "Select an option", default_key: str = None) -> str:
|
|
339
|
-
"""
|
|
340
|
-
Interactive selection using arrow keys with Rich Live display.
|
|
341
|
-
|
|
342
|
-
Args:
|
|
343
|
-
options: Dict with keys as option keys and values as descriptions
|
|
344
|
-
prompt_text: Text to show above the options
|
|
345
|
-
default_key: Default option key to start with
|
|
346
|
-
|
|
347
|
-
Returns:
|
|
348
|
-
Selected option key
|
|
349
|
-
"""
|
|
350
|
-
option_keys = list(options.keys())
|
|
351
|
-
if default_key and default_key in option_keys:
|
|
352
|
-
selected_index = option_keys.index(default_key)
|
|
353
|
-
else:
|
|
354
|
-
selected_index = 0
|
|
355
|
-
|
|
356
|
-
selected_key = None
|
|
357
|
-
|
|
358
|
-
def create_selection_panel():
|
|
359
|
-
"""Create the selection panel with current selection highlighted."""
|
|
360
|
-
table = Table.grid(padding=(0, 2))
|
|
361
|
-
table.add_column(style="cyan", justify="left", width=3)
|
|
362
|
-
table.add_column(style="white", justify="left")
|
|
363
|
-
|
|
364
|
-
for i, key in enumerate(option_keys):
|
|
365
|
-
if i == selected_index:
|
|
366
|
-
table.add_row("▶", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
|
|
367
|
-
else:
|
|
368
|
-
table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
|
|
369
|
-
|
|
370
|
-
table.add_row("", "")
|
|
371
|
-
table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]")
|
|
372
|
-
|
|
373
|
-
return Panel(
|
|
374
|
-
table,
|
|
375
|
-
title=f"[bold]{prompt_text}[/bold]",
|
|
376
|
-
border_style="cyan",
|
|
377
|
-
padding=(1, 2)
|
|
378
|
-
)
|
|
379
|
-
|
|
380
|
-
console.print()
|
|
381
|
-
|
|
382
|
-
def run_selection_loop():
|
|
383
|
-
nonlocal selected_key, selected_index
|
|
384
|
-
with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live:
|
|
385
|
-
while True:
|
|
386
|
-
try:
|
|
387
|
-
key = get_key()
|
|
388
|
-
if key == 'up':
|
|
389
|
-
selected_index = (selected_index - 1) % len(option_keys)
|
|
390
|
-
elif key == 'down':
|
|
391
|
-
selected_index = (selected_index + 1) % len(option_keys)
|
|
392
|
-
elif key == 'enter':
|
|
393
|
-
selected_key = option_keys[selected_index]
|
|
394
|
-
break
|
|
395
|
-
elif key == 'escape':
|
|
396
|
-
console.print("\n[yellow]Selection cancelled[/yellow]")
|
|
397
|
-
raise typer.Exit(1)
|
|
398
|
-
|
|
399
|
-
live.update(create_selection_panel(), refresh=True)
|
|
400
|
-
|
|
401
|
-
except KeyboardInterrupt:
|
|
402
|
-
console.print("\n[yellow]Selection cancelled[/yellow]")
|
|
403
|
-
raise typer.Exit(1)
|
|
404
|
-
|
|
405
|
-
run_selection_loop()
|
|
406
|
-
|
|
407
|
-
if selected_key is None:
|
|
408
|
-
console.print("\n[red]Selection failed.[/red]")
|
|
409
|
-
raise typer.Exit(1)
|
|
410
|
-
|
|
411
|
-
return selected_key
|
|
412
|
-
|
|
413
|
-
console = Console()
|
|
414
|
-
|
|
415
|
-
class BannerGroup(TyperGroup):
|
|
416
|
-
"""Custom group that shows banner before help."""
|
|
417
|
-
|
|
418
|
-
def format_help(self, ctx, formatter):
|
|
419
|
-
# Show banner before help
|
|
420
|
-
show_banner()
|
|
421
|
-
super().format_help(ctx, formatter)
|
|
43
|
+
from .shared_infra import (
|
|
44
|
+
install_shared_infra as _install_shared_infra_impl,
|
|
45
|
+
refresh_shared_templates as _refresh_shared_templates_impl,
|
|
46
|
+
)
|
|
422
47
|
|
|
48
|
+
from ._console import (
|
|
49
|
+
BANNER as BANNER,
|
|
50
|
+
TAGLINE as TAGLINE,
|
|
51
|
+
BannerGroup,
|
|
52
|
+
StepTracker,
|
|
53
|
+
console,
|
|
54
|
+
get_key as get_key,
|
|
55
|
+
select_with_arrows as select_with_arrows,
|
|
56
|
+
show_banner,
|
|
57
|
+
)
|
|
58
|
+
from ._assets import (
|
|
59
|
+
_locate_bundled_extension,
|
|
60
|
+
_locate_bundled_preset,
|
|
61
|
+
_locate_bundled_workflow as _locate_bundled_workflow,
|
|
62
|
+
_locate_core_pack,
|
|
63
|
+
_repo_root,
|
|
64
|
+
get_speckit_version as get_speckit_version,
|
|
65
|
+
)
|
|
66
|
+
from ._utils import (
|
|
67
|
+
CLAUDE_LOCAL_PATH as CLAUDE_LOCAL_PATH,
|
|
68
|
+
CLAUDE_NPM_LOCAL_PATH as CLAUDE_NPM_LOCAL_PATH,
|
|
69
|
+
_display_project_path,
|
|
70
|
+
check_tool as check_tool,
|
|
71
|
+
handle_vscode_settings as handle_vscode_settings,
|
|
72
|
+
init_git_repo as init_git_repo,
|
|
73
|
+
is_git_repo as is_git_repo,
|
|
74
|
+
merge_json_files as merge_json_files,
|
|
75
|
+
run_command as run_command,
|
|
76
|
+
)
|
|
77
|
+
from ._version import (
|
|
78
|
+
GITHUB_API_LATEST as GITHUB_API_LATEST,
|
|
79
|
+
self_app as _self_app,
|
|
80
|
+
self_check as self_check,
|
|
81
|
+
self_upgrade as self_upgrade,
|
|
82
|
+
)
|
|
83
|
+
from ._agent_config import (
|
|
84
|
+
AGENT_CONFIG as AGENT_CONFIG,
|
|
85
|
+
AI_ASSISTANT_ALIASES as AI_ASSISTANT_ALIASES,
|
|
86
|
+
AI_ASSISTANT_HELP as AI_ASSISTANT_HELP,
|
|
87
|
+
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
|
|
88
|
+
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
|
|
89
|
+
)
|
|
90
|
+
from ._init_options import (
|
|
91
|
+
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
|
|
92
|
+
is_ai_skills_enabled as _is_ai_skills_enabled,
|
|
93
|
+
load_init_options as load_init_options,
|
|
94
|
+
save_init_options as save_init_options,
|
|
95
|
+
)
|
|
423
96
|
|
|
424
97
|
app = typer.Typer(
|
|
425
98
|
name="specify",
|
|
@@ -429,495 +102,158 @@ app = typer.Typer(
|
|
|
429
102
|
cls=BannerGroup,
|
|
430
103
|
)
|
|
431
104
|
|
|
432
|
-
def
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
styled_banner = Text()
|
|
438
|
-
for i, line in enumerate(banner_lines):
|
|
439
|
-
color = colors[i % len(colors)]
|
|
440
|
-
styled_banner.append(line + "\n", style=color)
|
|
441
|
-
|
|
442
|
-
console.print(Align.center(styled_banner))
|
|
443
|
-
console.print(Align.center(Text(TAGLINE, style="italic bright_yellow")))
|
|
444
|
-
console.print()
|
|
105
|
+
def _version_callback(value: bool):
|
|
106
|
+
if value:
|
|
107
|
+
console.print(f"specify {get_speckit_version()}")
|
|
108
|
+
raise typer.Exit()
|
|
445
109
|
|
|
446
110
|
@app.callback()
|
|
447
|
-
def callback(
|
|
111
|
+
def callback(
|
|
112
|
+
ctx: typer.Context,
|
|
113
|
+
version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
114
|
+
):
|
|
448
115
|
"""Show banner when no subcommand is provided."""
|
|
449
116
|
if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv:
|
|
450
117
|
show_banner()
|
|
451
118
|
console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]"))
|
|
452
119
|
console.print()
|
|
453
120
|
|
|
454
|
-
def
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
return None
|
|
471
|
-
|
|
472
|
-
def check_tool(tool: str, tracker: StepTracker = None) -> bool:
|
|
473
|
-
"""Check if a tool is installed. Optionally update tracker.
|
|
474
|
-
|
|
475
|
-
Args:
|
|
476
|
-
tool: Name of the tool to check
|
|
477
|
-
tracker: Optional StepTracker to update with results
|
|
478
|
-
|
|
479
|
-
Returns:
|
|
480
|
-
True if tool is found, False otherwise
|
|
481
|
-
"""
|
|
482
|
-
# Special handling for Claude CLI after `claude migrate-installer`
|
|
483
|
-
# See: https://github.com/github/spec-kit/issues/123
|
|
484
|
-
# The migrate-installer command REMOVES the original executable from PATH
|
|
485
|
-
# and creates an alias at ~/.claude/local/claude instead
|
|
486
|
-
# This path should be prioritized over other claude executables in PATH
|
|
487
|
-
if tool == "claude":
|
|
488
|
-
if CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file():
|
|
489
|
-
if tracker:
|
|
490
|
-
tracker.complete(tool, "available")
|
|
491
|
-
return True
|
|
492
|
-
|
|
493
|
-
found = shutil.which(tool) is not None
|
|
494
|
-
|
|
495
|
-
if tracker:
|
|
496
|
-
if found:
|
|
497
|
-
tracker.complete(tool, "available")
|
|
498
|
-
else:
|
|
499
|
-
tracker.error(tool, "not found")
|
|
500
|
-
|
|
501
|
-
return found
|
|
502
|
-
|
|
503
|
-
def is_git_repo(path: Path = None) -> bool:
|
|
504
|
-
"""Check if the specified path is inside a git repository."""
|
|
505
|
-
if path is None:
|
|
506
|
-
path = Path.cwd()
|
|
507
|
-
|
|
508
|
-
if not path.is_dir():
|
|
509
|
-
return False
|
|
510
|
-
|
|
511
|
-
try:
|
|
512
|
-
# Use git command to check if inside a work tree
|
|
513
|
-
subprocess.run(
|
|
514
|
-
["git", "rev-parse", "--is-inside-work-tree"],
|
|
515
|
-
check=True,
|
|
516
|
-
capture_output=True,
|
|
517
|
-
cwd=path,
|
|
518
|
-
)
|
|
519
|
-
return True
|
|
520
|
-
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
521
|
-
return False
|
|
522
|
-
|
|
523
|
-
def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]:
|
|
524
|
-
"""Initialize a git repository in the specified path.
|
|
525
|
-
|
|
526
|
-
Args:
|
|
527
|
-
project_path: Path to initialize git repository in
|
|
528
|
-
quiet: if True suppress console output (tracker handles status)
|
|
529
|
-
|
|
530
|
-
Returns:
|
|
531
|
-
Tuple of (success: bool, error_message: Optional[str])
|
|
532
|
-
"""
|
|
533
|
-
try:
|
|
534
|
-
original_cwd = Path.cwd()
|
|
535
|
-
os.chdir(project_path)
|
|
536
|
-
if not quiet:
|
|
537
|
-
console.print("[cyan]Initializing git repository...[/cyan]")
|
|
538
|
-
subprocess.run(["git", "init"], check=True, capture_output=True, text=True)
|
|
539
|
-
subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True)
|
|
540
|
-
subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True)
|
|
541
|
-
if not quiet:
|
|
542
|
-
console.print("[green]✓[/green] Git repository initialized")
|
|
543
|
-
return True, None
|
|
544
|
-
|
|
545
|
-
except subprocess.CalledProcessError as e:
|
|
546
|
-
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
|
|
547
|
-
if e.stderr:
|
|
548
|
-
error_msg += f"\nError: {e.stderr.strip()}"
|
|
549
|
-
elif e.stdout:
|
|
550
|
-
error_msg += f"\nOutput: {e.stdout.strip()}"
|
|
551
|
-
|
|
552
|
-
if not quiet:
|
|
553
|
-
console.print(f"[red]Error initializing git repository:[/red] {e}")
|
|
554
|
-
return False, error_msg
|
|
555
|
-
finally:
|
|
556
|
-
os.chdir(original_cwd)
|
|
557
|
-
|
|
558
|
-
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
|
559
|
-
"""Handle merging or copying of .vscode/settings.json files."""
|
|
560
|
-
def log(message, color="green"):
|
|
561
|
-
if verbose and not tracker:
|
|
562
|
-
console.print(f"[{color}]{message}[/] {rel_path}")
|
|
563
|
-
|
|
564
|
-
try:
|
|
565
|
-
with open(sub_item, 'r', encoding='utf-8') as f:
|
|
566
|
-
new_settings = json.load(f)
|
|
567
|
-
|
|
568
|
-
if dest_file.exists():
|
|
569
|
-
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
|
|
570
|
-
with open(dest_file, 'w', encoding='utf-8') as f:
|
|
571
|
-
json.dump(merged, f, indent=4)
|
|
572
|
-
f.write('\n')
|
|
573
|
-
log("Merged:", "green")
|
|
574
|
-
else:
|
|
575
|
-
shutil.copy2(sub_item, dest_file)
|
|
576
|
-
log("Copied (no existing settings.json):", "blue")
|
|
577
|
-
|
|
578
|
-
except Exception as e:
|
|
579
|
-
log(f"Warning: Could not merge, copying instead: {e}", "yellow")
|
|
580
|
-
shutil.copy2(sub_item, dest_file)
|
|
581
|
-
|
|
582
|
-
def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict:
|
|
583
|
-
"""Merge new JSON content into existing JSON file.
|
|
584
|
-
|
|
585
|
-
Performs a deep merge where:
|
|
586
|
-
- New keys are added
|
|
587
|
-
- Existing keys are preserved unless overwritten by new content
|
|
588
|
-
- Nested dictionaries are merged recursively
|
|
589
|
-
- Lists and other values are replaced (not merged)
|
|
121
|
+
def _refresh_shared_templates(
|
|
122
|
+
project_path: Path,
|
|
123
|
+
*,
|
|
124
|
+
invoke_separator: str,
|
|
125
|
+
force: bool = False,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Refresh default-sensitive shared templates without touching scripts."""
|
|
128
|
+
_refresh_shared_templates_impl(
|
|
129
|
+
project_path,
|
|
130
|
+
version=get_speckit_version(),
|
|
131
|
+
core_pack=_locate_core_pack(),
|
|
132
|
+
repo_root=_repo_root(),
|
|
133
|
+
console=console,
|
|
134
|
+
invoke_separator=invoke_separator,
|
|
135
|
+
force=force,
|
|
136
|
+
)
|
|
590
137
|
|
|
591
|
-
Args:
|
|
592
|
-
existing_path: Path to existing JSON file
|
|
593
|
-
new_content: New JSON content to merge in
|
|
594
|
-
verbose: Whether to print merge details
|
|
595
138
|
|
|
596
|
-
|
|
597
|
-
|
|
139
|
+
def _install_shared_infra(
|
|
140
|
+
project_path: Path,
|
|
141
|
+
script_type: str,
|
|
142
|
+
tracker: StepTracker | None = None,
|
|
143
|
+
force: bool = False,
|
|
144
|
+
invoke_separator: str = ".",
|
|
145
|
+
refresh_managed: bool = False,
|
|
146
|
+
refresh_hint: str | None = None,
|
|
147
|
+
) -> bool:
|
|
148
|
+
"""Install shared infrastructure files into *project_path*.
|
|
149
|
+
|
|
150
|
+
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
|
151
|
+
the bundled core_pack or source checkout, where ``<variant>`` is
|
|
152
|
+
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
|
|
153
|
+
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
|
|
154
|
+
|
|
155
|
+
Shared scripts and page templates are processed to resolve
|
|
156
|
+
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
|
157
|
+
(``"."`` for markdown agents, ``"-"`` for skills agents).
|
|
158
|
+
|
|
159
|
+
Overwrite policy:
|
|
160
|
+
|
|
161
|
+
* ``force=True`` — overwrite every existing file (still skips symlinks
|
|
162
|
+
to avoid following links outside the project root).
|
|
163
|
+
* ``refresh_managed=True`` — overwrite only files whose on-disk hash
|
|
164
|
+
still matches the previously recorded manifest hash (i.e. unmodified
|
|
165
|
+
files installed by spec-kit). Files with diverging hashes are
|
|
166
|
+
treated as user customizations and preserved with a warning.
|
|
167
|
+
* Default — only add missing files; existing ones are skipped.
|
|
168
|
+
|
|
169
|
+
*refresh_hint* — caller-supplied rich-text fragment shown after the
|
|
170
|
+
"Preserved customized files" warning to tell the user which flag/command
|
|
171
|
+
they should re-run with to overwrite their customizations. Each caller
|
|
172
|
+
passes the flag that's actually valid in its CLI surface (e.g.
|
|
173
|
+
``--refresh-shared-infra`` for ``integration switch``,
|
|
174
|
+
``--force`` for ``init``/``integration upgrade``). When ``None``, no
|
|
175
|
+
remediation hint is printed for customizations.
|
|
176
|
+
|
|
177
|
+
Returns ``True`` on success.
|
|
598
178
|
"""
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
# Recursively merge nested dictionaries
|
|
612
|
-
result[key] = deep_merge(result[key], value)
|
|
613
|
-
else:
|
|
614
|
-
# Add new key or replace existing value
|
|
615
|
-
result[key] = value
|
|
616
|
-
return result
|
|
617
|
-
|
|
618
|
-
merged = deep_merge(existing_content, new_content)
|
|
619
|
-
|
|
620
|
-
if verbose:
|
|
621
|
-
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
|
|
622
|
-
|
|
623
|
-
return merged
|
|
624
|
-
|
|
625
|
-
def download_template_from_github(ai_assistant: str, download_dir: Path, *, script_type: str = "sh", verbose: bool = True, show_progress: bool = True, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Tuple[Path, dict]:
|
|
626
|
-
repo_owner = "github"
|
|
627
|
-
repo_name = "spec-kit"
|
|
628
|
-
if client is None:
|
|
629
|
-
client = httpx.Client(verify=ssl_context)
|
|
630
|
-
|
|
631
|
-
if verbose:
|
|
632
|
-
console.print("[cyan]Fetching latest release information...[/cyan]")
|
|
633
|
-
api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
|
634
|
-
|
|
635
|
-
try:
|
|
636
|
-
response = client.get(
|
|
637
|
-
api_url,
|
|
638
|
-
timeout=30,
|
|
639
|
-
follow_redirects=True,
|
|
640
|
-
headers=_github_auth_headers(github_token),
|
|
641
|
-
)
|
|
642
|
-
status = response.status_code
|
|
643
|
-
if status != 200:
|
|
644
|
-
# Format detailed error message with rate-limit info
|
|
645
|
-
error_msg = _format_rate_limit_error(status, response.headers, api_url)
|
|
646
|
-
if debug:
|
|
647
|
-
error_msg += f"\n\n[dim]Response body (truncated 500):[/dim]\n{response.text[:500]}"
|
|
648
|
-
raise RuntimeError(error_msg)
|
|
649
|
-
try:
|
|
650
|
-
release_data = response.json()
|
|
651
|
-
except ValueError as je:
|
|
652
|
-
raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}")
|
|
653
|
-
except Exception as e:
|
|
654
|
-
console.print(f"[red]Error fetching release information[/red]")
|
|
655
|
-
console.print(Panel(str(e), title="Fetch Error", border_style="red"))
|
|
656
|
-
raise typer.Exit(1)
|
|
657
|
-
|
|
658
|
-
assets = release_data.get("assets", [])
|
|
659
|
-
pattern = f"spec-kit-template-{ai_assistant}-{script_type}"
|
|
660
|
-
matching_assets = [
|
|
661
|
-
asset for asset in assets
|
|
662
|
-
if pattern in asset["name"] and asset["name"].endswith(".zip")
|
|
663
|
-
]
|
|
664
|
-
|
|
665
|
-
asset = matching_assets[0] if matching_assets else None
|
|
666
|
-
|
|
667
|
-
if asset is None:
|
|
668
|
-
console.print(f"[red]No matching release asset found[/red] for [bold]{ai_assistant}[/bold] (expected pattern: [bold]{pattern}[/bold])")
|
|
669
|
-
asset_names = [a.get('name', '?') for a in assets]
|
|
670
|
-
console.print(Panel("\n".join(asset_names) or "(no assets)", title="Available Assets", border_style="yellow"))
|
|
671
|
-
raise typer.Exit(1)
|
|
672
|
-
|
|
673
|
-
download_url = asset["browser_download_url"]
|
|
674
|
-
filename = asset["name"]
|
|
675
|
-
file_size = asset["size"]
|
|
676
|
-
|
|
677
|
-
if verbose:
|
|
678
|
-
console.print(f"[cyan]Found template:[/cyan] {filename}")
|
|
679
|
-
console.print(f"[cyan]Size:[/cyan] {file_size:,} bytes")
|
|
680
|
-
console.print(f"[cyan]Release:[/cyan] {release_data['tag_name']}")
|
|
681
|
-
|
|
682
|
-
zip_path = download_dir / filename
|
|
683
|
-
if verbose:
|
|
684
|
-
console.print(f"[cyan]Downloading template...[/cyan]")
|
|
685
|
-
|
|
686
|
-
try:
|
|
687
|
-
with client.stream(
|
|
688
|
-
"GET",
|
|
689
|
-
download_url,
|
|
690
|
-
timeout=60,
|
|
691
|
-
follow_redirects=True,
|
|
692
|
-
headers=_github_auth_headers(github_token),
|
|
693
|
-
) as response:
|
|
694
|
-
if response.status_code != 200:
|
|
695
|
-
# Handle rate-limiting on download as well
|
|
696
|
-
error_msg = _format_rate_limit_error(response.status_code, response.headers, download_url)
|
|
697
|
-
if debug:
|
|
698
|
-
error_msg += f"\n\n[dim]Response body (truncated 400):[/dim]\n{response.text[:400]}"
|
|
699
|
-
raise RuntimeError(error_msg)
|
|
700
|
-
total_size = int(response.headers.get('content-length', 0))
|
|
701
|
-
with open(zip_path, 'wb') as f:
|
|
702
|
-
if total_size == 0:
|
|
703
|
-
for chunk in response.iter_bytes(chunk_size=8192):
|
|
704
|
-
f.write(chunk)
|
|
705
|
-
else:
|
|
706
|
-
if show_progress:
|
|
707
|
-
with Progress(
|
|
708
|
-
SpinnerColumn(),
|
|
709
|
-
TextColumn("[progress.description]{task.description}"),
|
|
710
|
-
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
711
|
-
console=console,
|
|
712
|
-
) as progress:
|
|
713
|
-
task = progress.add_task("Downloading...", total=total_size)
|
|
714
|
-
downloaded = 0
|
|
715
|
-
for chunk in response.iter_bytes(chunk_size=8192):
|
|
716
|
-
f.write(chunk)
|
|
717
|
-
downloaded += len(chunk)
|
|
718
|
-
progress.update(task, completed=downloaded)
|
|
719
|
-
else:
|
|
720
|
-
for chunk in response.iter_bytes(chunk_size=8192):
|
|
721
|
-
f.write(chunk)
|
|
722
|
-
except Exception as e:
|
|
723
|
-
console.print(f"[red]Error downloading template[/red]")
|
|
724
|
-
detail = str(e)
|
|
725
|
-
if zip_path.exists():
|
|
726
|
-
zip_path.unlink()
|
|
727
|
-
console.print(Panel(detail, title="Download Error", border_style="red"))
|
|
728
|
-
raise typer.Exit(1)
|
|
729
|
-
if verbose:
|
|
730
|
-
console.print(f"Downloaded: {filename}")
|
|
731
|
-
metadata = {
|
|
732
|
-
"filename": filename,
|
|
733
|
-
"size": file_size,
|
|
734
|
-
"release": release_data["tag_name"],
|
|
735
|
-
"asset_url": download_url
|
|
736
|
-
}
|
|
737
|
-
return zip_path, metadata
|
|
179
|
+
return _install_shared_infra_impl(
|
|
180
|
+
project_path,
|
|
181
|
+
script_type,
|
|
182
|
+
version=get_speckit_version(),
|
|
183
|
+
core_pack=_locate_core_pack(),
|
|
184
|
+
repo_root=_repo_root(),
|
|
185
|
+
console=console,
|
|
186
|
+
force=force,
|
|
187
|
+
invoke_separator=invoke_separator,
|
|
188
|
+
refresh_managed=refresh_managed,
|
|
189
|
+
refresh_hint=refresh_hint,
|
|
190
|
+
)
|
|
738
191
|
|
|
739
|
-
def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path:
|
|
740
|
-
"""Download the latest release and extract it to create a new project.
|
|
741
|
-
Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup)
|
|
742
|
-
"""
|
|
743
|
-
current_dir = Path.cwd()
|
|
744
192
|
|
|
745
|
-
|
|
746
|
-
|
|
193
|
+
def _install_shared_infra_or_exit(
|
|
194
|
+
project_path: Path,
|
|
195
|
+
script_type: str,
|
|
196
|
+
tracker: StepTracker | None = None,
|
|
197
|
+
force: bool = False,
|
|
198
|
+
invoke_separator: str = ".",
|
|
199
|
+
refresh_managed: bool = False,
|
|
200
|
+
refresh_hint: str | None = None,
|
|
201
|
+
) -> bool:
|
|
747
202
|
try:
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
github_token=github_token
|
|
203
|
+
return _install_shared_infra(
|
|
204
|
+
project_path,
|
|
205
|
+
script_type,
|
|
206
|
+
tracker=tracker,
|
|
207
|
+
force=force,
|
|
208
|
+
invoke_separator=invoke_separator,
|
|
209
|
+
refresh_managed=refresh_managed,
|
|
210
|
+
refresh_hint=refresh_hint,
|
|
757
211
|
)
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
tracker.add("download", "Download template")
|
|
761
|
-
tracker.complete("download", meta['filename'])
|
|
762
|
-
except Exception as e:
|
|
763
|
-
if tracker:
|
|
764
|
-
tracker.error("fetch", str(e))
|
|
765
|
-
else:
|
|
766
|
-
if verbose:
|
|
767
|
-
console.print(f"[red]Error downloading template:[/red] {e}")
|
|
768
|
-
raise
|
|
769
|
-
|
|
770
|
-
if tracker:
|
|
771
|
-
tracker.add("extract", "Extract template")
|
|
772
|
-
tracker.start("extract")
|
|
773
|
-
elif verbose:
|
|
774
|
-
console.print("Extracting template...")
|
|
775
|
-
|
|
776
|
-
try:
|
|
777
|
-
if not is_current_dir:
|
|
778
|
-
project_path.mkdir(parents=True)
|
|
779
|
-
|
|
780
|
-
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
781
|
-
zip_contents = zip_ref.namelist()
|
|
782
|
-
if tracker:
|
|
783
|
-
tracker.start("zip-list")
|
|
784
|
-
tracker.complete("zip-list", f"{len(zip_contents)} entries")
|
|
785
|
-
elif verbose:
|
|
786
|
-
console.print(f"[cyan]ZIP contains {len(zip_contents)} items[/cyan]")
|
|
787
|
-
|
|
788
|
-
if is_current_dir:
|
|
789
|
-
with tempfile.TemporaryDirectory() as temp_dir:
|
|
790
|
-
temp_path = Path(temp_dir)
|
|
791
|
-
zip_ref.extractall(temp_path)
|
|
792
|
-
|
|
793
|
-
extracted_items = list(temp_path.iterdir())
|
|
794
|
-
if tracker:
|
|
795
|
-
tracker.start("extracted-summary")
|
|
796
|
-
tracker.complete("extracted-summary", f"temp {len(extracted_items)} items")
|
|
797
|
-
elif verbose:
|
|
798
|
-
console.print(f"[cyan]Extracted {len(extracted_items)} items to temp location[/cyan]")
|
|
799
|
-
|
|
800
|
-
source_dir = temp_path
|
|
801
|
-
if len(extracted_items) == 1 and extracted_items[0].is_dir():
|
|
802
|
-
source_dir = extracted_items[0]
|
|
803
|
-
if tracker:
|
|
804
|
-
tracker.add("flatten", "Flatten nested directory")
|
|
805
|
-
tracker.complete("flatten")
|
|
806
|
-
elif verbose:
|
|
807
|
-
console.print(f"[cyan]Found nested directory structure[/cyan]")
|
|
808
|
-
|
|
809
|
-
for item in source_dir.iterdir():
|
|
810
|
-
dest_path = project_path / item.name
|
|
811
|
-
if item.is_dir():
|
|
812
|
-
if dest_path.exists():
|
|
813
|
-
if verbose and not tracker:
|
|
814
|
-
console.print(f"[yellow]Merging directory:[/yellow] {item.name}")
|
|
815
|
-
for sub_item in item.rglob('*'):
|
|
816
|
-
if sub_item.is_file():
|
|
817
|
-
rel_path = sub_item.relative_to(item)
|
|
818
|
-
dest_file = dest_path / rel_path
|
|
819
|
-
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
|
820
|
-
# Special handling for .vscode/settings.json - merge instead of overwrite
|
|
821
|
-
if dest_file.name == "settings.json" and dest_file.parent.name == ".vscode":
|
|
822
|
-
handle_vscode_settings(sub_item, dest_file, rel_path, verbose, tracker)
|
|
823
|
-
else:
|
|
824
|
-
shutil.copy2(sub_item, dest_file)
|
|
825
|
-
else:
|
|
826
|
-
shutil.copytree(item, dest_path)
|
|
827
|
-
else:
|
|
828
|
-
if dest_path.exists() and verbose and not tracker:
|
|
829
|
-
console.print(f"[yellow]Overwriting file:[/yellow] {item.name}")
|
|
830
|
-
shutil.copy2(item, dest_path)
|
|
831
|
-
if verbose and not tracker:
|
|
832
|
-
console.print(f"[cyan]Template files merged into current directory[/cyan]")
|
|
833
|
-
else:
|
|
834
|
-
zip_ref.extractall(project_path)
|
|
835
|
-
|
|
836
|
-
extracted_items = list(project_path.iterdir())
|
|
837
|
-
if tracker:
|
|
838
|
-
tracker.start("extracted-summary")
|
|
839
|
-
tracker.complete("extracted-summary", f"{len(extracted_items)} top-level items")
|
|
840
|
-
elif verbose:
|
|
841
|
-
console.print(f"[cyan]Extracted {len(extracted_items)} items to {project_path}:[/cyan]")
|
|
842
|
-
for item in extracted_items:
|
|
843
|
-
console.print(f" - {item.name} ({'dir' if item.is_dir() else 'file'})")
|
|
844
|
-
|
|
845
|
-
if len(extracted_items) == 1 and extracted_items[0].is_dir():
|
|
846
|
-
nested_dir = extracted_items[0]
|
|
847
|
-
temp_move_dir = project_path.parent / f"{project_path.name}_temp"
|
|
848
|
-
|
|
849
|
-
shutil.move(str(nested_dir), str(temp_move_dir))
|
|
850
|
-
|
|
851
|
-
project_path.rmdir()
|
|
852
|
-
|
|
853
|
-
shutil.move(str(temp_move_dir), str(project_path))
|
|
854
|
-
if tracker:
|
|
855
|
-
tracker.add("flatten", "Flatten nested directory")
|
|
856
|
-
tracker.complete("flatten")
|
|
857
|
-
elif verbose:
|
|
858
|
-
console.print(f"[cyan]Flattened nested directory structure[/cyan]")
|
|
859
|
-
|
|
860
|
-
except Exception as e:
|
|
861
|
-
if tracker:
|
|
862
|
-
tracker.error("extract", str(e))
|
|
863
|
-
else:
|
|
864
|
-
if verbose:
|
|
865
|
-
console.print(f"[red]Error extracting template:[/red] {e}")
|
|
866
|
-
if debug:
|
|
867
|
-
console.print(Panel(str(e), title="Extraction Error", border_style="red"))
|
|
868
|
-
|
|
869
|
-
if not is_current_dir and project_path.exists():
|
|
870
|
-
shutil.rmtree(project_path)
|
|
212
|
+
except (ValueError, OSError) as exc:
|
|
213
|
+
console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}")
|
|
871
214
|
raise typer.Exit(1)
|
|
872
|
-
else:
|
|
873
|
-
if tracker:
|
|
874
|
-
tracker.complete("extract")
|
|
875
|
-
finally:
|
|
876
|
-
if tracker:
|
|
877
|
-
tracker.add("cleanup", "Remove temporary archive")
|
|
878
|
-
|
|
879
|
-
if zip_path.exists():
|
|
880
|
-
zip_path.unlink()
|
|
881
|
-
if tracker:
|
|
882
|
-
tracker.complete("cleanup")
|
|
883
|
-
elif verbose:
|
|
884
|
-
console.print(f"Cleaned up: {zip_path.name}")
|
|
885
|
-
|
|
886
|
-
return project_path
|
|
887
215
|
|
|
888
216
|
|
|
889
217
|
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
|
|
890
|
-
"""Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows)."""
|
|
218
|
+
"""Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows)."""
|
|
891
219
|
if os.name == "nt":
|
|
892
220
|
return # Windows: skip silently
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
221
|
+
scan_roots = [
|
|
222
|
+
project_path / ".specify" / "scripts",
|
|
223
|
+
project_path / ".specify" / "extensions",
|
|
224
|
+
]
|
|
896
225
|
failures: list[str] = []
|
|
897
226
|
updated = 0
|
|
898
|
-
for
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
227
|
+
for scripts_root in scan_roots:
|
|
228
|
+
if not scripts_root.is_dir():
|
|
229
|
+
continue
|
|
230
|
+
for script in scripts_root.rglob("*.sh"):
|
|
902
231
|
try:
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
232
|
+
if script.is_symlink() or not script.is_file():
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
with script.open("rb") as f:
|
|
236
|
+
if f.read(2) != b"#!":
|
|
237
|
+
continue
|
|
238
|
+
except Exception:
|
|
239
|
+
continue
|
|
240
|
+
st = script.stat()
|
|
241
|
+
mode = st.st_mode
|
|
242
|
+
if mode & 0o111:
|
|
243
|
+
continue
|
|
244
|
+
new_mode = mode
|
|
245
|
+
if mode & 0o400:
|
|
246
|
+
new_mode |= 0o100
|
|
247
|
+
if mode & 0o040:
|
|
248
|
+
new_mode |= 0o010
|
|
249
|
+
if mode & 0o004:
|
|
250
|
+
new_mode |= 0o001
|
|
251
|
+
if not (new_mode & 0o100):
|
|
252
|
+
new_mode |= 0o100
|
|
253
|
+
os.chmod(script, new_mode)
|
|
254
|
+
updated += 1
|
|
255
|
+
except Exception as e:
|
|
256
|
+
failures.append(f"{_display_project_path(project_path, script)}: {e}")
|
|
921
257
|
if tracker:
|
|
922
258
|
detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "")
|
|
923
259
|
tracker.add("chmod", "Set script permissions recursively")
|
|
@@ -930,303 +266,186 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
|
|
|
930
266
|
for f in failures:
|
|
931
267
|
console.print(f" - {f}")
|
|
932
268
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, or q"),
|
|
937
|
-
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
|
938
|
-
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"),
|
|
939
|
-
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
|
940
|
-
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
|
|
941
|
-
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
|
|
942
|
-
skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"),
|
|
943
|
-
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
|
|
944
|
-
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
|
945
|
-
):
|
|
946
|
-
"""
|
|
947
|
-
Initialize a new Specify project from the latest template.
|
|
948
|
-
|
|
949
|
-
This command will:
|
|
950
|
-
1. Check that required tools are installed (git is optional)
|
|
951
|
-
2. Let you choose your AI assistant
|
|
952
|
-
3. Download the appropriate template from GitHub
|
|
953
|
-
4. Extract the template to a new project directory or current directory
|
|
954
|
-
5. Initialize a fresh git repository (if not --no-git and no existing repo)
|
|
955
|
-
6. Optionally set up AI assistant commands
|
|
956
|
-
|
|
957
|
-
Examples:
|
|
958
|
-
specify init my-project
|
|
959
|
-
specify init my-project --ai claude
|
|
960
|
-
specify init my-project --ai copilot --no-git
|
|
961
|
-
specify init --ignore-agent-tools my-project
|
|
962
|
-
specify init . --ai claude # Initialize in current directory
|
|
963
|
-
specify init . # Initialize in current directory (interactive AI selection)
|
|
964
|
-
specify init --here --ai claude # Alternative syntax for current directory
|
|
965
|
-
specify init --here --ai codex
|
|
966
|
-
specify init --here --ai codebuddy
|
|
967
|
-
specify init --here
|
|
968
|
-
specify init --here --force # Skip confirmation when current directory not empty
|
|
969
|
-
"""
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# Agent-context extension config helpers
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
970
272
|
|
|
971
|
-
|
|
273
|
+
_AGENT_CTX_EXT_CONFIG = (
|
|
274
|
+
Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml"
|
|
275
|
+
)
|
|
972
276
|
|
|
973
|
-
if project_name == ".":
|
|
974
|
-
here = True
|
|
975
|
-
project_name = None # Clear project_name to use existing validation logic
|
|
976
277
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
278
|
+
def _load_agent_context_config(project_root: Path) -> dict[str, Any]:
|
|
279
|
+
"""Load the agent-context extension config, returning defaults on failure."""
|
|
280
|
+
from .integrations.base import IntegrationBase
|
|
980
281
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
282
|
+
defaults: dict[str, Any] = {
|
|
283
|
+
"context_file": "",
|
|
284
|
+
"context_markers": {
|
|
285
|
+
"start": IntegrationBase.CONTEXT_MARKER_START,
|
|
286
|
+
"end": IntegrationBase.CONTEXT_MARKER_END,
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
path = project_root / _AGENT_CTX_EXT_CONFIG
|
|
290
|
+
if not path.exists():
|
|
291
|
+
return defaults
|
|
292
|
+
try:
|
|
293
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
294
|
+
except (OSError, UnicodeError, yaml.YAMLError):
|
|
295
|
+
return defaults
|
|
296
|
+
if not isinstance(raw, dict):
|
|
297
|
+
return defaults
|
|
298
|
+
return raw
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _save_agent_context_config(
|
|
302
|
+
project_root: Path, config: dict[str, Any]
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Persist *config* to the agent-context extension config file."""
|
|
305
|
+
path = project_root / _AGENT_CTX_EXT_CONFIG
|
|
306
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False), encoding="utf-8")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _update_agent_context_config_file(
|
|
311
|
+
project_root: Path,
|
|
312
|
+
context_file: str | None,
|
|
313
|
+
*,
|
|
314
|
+
preserve_markers: bool = True,
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Update the agent-context extension config with *context_file*.
|
|
317
|
+
|
|
318
|
+
When *preserve_markers* is True (default), any existing
|
|
319
|
+
``context_markers`` values are kept unchanged so user customisations
|
|
320
|
+
survive integration changes and reinit. When False, the default
|
|
321
|
+
markers are written unconditionally.
|
|
322
|
+
"""
|
|
323
|
+
from .integrations.base import IntegrationBase
|
|
984
324
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
325
|
+
cfg = _load_agent_context_config(project_root)
|
|
326
|
+
cfg["context_file"] = context_file or ""
|
|
327
|
+
if not preserve_markers or not isinstance(cfg.get("context_markers"), dict):
|
|
328
|
+
cfg["context_markers"] = {
|
|
329
|
+
"start": IntegrationBase.CONTEXT_MARKER_START,
|
|
330
|
+
"end": IntegrationBase.CONTEXT_MARKER_END,
|
|
331
|
+
}
|
|
332
|
+
_save_agent_context_config(project_root, cfg)
|
|
988
333
|
|
|
989
|
-
existing_items = list(project_path.iterdir())
|
|
990
|
-
if existing_items:
|
|
991
|
-
console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)")
|
|
992
|
-
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
|
|
993
|
-
if force:
|
|
994
|
-
console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]")
|
|
995
|
-
else:
|
|
996
|
-
response = typer.confirm("Do you want to continue?")
|
|
997
|
-
if not response:
|
|
998
|
-
console.print("[yellow]Operation cancelled[/yellow]")
|
|
999
|
-
raise typer.Exit(0)
|
|
1000
|
-
else:
|
|
1001
|
-
project_path = Path(project_name).resolve()
|
|
1002
|
-
if project_path.exists():
|
|
1003
|
-
error_panel = Panel(
|
|
1004
|
-
f"Directory '[cyan]{project_name}[/cyan]' already exists\n"
|
|
1005
|
-
"Please choose a different project name or remove the existing directory.",
|
|
1006
|
-
title="[red]Directory Conflict[/red]",
|
|
1007
|
-
border_style="red",
|
|
1008
|
-
padding=(1, 2)
|
|
1009
|
-
)
|
|
1010
|
-
console.print()
|
|
1011
|
-
console.print(error_panel)
|
|
1012
|
-
raise typer.Exit(1)
|
|
1013
334
|
|
|
1014
|
-
|
|
335
|
+
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
|
336
|
+
"""Resolve the agent-specific skills directory.
|
|
1015
337
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
338
|
+
Returns ``project_path / <agent_folder> / "skills"``, falling back
|
|
339
|
+
to ``project_path / ".agents/skills"`` for unknown agents.
|
|
340
|
+
"""
|
|
341
|
+
agent_config = AGENT_CONFIG.get(selected_ai, {})
|
|
342
|
+
agent_folder = agent_config.get("folder", "")
|
|
343
|
+
if agent_folder:
|
|
344
|
+
return project_path / agent_folder.rstrip("/") / "skills"
|
|
345
|
+
return project_path / ".agents" / "skills"
|
|
1022
346
|
|
|
1023
|
-
if not here:
|
|
1024
|
-
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
|
|
1025
347
|
|
|
1026
|
-
|
|
348
|
+
def resolve_active_skills_dir(project_root: Path) -> Path | None:
|
|
349
|
+
"""Return the active skills directory, creating it on demand when enabled.
|
|
1027
350
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
351
|
+
Reads ``.specify/init-options.json`` to determine whether skills are
|
|
352
|
+
enabled and which agent was selected. Only ``ai_skills`` set to boolean
|
|
353
|
+
``True`` creates the directory safely (symlink/containment checks); when
|
|
354
|
+
``ai_skills`` is not boolean ``True``, only Kimi's native-skills fallback
|
|
355
|
+
is honoured, and the native skills directory must already exist.
|
|
1033
356
|
|
|
1034
|
-
|
|
1035
|
-
if
|
|
1036
|
-
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
|
1037
|
-
raise typer.Exit(1)
|
|
1038
|
-
selected_ai = ai_assistant
|
|
1039
|
-
else:
|
|
1040
|
-
# Create options dict for selection (agent_key: display_name)
|
|
1041
|
-
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
|
|
1042
|
-
selected_ai = select_with_arrows(
|
|
1043
|
-
ai_choices,
|
|
1044
|
-
"Choose your AI assistant:",
|
|
1045
|
-
"copilot"
|
|
1046
|
-
)
|
|
357
|
+
Returns:
|
|
358
|
+
The skills directory ``Path``, or ``None`` if skills are not active.
|
|
1047
359
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
f"Install from: [cyan]{install_url}[/cyan]\n"
|
|
1056
|
-
f"{agent_config['name']} is required to continue with this project type.\n\n"
|
|
1057
|
-
"Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check",
|
|
1058
|
-
title="[red]Agent Detection Error[/red]",
|
|
1059
|
-
border_style="red",
|
|
1060
|
-
padding=(1, 2)
|
|
1061
|
-
)
|
|
1062
|
-
console.print()
|
|
1063
|
-
console.print(error_panel)
|
|
1064
|
-
raise typer.Exit(1)
|
|
360
|
+
Raises:
|
|
361
|
+
ValueError: If the resolved skills path escapes the project root,
|
|
362
|
+
a parent component is a symlink, or a path component exists
|
|
363
|
+
but is not a directory.
|
|
364
|
+
OSError: If the directory cannot be created (e.g. permission denied).
|
|
365
|
+
"""
|
|
366
|
+
from .shared_infra import _ensure_safe_shared_directory
|
|
1065
367
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
raise typer.Exit(1)
|
|
1070
|
-
selected_script = script_type
|
|
1071
|
-
else:
|
|
1072
|
-
default_script = "ps" if os.name == "nt" else "sh"
|
|
368
|
+
opts = load_init_options(project_root)
|
|
369
|
+
if not isinstance(opts, dict):
|
|
370
|
+
opts = {}
|
|
1073
371
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
selected_script = default_script
|
|
1078
|
-
|
|
1079
|
-
console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}")
|
|
1080
|
-
console.print(f"[cyan]Selected script type:[/cyan] {selected_script}")
|
|
1081
|
-
|
|
1082
|
-
tracker = StepTracker("Initialize Specify Project")
|
|
1083
|
-
|
|
1084
|
-
sys._specify_tracker_active = True
|
|
1085
|
-
|
|
1086
|
-
tracker.add("precheck", "Check required tools")
|
|
1087
|
-
tracker.complete("precheck", "ok")
|
|
1088
|
-
tracker.add("ai-select", "Select AI assistant")
|
|
1089
|
-
tracker.complete("ai-select", f"{selected_ai}")
|
|
1090
|
-
tracker.add("script-select", "Select script type")
|
|
1091
|
-
tracker.complete("script-select", selected_script)
|
|
1092
|
-
for key, label in [
|
|
1093
|
-
("fetch", "Fetch latest release"),
|
|
1094
|
-
("download", "Download template"),
|
|
1095
|
-
("extract", "Extract template"),
|
|
1096
|
-
("zip-list", "Archive contents"),
|
|
1097
|
-
("extracted-summary", "Extraction summary"),
|
|
1098
|
-
("chmod", "Ensure scripts executable"),
|
|
1099
|
-
("cleanup", "Cleanup"),
|
|
1100
|
-
("git", "Initialize git repository"),
|
|
1101
|
-
("final", "Finalize")
|
|
1102
|
-
]:
|
|
1103
|
-
tracker.add(key, label)
|
|
1104
|
-
|
|
1105
|
-
# Track git error message outside Live context so it persists
|
|
1106
|
-
git_error_message = None
|
|
1107
|
-
|
|
1108
|
-
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
|
|
1109
|
-
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
|
1110
|
-
try:
|
|
1111
|
-
verify = not skip_tls
|
|
1112
|
-
local_ssl_context = ssl_context if verify else False
|
|
1113
|
-
local_client = httpx.Client(verify=local_ssl_context)
|
|
1114
|
-
|
|
1115
|
-
download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token)
|
|
1116
|
-
|
|
1117
|
-
ensure_executable_scripts(project_path, tracker=tracker)
|
|
1118
|
-
|
|
1119
|
-
if not no_git:
|
|
1120
|
-
tracker.start("git")
|
|
1121
|
-
if is_git_repo(project_path):
|
|
1122
|
-
tracker.complete("git", "existing repo detected")
|
|
1123
|
-
elif should_init_git:
|
|
1124
|
-
success, error_msg = init_git_repo(project_path, quiet=True)
|
|
1125
|
-
if success:
|
|
1126
|
-
tracker.complete("git", "initialized")
|
|
1127
|
-
else:
|
|
1128
|
-
tracker.error("git", "init failed")
|
|
1129
|
-
git_error_message = error_msg
|
|
1130
|
-
else:
|
|
1131
|
-
tracker.skip("git", "git not available")
|
|
1132
|
-
else:
|
|
1133
|
-
tracker.skip("git", "--no-git flag")
|
|
372
|
+
agent = opts.get("ai")
|
|
373
|
+
if not isinstance(agent, str) or not agent:
|
|
374
|
+
return None
|
|
1134
375
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
|
|
1139
|
-
if debug:
|
|
1140
|
-
_env_pairs = [
|
|
1141
|
-
("Python", sys.version.split()[0]),
|
|
1142
|
-
("Platform", sys.platform),
|
|
1143
|
-
("CWD", str(Path.cwd())),
|
|
1144
|
-
]
|
|
1145
|
-
_label_width = max(len(k) for k, _ in _env_pairs)
|
|
1146
|
-
env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
|
|
1147
|
-
console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
|
|
1148
|
-
if not here and project_path.exists():
|
|
1149
|
-
shutil.rmtree(project_path)
|
|
1150
|
-
raise typer.Exit(1)
|
|
1151
|
-
finally:
|
|
1152
|
-
pass
|
|
376
|
+
ai_skills_enabled = _is_ai_skills_enabled(opts)
|
|
377
|
+
if not ai_skills_enabled and agent != "kimi":
|
|
378
|
+
return None
|
|
1153
379
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
f"[cyan]cd {project_path if not here else '.'}[/cyan]\n"
|
|
1165
|
-
f"[cyan]git init[/cyan]\n"
|
|
1166
|
-
f"[cyan]git add .[/cyan]\n"
|
|
1167
|
-
f"[cyan]git commit -m \"Initial commit\"[/cyan]",
|
|
1168
|
-
title="[red]Git Initialization Failed[/red]",
|
|
1169
|
-
border_style="red",
|
|
1170
|
-
padding=(1, 2)
|
|
1171
|
-
)
|
|
1172
|
-
console.print(git_error_panel)
|
|
1173
|
-
|
|
1174
|
-
# Agent folder security notice
|
|
1175
|
-
agent_config = AGENT_CONFIG.get(selected_ai)
|
|
1176
|
-
if agent_config:
|
|
1177
|
-
agent_folder = agent_config["folder"]
|
|
1178
|
-
security_notice = Panel(
|
|
1179
|
-
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
|
1180
|
-
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
|
1181
|
-
title="[yellow]Agent Folder Security[/yellow]",
|
|
1182
|
-
border_style="yellow",
|
|
1183
|
-
padding=(1, 2)
|
|
380
|
+
skills_dir = _get_skills_dir(project_root, agent)
|
|
381
|
+
|
|
382
|
+
if not ai_skills_enabled:
|
|
383
|
+
# Kimi native-skills fallback when ai_skills is not boolean True:
|
|
384
|
+
# use the native skills directory only if it already exists.
|
|
385
|
+
if not skills_dir.is_dir():
|
|
386
|
+
return None
|
|
387
|
+
_ensure_safe_shared_directory(
|
|
388
|
+
project_root, skills_dir,
|
|
389
|
+
create=False, context="agent skills directory",
|
|
1184
390
|
)
|
|
1185
|
-
|
|
1186
|
-
|
|
391
|
+
return skills_dir
|
|
392
|
+
|
|
393
|
+
# ai_skills is boolean True: create the directory safely.
|
|
394
|
+
_ensure_safe_shared_directory(
|
|
395
|
+
project_root, skills_dir, context="agent skills directory",
|
|
396
|
+
)
|
|
397
|
+
return skills_dir
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _cli_error_detail(exc: BaseException) -> str:
|
|
401
|
+
"""Return a compact one-line exception detail for CLI output."""
|
|
402
|
+
detail = str(exc).replace("\n", " ").strip()
|
|
403
|
+
return detail or exc.__class__.__name__
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _cli_phase_label(phase: str, target_kind: str, target: str | None = None) -> str:
|
|
407
|
+
"""Format a stable operation label for user-visible diagnostics."""
|
|
408
|
+
label = f"{phase} {target_kind}".strip()
|
|
409
|
+
if target:
|
|
410
|
+
label = f"{label} '{target}'"
|
|
411
|
+
return label
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _print_cli_warning(
|
|
415
|
+
phase: str,
|
|
416
|
+
target_kind: str,
|
|
417
|
+
target: str | None,
|
|
418
|
+
exc: BaseException,
|
|
419
|
+
*,
|
|
420
|
+
continuing: str | None = None,
|
|
421
|
+
) -> None:
|
|
422
|
+
"""Print a warning that names the failed CLI phase and target."""
|
|
423
|
+
label = _cli_phase_label(phase, target_kind, target)
|
|
424
|
+
console.print(f"[yellow]Warning:[/yellow] Failed to {label}: {_cli_error_detail(exc)}")
|
|
425
|
+
if continuing:
|
|
426
|
+
console.print(f"[dim]{continuing}[/dim]")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# Constants kept for backward compatibility with presets and extensions.
|
|
430
|
+
DEFAULT_SKILLS_DIR = ".agents/skills"
|
|
431
|
+
SKILL_DESCRIPTIONS = {
|
|
432
|
+
"specify": "Create or update feature specifications from natural language descriptions.",
|
|
433
|
+
"plan": "Generate technical implementation plans from feature specifications.",
|
|
434
|
+
"tasks": "Break down implementation plans into actionable task lists.",
|
|
435
|
+
"implement": "Execute all tasks from the task breakdown to build the feature.",
|
|
436
|
+
"analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md.",
|
|
437
|
+
"clarify": "Structured clarification workflow for underspecified requirements.",
|
|
438
|
+
"constitution": "Create or update project governing principles and development guidelines.",
|
|
439
|
+
"checklist": "Generate custom quality checklists for validating requirements completeness and clarity.",
|
|
440
|
+
"taskstoissues": "Convert tasks from tasks.md into GitHub issues.",
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ===== init command =====
|
|
445
|
+
# Moved to commands/init.py — registered here to preserve CLI surface.
|
|
446
|
+
from .commands import init as _init_cmd # noqa: E402
|
|
447
|
+
_init_cmd.register(app)
|
|
1187
448
|
|
|
1188
|
-
steps_lines = []
|
|
1189
|
-
if not here:
|
|
1190
|
-
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
|
|
1191
|
-
step_num = 2
|
|
1192
|
-
else:
|
|
1193
|
-
steps_lines.append("1. You're already in the project directory!")
|
|
1194
|
-
step_num = 2
|
|
1195
|
-
|
|
1196
|
-
# Add Codex-specific setup step if needed
|
|
1197
|
-
if selected_ai == "codex":
|
|
1198
|
-
codex_path = project_path / ".codex"
|
|
1199
|
-
quoted_path = shlex.quote(str(codex_path))
|
|
1200
|
-
if os.name == "nt": # Windows
|
|
1201
|
-
cmd = f"setx CODEX_HOME {quoted_path}"
|
|
1202
|
-
else: # Unix-like systems
|
|
1203
|
-
cmd = f"export CODEX_HOME={quoted_path}"
|
|
1204
|
-
|
|
1205
|
-
steps_lines.append(f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]")
|
|
1206
|
-
step_num += 1
|
|
1207
|
-
|
|
1208
|
-
steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:")
|
|
1209
|
-
|
|
1210
|
-
steps_lines.append(" 2.1 [cyan]/speckit.constitution[/] - Establish project principles")
|
|
1211
|
-
steps_lines.append(" 2.2 [cyan]/speckit.specify[/] - Create baseline specification")
|
|
1212
|
-
steps_lines.append(" 2.3 [cyan]/speckit.plan[/] - Create implementation plan")
|
|
1213
|
-
steps_lines.append(" 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks")
|
|
1214
|
-
steps_lines.append(" 2.5 [cyan]/speckit.implement[/] - Execute implementation")
|
|
1215
|
-
|
|
1216
|
-
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
|
|
1217
|
-
console.print()
|
|
1218
|
-
console.print(steps_panel)
|
|
1219
|
-
|
|
1220
|
-
enhancement_lines = [
|
|
1221
|
-
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
|
|
1222
|
-
"",
|
|
1223
|
-
f"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
|
|
1224
|
-
f"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
|
|
1225
|
-
f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
|
|
1226
|
-
]
|
|
1227
|
-
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
|
|
1228
|
-
console.print()
|
|
1229
|
-
console.print(enhancements_panel)
|
|
1230
449
|
|
|
1231
450
|
@app.command()
|
|
1232
451
|
def check():
|
|
@@ -1241,6 +460,8 @@ def check():
|
|
|
1241
460
|
|
|
1242
461
|
agent_results = {}
|
|
1243
462
|
for agent_key, agent_config in AGENT_CONFIG.items():
|
|
463
|
+
if agent_key == "generic":
|
|
464
|
+
continue # Generic is not a real agent to check
|
|
1244
465
|
agent_name = agent_config["name"]
|
|
1245
466
|
requires_cli = agent_config["requires_cli"]
|
|
1246
467
|
|
|
@@ -1255,10 +476,10 @@ def check():
|
|
|
1255
476
|
|
|
1256
477
|
# Check VS Code variants (not in agent config)
|
|
1257
478
|
tracker.add("code", "Visual Studio Code")
|
|
1258
|
-
|
|
479
|
+
check_tool("code", tracker=tracker)
|
|
1259
480
|
|
|
1260
481
|
tracker.add("code-insiders", "Visual Studio Code Insiders")
|
|
1261
|
-
|
|
482
|
+
check_tool("code-insiders", tracker=tracker)
|
|
1262
483
|
|
|
1263
484
|
console.print(tracker.render())
|
|
1264
485
|
|
|
@@ -1268,71 +489,68 @@ def check():
|
|
|
1268
489
|
console.print("[dim]Tip: Install git for repository management[/dim]")
|
|
1269
490
|
|
|
1270
491
|
if not any(agent_results.values()):
|
|
1271
|
-
console.print("[dim]Tip: Install
|
|
492
|
+
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
|
|
493
|
+
|
|
494
|
+
console.print("[dim]Tip: Run 'specify self check' to verify you have the latest CLI version[/dim]")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _feature_capabilities() -> dict[str, bool]:
|
|
498
|
+
"""Return stable local CLI capability flags for humans and agents."""
|
|
499
|
+
return {
|
|
500
|
+
"controlled_multi_install_integrations": True,
|
|
501
|
+
"integration_use_command": True,
|
|
502
|
+
"multi_install_safe_registry_metadata": True,
|
|
503
|
+
"integration_upgrade_command": True,
|
|
504
|
+
"self_check_command": True,
|
|
505
|
+
"workflow_catalog": True,
|
|
506
|
+
"bundled_templates": True,
|
|
507
|
+
}
|
|
508
|
+
|
|
1272
509
|
|
|
1273
510
|
@app.command()
|
|
1274
|
-
def version(
|
|
511
|
+
def version(
|
|
512
|
+
features: bool = typer.Option(
|
|
513
|
+
False,
|
|
514
|
+
"--features",
|
|
515
|
+
help="Show local CLI feature capabilities.",
|
|
516
|
+
),
|
|
517
|
+
json_output: bool = typer.Option(
|
|
518
|
+
False,
|
|
519
|
+
"--json",
|
|
520
|
+
help="Emit feature capabilities as JSON. Requires --features.",
|
|
521
|
+
),
|
|
522
|
+
):
|
|
1275
523
|
"""Display version and system information."""
|
|
1276
524
|
import platform
|
|
1277
|
-
|
|
1278
|
-
|
|
525
|
+
|
|
526
|
+
cli_version = get_speckit_version()
|
|
527
|
+
|
|
528
|
+
if json_output and not features:
|
|
529
|
+
console.print("[red]Error:[/red] --json requires --features.")
|
|
530
|
+
raise typer.Exit(1)
|
|
531
|
+
|
|
532
|
+
if features:
|
|
533
|
+
capabilities = _feature_capabilities()
|
|
534
|
+
if json_output:
|
|
535
|
+
payload = {"version": cli_version, "features": capabilities}
|
|
536
|
+
console.print(json.dumps(payload, indent=2))
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
console.print(f"Spec Kit CLI: {cli_version}")
|
|
540
|
+
console.print()
|
|
541
|
+
console.print("Features:")
|
|
542
|
+
for key, enabled in capabilities.items():
|
|
543
|
+
label = key.replace("_", " ")
|
|
544
|
+
console.print(f"- {label}: {'yes' if enabled else 'no'}")
|
|
545
|
+
return
|
|
546
|
+
|
|
1279
547
|
show_banner()
|
|
1280
|
-
|
|
1281
|
-
# Get CLI version from package metadata
|
|
1282
|
-
cli_version = "unknown"
|
|
1283
|
-
try:
|
|
1284
|
-
cli_version = importlib.metadata.version("specify-cli")
|
|
1285
|
-
except Exception:
|
|
1286
|
-
# Fallback: try reading from pyproject.toml if running from source
|
|
1287
|
-
try:
|
|
1288
|
-
import tomllib
|
|
1289
|
-
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
|
1290
|
-
if pyproject_path.exists():
|
|
1291
|
-
with open(pyproject_path, "rb") as f:
|
|
1292
|
-
data = tomllib.load(f)
|
|
1293
|
-
cli_version = data.get("project", {}).get("version", "unknown")
|
|
1294
|
-
except Exception:
|
|
1295
|
-
pass
|
|
1296
|
-
|
|
1297
|
-
# Fetch latest template release version
|
|
1298
|
-
repo_owner = "github"
|
|
1299
|
-
repo_name = "spec-kit"
|
|
1300
|
-
api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
|
1301
|
-
|
|
1302
|
-
template_version = "unknown"
|
|
1303
|
-
release_date = "unknown"
|
|
1304
|
-
|
|
1305
|
-
try:
|
|
1306
|
-
response = client.get(
|
|
1307
|
-
api_url,
|
|
1308
|
-
timeout=10,
|
|
1309
|
-
follow_redirects=True,
|
|
1310
|
-
headers=_github_auth_headers(),
|
|
1311
|
-
)
|
|
1312
|
-
if response.status_code == 200:
|
|
1313
|
-
release_data = response.json()
|
|
1314
|
-
template_version = release_data.get("tag_name", "unknown")
|
|
1315
|
-
# Remove 'v' prefix if present
|
|
1316
|
-
if template_version.startswith("v"):
|
|
1317
|
-
template_version = template_version[1:]
|
|
1318
|
-
release_date = release_data.get("published_at", "unknown")
|
|
1319
|
-
if release_date != "unknown":
|
|
1320
|
-
# Format the date nicely
|
|
1321
|
-
try:
|
|
1322
|
-
dt = datetime.fromisoformat(release_date.replace('Z', '+00:00'))
|
|
1323
|
-
release_date = dt.strftime("%Y-%m-%d")
|
|
1324
|
-
except Exception:
|
|
1325
|
-
pass
|
|
1326
|
-
except Exception:
|
|
1327
|
-
pass
|
|
1328
548
|
|
|
1329
549
|
info_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
1330
550
|
info_table.add_column("Key", style="cyan", justify="right")
|
|
1331
551
|
info_table.add_column("Value", style="white")
|
|
1332
552
|
|
|
1333
553
|
info_table.add_row("CLI Version", cli_version)
|
|
1334
|
-
info_table.add_row("Template Version", template_version)
|
|
1335
|
-
info_table.add_row("Released", release_date)
|
|
1336
554
|
info_table.add_row("", "")
|
|
1337
555
|
info_table.add_row("Python", platform.python_version())
|
|
1338
556
|
info_table.add_row("Platform", platform.system())
|
|
@@ -1349,9 +567,2875 @@ def version():
|
|
|
1349
567
|
console.print(panel)
|
|
1350
568
|
console.print()
|
|
1351
569
|
|
|
1352
|
-
|
|
1353
|
-
app()
|
|
570
|
+
app.add_typer(_self_app, name="self")
|
|
1354
571
|
|
|
1355
|
-
if __name__ == "__main__":
|
|
1356
|
-
main()
|
|
1357
572
|
|
|
573
|
+
# ===== Extension Commands =====
|
|
574
|
+
|
|
575
|
+
extension_app = typer.Typer(
|
|
576
|
+
name="extension",
|
|
577
|
+
help="Manage spec-kit extensions",
|
|
578
|
+
add_completion=False,
|
|
579
|
+
)
|
|
580
|
+
app.add_typer(extension_app, name="extension")
|
|
581
|
+
|
|
582
|
+
catalog_app = typer.Typer(
|
|
583
|
+
name="catalog",
|
|
584
|
+
help="Manage extension catalogs",
|
|
585
|
+
add_completion=False,
|
|
586
|
+
)
|
|
587
|
+
extension_app.add_typer(catalog_app, name="catalog")
|
|
588
|
+
|
|
589
|
+
preset_app = typer.Typer(
|
|
590
|
+
name="preset",
|
|
591
|
+
help="Manage spec-kit presets",
|
|
592
|
+
add_completion=False,
|
|
593
|
+
)
|
|
594
|
+
app.add_typer(preset_app, name="preset")
|
|
595
|
+
|
|
596
|
+
preset_catalog_app = typer.Typer(
|
|
597
|
+
name="catalog",
|
|
598
|
+
help="Manage preset catalogs",
|
|
599
|
+
add_completion=False,
|
|
600
|
+
)
|
|
601
|
+
preset_app.add_typer(preset_catalog_app, name="catalog")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ===== Integration Commands =====
|
|
605
|
+
|
|
606
|
+
# Moved to integrations/_commands.py — registered here to preserve CLI surface.
|
|
607
|
+
from .integrations._commands import register as _register_integration_cmds # noqa: E402
|
|
608
|
+
_register_integration_cmds(app)
|
|
609
|
+
|
|
610
|
+
# Re-exported from integrations/_helpers.py to preserve the public import surface.
|
|
611
|
+
from .integrations._helpers import ( # noqa: E402
|
|
612
|
+
_clear_init_options_for_integration as _clear_init_options_for_integration,
|
|
613
|
+
_update_init_options_for_integration as _update_init_options_for_integration,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _require_specify_project() -> Path:
|
|
618
|
+
"""Return the current project root if it is a spec-kit project, else exit."""
|
|
619
|
+
project_root = Path.cwd()
|
|
620
|
+
if (project_root / ".specify").is_dir():
|
|
621
|
+
return project_root
|
|
622
|
+
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
|
623
|
+
console.print("Run this command from a spec-kit project root")
|
|
624
|
+
raise typer.Exit(1)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
# ===== Preset Commands =====
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@preset_app.command("list")
|
|
632
|
+
def preset_list():
|
|
633
|
+
"""List installed presets."""
|
|
634
|
+
from .presets import PresetManager
|
|
635
|
+
|
|
636
|
+
project_root = _require_specify_project()
|
|
637
|
+
manager = PresetManager(project_root)
|
|
638
|
+
installed = manager.list_installed()
|
|
639
|
+
|
|
640
|
+
if not installed:
|
|
641
|
+
console.print("[yellow]No presets installed.[/yellow]")
|
|
642
|
+
console.print("\nInstall a preset with:")
|
|
643
|
+
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
|
|
647
|
+
for pack in installed:
|
|
648
|
+
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
|
649
|
+
pri = pack.get('priority', 10)
|
|
650
|
+
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
|
|
651
|
+
console.print(f" {pack['description']}")
|
|
652
|
+
if pack.get("tags"):
|
|
653
|
+
tags_str = ", ".join(pack["tags"])
|
|
654
|
+
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
|
655
|
+
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
|
656
|
+
console.print()
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@preset_app.command("add")
|
|
660
|
+
def preset_add(
|
|
661
|
+
preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
|
|
662
|
+
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
|
|
663
|
+
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
|
|
664
|
+
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
|
665
|
+
):
|
|
666
|
+
"""Install a preset."""
|
|
667
|
+
from .presets import (
|
|
668
|
+
PresetManager,
|
|
669
|
+
PresetCatalog,
|
|
670
|
+
PresetError,
|
|
671
|
+
PresetValidationError,
|
|
672
|
+
PresetCompatibilityError,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
project_root = _require_specify_project()
|
|
676
|
+
# Validate priority
|
|
677
|
+
if priority < 1:
|
|
678
|
+
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
679
|
+
raise typer.Exit(1)
|
|
680
|
+
|
|
681
|
+
manager = PresetManager(project_root)
|
|
682
|
+
speckit_version = get_speckit_version()
|
|
683
|
+
|
|
684
|
+
try:
|
|
685
|
+
if dev:
|
|
686
|
+
dev_path = Path(dev).resolve()
|
|
687
|
+
if not dev_path.exists():
|
|
688
|
+
console.print(f"[red]Error:[/red] Directory not found: {dev}")
|
|
689
|
+
raise typer.Exit(1)
|
|
690
|
+
|
|
691
|
+
console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...")
|
|
692
|
+
manifest = manager.install_from_directory(dev_path, speckit_version, priority)
|
|
693
|
+
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
|
694
|
+
|
|
695
|
+
elif from_url:
|
|
696
|
+
# Validate URL scheme before downloading
|
|
697
|
+
from urllib.parse import urlparse as _urlparse
|
|
698
|
+
_parsed = _urlparse(from_url)
|
|
699
|
+
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
700
|
+
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
|
|
701
|
+
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
|
|
702
|
+
raise typer.Exit(1)
|
|
703
|
+
|
|
704
|
+
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
|
705
|
+
import urllib.request
|
|
706
|
+
import urllib.error
|
|
707
|
+
import tempfile
|
|
708
|
+
|
|
709
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
710
|
+
zip_path = Path(tmpdir) / "preset.zip"
|
|
711
|
+
try:
|
|
712
|
+
from specify_cli.authentication.http import open_url as _open_url
|
|
713
|
+
|
|
714
|
+
with _open_url(from_url, timeout=60) as response:
|
|
715
|
+
zip_path.write_bytes(response.read())
|
|
716
|
+
except urllib.error.URLError as e:
|
|
717
|
+
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
|
718
|
+
raise typer.Exit(1)
|
|
719
|
+
|
|
720
|
+
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
|
721
|
+
|
|
722
|
+
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
|
723
|
+
|
|
724
|
+
elif preset_id:
|
|
725
|
+
# Try bundled preset first, then catalog
|
|
726
|
+
bundled_path = _locate_bundled_preset(preset_id)
|
|
727
|
+
if bundled_path:
|
|
728
|
+
console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...")
|
|
729
|
+
manifest = manager.install_from_directory(bundled_path, speckit_version, priority)
|
|
730
|
+
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
|
731
|
+
else:
|
|
732
|
+
catalog = PresetCatalog(project_root)
|
|
733
|
+
pack_info = catalog.get_pack_info(preset_id)
|
|
734
|
+
|
|
735
|
+
if not pack_info:
|
|
736
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog")
|
|
737
|
+
raise typer.Exit(1)
|
|
738
|
+
|
|
739
|
+
# Bundled presets should have been caught above; if we reach
|
|
740
|
+
# here the bundled files are missing from the installation.
|
|
741
|
+
if pack_info.get("bundled") and not pack_info.get("download_url"):
|
|
742
|
+
from .extensions import REINSTALL_COMMAND
|
|
743
|
+
console.print(
|
|
744
|
+
f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit "
|
|
745
|
+
f"but could not be found in the installed package."
|
|
746
|
+
)
|
|
747
|
+
console.print(
|
|
748
|
+
"\nThis usually means the spec-kit installation is incomplete or corrupted."
|
|
749
|
+
)
|
|
750
|
+
console.print("Try reinstalling spec-kit:")
|
|
751
|
+
console.print(f" {REINSTALL_COMMAND}")
|
|
752
|
+
raise typer.Exit(1)
|
|
753
|
+
|
|
754
|
+
if not pack_info.get("_install_allowed", True):
|
|
755
|
+
catalog_name = pack_info.get("_catalog_name", "unknown")
|
|
756
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
|
|
757
|
+
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
|
|
758
|
+
raise typer.Exit(1)
|
|
759
|
+
|
|
760
|
+
console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...")
|
|
761
|
+
|
|
762
|
+
try:
|
|
763
|
+
zip_path = catalog.download_pack(preset_id)
|
|
764
|
+
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
|
765
|
+
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
|
766
|
+
finally:
|
|
767
|
+
if 'zip_path' in locals() and zip_path.exists():
|
|
768
|
+
zip_path.unlink(missing_ok=True)
|
|
769
|
+
else:
|
|
770
|
+
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
|
|
771
|
+
raise typer.Exit(1)
|
|
772
|
+
|
|
773
|
+
except PresetCompatibilityError as e:
|
|
774
|
+
console.print(f"[red]Compatibility Error:[/red] {e}")
|
|
775
|
+
raise typer.Exit(1)
|
|
776
|
+
except PresetValidationError as e:
|
|
777
|
+
console.print(f"[red]Validation Error:[/red] {e}")
|
|
778
|
+
raise typer.Exit(1)
|
|
779
|
+
except PresetError as e:
|
|
780
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
781
|
+
raise typer.Exit(1)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@preset_app.command("remove")
|
|
785
|
+
def preset_remove(
|
|
786
|
+
preset_id: str = typer.Argument(..., help="Preset ID to remove"),
|
|
787
|
+
):
|
|
788
|
+
"""Remove an installed preset."""
|
|
789
|
+
from .presets import PresetManager
|
|
790
|
+
|
|
791
|
+
project_root = _require_specify_project()
|
|
792
|
+
manager = PresetManager(project_root)
|
|
793
|
+
|
|
794
|
+
if not manager.registry.is_installed(preset_id):
|
|
795
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
|
|
796
|
+
raise typer.Exit(1)
|
|
797
|
+
|
|
798
|
+
if manager.remove(preset_id):
|
|
799
|
+
console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully")
|
|
800
|
+
else:
|
|
801
|
+
console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'")
|
|
802
|
+
raise typer.Exit(1)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
@preset_app.command("search")
|
|
806
|
+
def preset_search(
|
|
807
|
+
query: str = typer.Argument(None, help="Search query"),
|
|
808
|
+
tag: str = typer.Option(None, "--tag", help="Filter by tag"),
|
|
809
|
+
author: str = typer.Option(None, "--author", help="Filter by author"),
|
|
810
|
+
):
|
|
811
|
+
"""Search for presets in the catalog."""
|
|
812
|
+
from .presets import PresetCatalog, PresetError
|
|
813
|
+
|
|
814
|
+
project_root = _require_specify_project()
|
|
815
|
+
catalog = PresetCatalog(project_root)
|
|
816
|
+
|
|
817
|
+
try:
|
|
818
|
+
results = catalog.search(query=query, tag=tag, author=author)
|
|
819
|
+
except PresetError as e:
|
|
820
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
821
|
+
raise typer.Exit(1)
|
|
822
|
+
|
|
823
|
+
if not results:
|
|
824
|
+
console.print("[yellow]No presets found matching your criteria.[/yellow]")
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
|
|
828
|
+
for pack in results:
|
|
829
|
+
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
|
|
830
|
+
console.print(f" {pack.get('description', '')}")
|
|
831
|
+
if pack.get("tags"):
|
|
832
|
+
tags_str = ", ".join(pack["tags"])
|
|
833
|
+
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
|
834
|
+
console.print()
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
@preset_app.command("resolve")
|
|
838
|
+
def preset_resolve(
|
|
839
|
+
template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"),
|
|
840
|
+
):
|
|
841
|
+
"""Show which template will be resolved for a given name."""
|
|
842
|
+
from .presets import PresetResolver
|
|
843
|
+
|
|
844
|
+
project_root = _require_specify_project()
|
|
845
|
+
resolver = PresetResolver(project_root)
|
|
846
|
+
layers = resolver.collect_all_layers(template_name)
|
|
847
|
+
|
|
848
|
+
if layers:
|
|
849
|
+
# Use the highest-priority layer for display because the final output
|
|
850
|
+
# may be composed and may not map to resolve_with_source()'s single path.
|
|
851
|
+
display_layer = layers[0]
|
|
852
|
+
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
|
|
853
|
+
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
|
|
854
|
+
|
|
855
|
+
has_composition = (
|
|
856
|
+
layers[0]["strategy"] != "replace"
|
|
857
|
+
and any(layer["strategy"] != "replace" for layer in layers)
|
|
858
|
+
)
|
|
859
|
+
if has_composition:
|
|
860
|
+
# Verify composition is actually possible
|
|
861
|
+
try:
|
|
862
|
+
composed = resolver.resolve_content(template_name)
|
|
863
|
+
except Exception as exc:
|
|
864
|
+
composed = None
|
|
865
|
+
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
|
|
866
|
+
if composed is None:
|
|
867
|
+
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
|
|
868
|
+
else:
|
|
869
|
+
console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]")
|
|
870
|
+
console.print("\n [bold]Composition chain:[/bold]")
|
|
871
|
+
# Compute the effective base: first replace layer scanning from
|
|
872
|
+
# highest priority (matching resolve_content top-down logic).
|
|
873
|
+
# Only show layers from the base upward (lower layers are ignored).
|
|
874
|
+
effective_base_idx = None
|
|
875
|
+
for idx, lyr in enumerate(layers):
|
|
876
|
+
if lyr["strategy"] == "replace":
|
|
877
|
+
effective_base_idx = idx
|
|
878
|
+
break
|
|
879
|
+
# Show only contributing layers (base and above)
|
|
880
|
+
if effective_base_idx is not None:
|
|
881
|
+
contributing = layers[:effective_base_idx + 1]
|
|
882
|
+
else:
|
|
883
|
+
contributing = layers
|
|
884
|
+
for i, layer in enumerate(reversed(contributing)):
|
|
885
|
+
strategy_label = layer["strategy"]
|
|
886
|
+
if strategy_label == "replace" and i == 0:
|
|
887
|
+
strategy_label = "base"
|
|
888
|
+
console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}")
|
|
889
|
+
else:
|
|
890
|
+
# No layers found — fall back to resolve_with_source for non-composition cases
|
|
891
|
+
result = resolver.resolve_with_source(template_name)
|
|
892
|
+
if result:
|
|
893
|
+
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
|
894
|
+
console.print(f" [dim](from: {result['source']})[/dim]")
|
|
895
|
+
else:
|
|
896
|
+
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
|
897
|
+
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@preset_app.command("info")
|
|
901
|
+
def preset_info(
|
|
902
|
+
preset_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
|
903
|
+
):
|
|
904
|
+
"""Show detailed information about a preset."""
|
|
905
|
+
from .extensions import normalize_priority
|
|
906
|
+
from .presets import PresetCatalog, PresetManager, PresetError
|
|
907
|
+
|
|
908
|
+
project_root = _require_specify_project()
|
|
909
|
+
# Check if installed locally first
|
|
910
|
+
manager = PresetManager(project_root)
|
|
911
|
+
local_pack = manager.get_pack(preset_id)
|
|
912
|
+
|
|
913
|
+
if local_pack:
|
|
914
|
+
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
|
|
915
|
+
console.print(f" ID: {local_pack.id}")
|
|
916
|
+
console.print(f" Version: {local_pack.version}")
|
|
917
|
+
console.print(f" Description: {local_pack.description}")
|
|
918
|
+
if local_pack.author:
|
|
919
|
+
console.print(f" Author: {local_pack.author}")
|
|
920
|
+
if local_pack.tags:
|
|
921
|
+
console.print(f" Tags: {', '.join(local_pack.tags)}")
|
|
922
|
+
console.print(f" Templates: {len(local_pack.templates)}")
|
|
923
|
+
for tmpl in local_pack.templates:
|
|
924
|
+
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
|
925
|
+
repo = local_pack.data.get("preset", {}).get("repository")
|
|
926
|
+
if repo:
|
|
927
|
+
console.print(f" Repository: {repo}")
|
|
928
|
+
license_val = local_pack.data.get("preset", {}).get("license")
|
|
929
|
+
if license_val:
|
|
930
|
+
console.print(f" License: {license_val}")
|
|
931
|
+
console.print("\n [green]Status: installed[/green]")
|
|
932
|
+
# Get priority from registry
|
|
933
|
+
pack_metadata = manager.registry.get(preset_id)
|
|
934
|
+
priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None)
|
|
935
|
+
console.print(f" [dim]Priority:[/dim] {priority}")
|
|
936
|
+
console.print()
|
|
937
|
+
return
|
|
938
|
+
|
|
939
|
+
# Fall back to catalog
|
|
940
|
+
catalog = PresetCatalog(project_root)
|
|
941
|
+
try:
|
|
942
|
+
pack_info = catalog.get_pack_info(preset_id)
|
|
943
|
+
except PresetError:
|
|
944
|
+
pack_info = None
|
|
945
|
+
|
|
946
|
+
if not pack_info:
|
|
947
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)")
|
|
948
|
+
raise typer.Exit(1)
|
|
949
|
+
|
|
950
|
+
console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n")
|
|
951
|
+
console.print(f" ID: {pack_info['id']}")
|
|
952
|
+
console.print(f" Version: {pack_info.get('version', '?')}")
|
|
953
|
+
console.print(f" Description: {pack_info.get('description', '')}")
|
|
954
|
+
if pack_info.get("author"):
|
|
955
|
+
console.print(f" Author: {pack_info['author']}")
|
|
956
|
+
if pack_info.get("tags"):
|
|
957
|
+
console.print(f" Tags: {', '.join(pack_info['tags'])}")
|
|
958
|
+
if pack_info.get("repository"):
|
|
959
|
+
console.print(f" Repository: {pack_info['repository']}")
|
|
960
|
+
if pack_info.get("license"):
|
|
961
|
+
console.print(f" License: {pack_info['license']}")
|
|
962
|
+
console.print("\n [yellow]Status: not installed[/yellow]")
|
|
963
|
+
console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]")
|
|
964
|
+
console.print()
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
@preset_app.command("set-priority")
|
|
968
|
+
def preset_set_priority(
|
|
969
|
+
preset_id: str = typer.Argument(help="Preset ID"),
|
|
970
|
+
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
|
|
971
|
+
):
|
|
972
|
+
"""Set the resolution priority of an installed preset."""
|
|
973
|
+
from .presets import PresetManager
|
|
974
|
+
|
|
975
|
+
project_root = _require_specify_project()
|
|
976
|
+
# Validate priority
|
|
977
|
+
if priority < 1:
|
|
978
|
+
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
979
|
+
raise typer.Exit(1)
|
|
980
|
+
|
|
981
|
+
manager = PresetManager(project_root)
|
|
982
|
+
|
|
983
|
+
# Check if preset is installed
|
|
984
|
+
if not manager.registry.is_installed(preset_id):
|
|
985
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
|
|
986
|
+
raise typer.Exit(1)
|
|
987
|
+
|
|
988
|
+
# Get current metadata
|
|
989
|
+
metadata = manager.registry.get(preset_id)
|
|
990
|
+
if metadata is None or not isinstance(metadata, dict):
|
|
991
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
|
|
992
|
+
raise typer.Exit(1)
|
|
993
|
+
|
|
994
|
+
from .extensions import normalize_priority
|
|
995
|
+
raw_priority = metadata.get("priority")
|
|
996
|
+
# Only skip if the stored value is already a valid int equal to requested priority
|
|
997
|
+
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
|
998
|
+
if isinstance(raw_priority, int) and raw_priority == priority:
|
|
999
|
+
console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]")
|
|
1000
|
+
raise typer.Exit(0)
|
|
1001
|
+
|
|
1002
|
+
old_priority = normalize_priority(raw_priority)
|
|
1003
|
+
|
|
1004
|
+
# Update priority
|
|
1005
|
+
manager.registry.update(preset_id, {"priority": priority})
|
|
1006
|
+
|
|
1007
|
+
console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}")
|
|
1008
|
+
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
@preset_app.command("enable")
|
|
1012
|
+
def preset_enable(
|
|
1013
|
+
preset_id: str = typer.Argument(help="Preset ID to enable"),
|
|
1014
|
+
):
|
|
1015
|
+
"""Enable a disabled preset."""
|
|
1016
|
+
from .presets import PresetManager
|
|
1017
|
+
|
|
1018
|
+
project_root = _require_specify_project()
|
|
1019
|
+
manager = PresetManager(project_root)
|
|
1020
|
+
|
|
1021
|
+
# Check if preset is installed
|
|
1022
|
+
if not manager.registry.is_installed(preset_id):
|
|
1023
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
|
|
1024
|
+
raise typer.Exit(1)
|
|
1025
|
+
|
|
1026
|
+
# Get current metadata
|
|
1027
|
+
metadata = manager.registry.get(preset_id)
|
|
1028
|
+
if metadata is None or not isinstance(metadata, dict):
|
|
1029
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
|
|
1030
|
+
raise typer.Exit(1)
|
|
1031
|
+
|
|
1032
|
+
if metadata.get("enabled", True):
|
|
1033
|
+
console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]")
|
|
1034
|
+
raise typer.Exit(0)
|
|
1035
|
+
|
|
1036
|
+
# Enable the preset
|
|
1037
|
+
manager.registry.update(preset_id, {"enabled": True})
|
|
1038
|
+
|
|
1039
|
+
console.print(f"[green]✓[/green] Preset '{preset_id}' enabled")
|
|
1040
|
+
console.print("\nTemplates from this preset will now be included in resolution.")
|
|
1041
|
+
console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]")
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
@preset_app.command("disable")
|
|
1045
|
+
def preset_disable(
|
|
1046
|
+
preset_id: str = typer.Argument(help="Preset ID to disable"),
|
|
1047
|
+
):
|
|
1048
|
+
"""Disable a preset without removing it."""
|
|
1049
|
+
from .presets import PresetManager
|
|
1050
|
+
|
|
1051
|
+
project_root = _require_specify_project()
|
|
1052
|
+
manager = PresetManager(project_root)
|
|
1053
|
+
|
|
1054
|
+
# Check if preset is installed
|
|
1055
|
+
if not manager.registry.is_installed(preset_id):
|
|
1056
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed")
|
|
1057
|
+
raise typer.Exit(1)
|
|
1058
|
+
|
|
1059
|
+
# Get current metadata
|
|
1060
|
+
metadata = manager.registry.get(preset_id)
|
|
1061
|
+
if metadata is None or not isinstance(metadata, dict):
|
|
1062
|
+
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)")
|
|
1063
|
+
raise typer.Exit(1)
|
|
1064
|
+
|
|
1065
|
+
if not metadata.get("enabled", True):
|
|
1066
|
+
console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]")
|
|
1067
|
+
raise typer.Exit(0)
|
|
1068
|
+
|
|
1069
|
+
# Disable the preset
|
|
1070
|
+
manager.registry.update(preset_id, {"enabled": False})
|
|
1071
|
+
|
|
1072
|
+
console.print(f"[green]✓[/green] Preset '{preset_id}' disabled")
|
|
1073
|
+
console.print("\nTemplates from this preset will be skipped during resolution.")
|
|
1074
|
+
console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]")
|
|
1075
|
+
console.print(f"To re-enable: specify preset enable {preset_id}")
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
# ===== Preset Catalog Commands =====
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
@preset_catalog_app.command("list")
|
|
1082
|
+
def preset_catalog_list():
|
|
1083
|
+
"""List all active preset catalogs."""
|
|
1084
|
+
from .presets import PresetCatalog, PresetValidationError
|
|
1085
|
+
|
|
1086
|
+
project_root = _require_specify_project()
|
|
1087
|
+
catalog = PresetCatalog(project_root)
|
|
1088
|
+
|
|
1089
|
+
try:
|
|
1090
|
+
active_catalogs = catalog.get_active_catalogs()
|
|
1091
|
+
except PresetValidationError as e:
|
|
1092
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1093
|
+
raise typer.Exit(1)
|
|
1094
|
+
|
|
1095
|
+
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
|
|
1096
|
+
for entry in active_catalogs:
|
|
1097
|
+
install_str = (
|
|
1098
|
+
"[green]install allowed[/green]"
|
|
1099
|
+
if entry.install_allowed
|
|
1100
|
+
else "[yellow]discovery only[/yellow]"
|
|
1101
|
+
)
|
|
1102
|
+
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
|
|
1103
|
+
if entry.description:
|
|
1104
|
+
console.print(f" {entry.description}")
|
|
1105
|
+
console.print(f" URL: {entry.url}")
|
|
1106
|
+
console.print(f" Install: {install_str}")
|
|
1107
|
+
console.print()
|
|
1108
|
+
|
|
1109
|
+
config_path = project_root / ".specify" / "preset-catalogs.yml"
|
|
1110
|
+
user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
|
|
1111
|
+
if os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
|
|
1112
|
+
console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]")
|
|
1113
|
+
else:
|
|
1114
|
+
try:
|
|
1115
|
+
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
|
|
1116
|
+
except PresetValidationError:
|
|
1117
|
+
proj_loaded = False
|
|
1118
|
+
if proj_loaded:
|
|
1119
|
+
console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]")
|
|
1120
|
+
else:
|
|
1121
|
+
try:
|
|
1122
|
+
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
|
|
1123
|
+
except PresetValidationError:
|
|
1124
|
+
user_loaded = False
|
|
1125
|
+
if user_loaded:
|
|
1126
|
+
console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]")
|
|
1127
|
+
else:
|
|
1128
|
+
console.print("[dim]Using built-in default catalog stack.[/dim]")
|
|
1129
|
+
console.print(
|
|
1130
|
+
"[dim]Add .specify/preset-catalogs.yml to customize.[/dim]"
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
@preset_catalog_app.command("add")
|
|
1135
|
+
def preset_catalog_add(
|
|
1136
|
+
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
|
|
1137
|
+
name: str = typer.Option(..., "--name", help="Catalog name"),
|
|
1138
|
+
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
|
|
1139
|
+
install_allowed: bool = typer.Option(
|
|
1140
|
+
False, "--install-allowed/--no-install-allowed",
|
|
1141
|
+
help="Allow presets from this catalog to be installed",
|
|
1142
|
+
),
|
|
1143
|
+
description: str = typer.Option("", "--description", help="Description of the catalog"),
|
|
1144
|
+
):
|
|
1145
|
+
"""Add a catalog to .specify/preset-catalogs.yml."""
|
|
1146
|
+
from .presets import PresetCatalog, PresetValidationError
|
|
1147
|
+
|
|
1148
|
+
project_root = _require_specify_project()
|
|
1149
|
+
specify_dir = project_root / ".specify"
|
|
1150
|
+
|
|
1151
|
+
# Validate URL
|
|
1152
|
+
tmp_catalog = PresetCatalog(project_root)
|
|
1153
|
+
try:
|
|
1154
|
+
tmp_catalog._validate_catalog_url(url)
|
|
1155
|
+
except PresetValidationError as e:
|
|
1156
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1157
|
+
raise typer.Exit(1)
|
|
1158
|
+
|
|
1159
|
+
config_path = specify_dir / "preset-catalogs.yml"
|
|
1160
|
+
|
|
1161
|
+
# Load existing config
|
|
1162
|
+
if config_path.exists():
|
|
1163
|
+
try:
|
|
1164
|
+
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
1165
|
+
except Exception as e:
|
|
1166
|
+
config_label = _display_project_path(project_root, config_path)
|
|
1167
|
+
console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
|
|
1168
|
+
raise typer.Exit(1)
|
|
1169
|
+
else:
|
|
1170
|
+
config = {}
|
|
1171
|
+
|
|
1172
|
+
catalogs = config.get("catalogs", [])
|
|
1173
|
+
if not isinstance(catalogs, list):
|
|
1174
|
+
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
|
1175
|
+
raise typer.Exit(1)
|
|
1176
|
+
|
|
1177
|
+
# Check for duplicate name
|
|
1178
|
+
for existing in catalogs:
|
|
1179
|
+
if isinstance(existing, dict) and existing.get("name") == name:
|
|
1180
|
+
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
|
|
1181
|
+
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
|
|
1182
|
+
raise typer.Exit(1)
|
|
1183
|
+
|
|
1184
|
+
catalogs.append({
|
|
1185
|
+
"name": name,
|
|
1186
|
+
"url": url,
|
|
1187
|
+
"priority": priority,
|
|
1188
|
+
"install_allowed": install_allowed,
|
|
1189
|
+
"description": description,
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
config["catalogs"] = catalogs
|
|
1193
|
+
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
1194
|
+
|
|
1195
|
+
install_label = "install allowed" if install_allowed else "discovery only"
|
|
1196
|
+
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
|
1197
|
+
console.print(f" URL: {url}")
|
|
1198
|
+
console.print(f" Priority: {priority}")
|
|
1199
|
+
console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
@preset_catalog_app.command("remove")
|
|
1203
|
+
def preset_catalog_remove(
|
|
1204
|
+
name: str = typer.Argument(help="Catalog name to remove"),
|
|
1205
|
+
):
|
|
1206
|
+
"""Remove a catalog from .specify/preset-catalogs.yml."""
|
|
1207
|
+
project_root = _require_specify_project()
|
|
1208
|
+
specify_dir = project_root / ".specify"
|
|
1209
|
+
|
|
1210
|
+
config_path = specify_dir / "preset-catalogs.yml"
|
|
1211
|
+
if not config_path.exists():
|
|
1212
|
+
console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.")
|
|
1213
|
+
raise typer.Exit(1)
|
|
1214
|
+
|
|
1215
|
+
try:
|
|
1216
|
+
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
1217
|
+
except Exception:
|
|
1218
|
+
console.print("[red]Error:[/red] Failed to read preset catalog config.")
|
|
1219
|
+
raise typer.Exit(1)
|
|
1220
|
+
|
|
1221
|
+
catalogs = config.get("catalogs", [])
|
|
1222
|
+
if not isinstance(catalogs, list):
|
|
1223
|
+
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
|
1224
|
+
raise typer.Exit(1)
|
|
1225
|
+
original_count = len(catalogs)
|
|
1226
|
+
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
|
|
1227
|
+
|
|
1228
|
+
if len(catalogs) == original_count:
|
|
1229
|
+
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
|
|
1230
|
+
raise typer.Exit(1)
|
|
1231
|
+
|
|
1232
|
+
config["catalogs"] = catalogs
|
|
1233
|
+
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
1234
|
+
|
|
1235
|
+
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
|
1236
|
+
if not catalogs:
|
|
1237
|
+
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
# ===== Extension Commands =====
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _resolve_installed_extension(
|
|
1244
|
+
argument: str,
|
|
1245
|
+
installed_extensions: list,
|
|
1246
|
+
command_name: str = "command",
|
|
1247
|
+
allow_not_found: bool = False,
|
|
1248
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
1249
|
+
"""Resolve an extension argument (ID or display name) to an installed extension.
|
|
1250
|
+
|
|
1251
|
+
Args:
|
|
1252
|
+
argument: Extension ID or display name provided by user
|
|
1253
|
+
installed_extensions: List of installed extension dicts from manager.list_installed()
|
|
1254
|
+
command_name: Name of the command for error messages (e.g., "enable", "disable")
|
|
1255
|
+
allow_not_found: If True, return (None, None) when not found instead of raising
|
|
1256
|
+
|
|
1257
|
+
Returns:
|
|
1258
|
+
Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found
|
|
1259
|
+
|
|
1260
|
+
Raises:
|
|
1261
|
+
typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous
|
|
1262
|
+
"""
|
|
1263
|
+
from rich.table import Table
|
|
1264
|
+
|
|
1265
|
+
# First, try exact ID match
|
|
1266
|
+
for ext in installed_extensions:
|
|
1267
|
+
if ext["id"] == argument:
|
|
1268
|
+
return (ext["id"], ext["name"])
|
|
1269
|
+
|
|
1270
|
+
# If not found by ID, try display name match
|
|
1271
|
+
name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()]
|
|
1272
|
+
|
|
1273
|
+
if len(name_matches) == 1:
|
|
1274
|
+
# Unique display-name match
|
|
1275
|
+
return (name_matches[0]["id"], name_matches[0]["name"])
|
|
1276
|
+
elif len(name_matches) > 1:
|
|
1277
|
+
# Ambiguous display-name match
|
|
1278
|
+
console.print(
|
|
1279
|
+
f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
|
|
1280
|
+
"Multiple installed extensions share this name:"
|
|
1281
|
+
)
|
|
1282
|
+
table = Table(title="Matching extensions")
|
|
1283
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
1284
|
+
table.add_column("Name", style="white")
|
|
1285
|
+
table.add_column("Version", style="green")
|
|
1286
|
+
for ext in name_matches:
|
|
1287
|
+
table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", "")))
|
|
1288
|
+
console.print(table)
|
|
1289
|
+
console.print("\nPlease rerun using the extension ID:")
|
|
1290
|
+
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
|
|
1291
|
+
raise typer.Exit(1)
|
|
1292
|
+
else:
|
|
1293
|
+
# No match by ID or display name
|
|
1294
|
+
if allow_not_found:
|
|
1295
|
+
return (None, None)
|
|
1296
|
+
console.print(f"[red]Error:[/red] Extension '{argument}' is not installed")
|
|
1297
|
+
raise typer.Exit(1)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _resolve_catalog_extension(
|
|
1301
|
+
argument: str,
|
|
1302
|
+
catalog,
|
|
1303
|
+
command_name: str = "info",
|
|
1304
|
+
) -> tuple[Optional[dict], Optional[Exception]]:
|
|
1305
|
+
"""Resolve an extension argument (ID or display name) from the catalog.
|
|
1306
|
+
|
|
1307
|
+
Args:
|
|
1308
|
+
argument: Extension ID or display name provided by user
|
|
1309
|
+
catalog: ExtensionCatalog instance
|
|
1310
|
+
command_name: Name of the command for error messages
|
|
1311
|
+
|
|
1312
|
+
Returns:
|
|
1313
|
+
Tuple of (extension_info, catalog_error)
|
|
1314
|
+
- If found: (ext_info_dict, None)
|
|
1315
|
+
- If catalog error: (None, error)
|
|
1316
|
+
- If not found: (None, None)
|
|
1317
|
+
"""
|
|
1318
|
+
from rich.table import Table
|
|
1319
|
+
from .extensions import ExtensionError
|
|
1320
|
+
|
|
1321
|
+
try:
|
|
1322
|
+
# First try by ID
|
|
1323
|
+
ext_info = catalog.get_extension_info(argument)
|
|
1324
|
+
if ext_info:
|
|
1325
|
+
return (ext_info, None)
|
|
1326
|
+
|
|
1327
|
+
# Try by display name - search using argument as query, then filter for exact match
|
|
1328
|
+
search_results = catalog.search(query=argument)
|
|
1329
|
+
name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()]
|
|
1330
|
+
|
|
1331
|
+
if len(name_matches) == 1:
|
|
1332
|
+
return (name_matches[0], None)
|
|
1333
|
+
elif len(name_matches) > 1:
|
|
1334
|
+
# Ambiguous display-name match in catalog
|
|
1335
|
+
console.print(
|
|
1336
|
+
f"[red]Error:[/red] Extension name '{argument}' is ambiguous. "
|
|
1337
|
+
"Multiple catalog extensions share this name:"
|
|
1338
|
+
)
|
|
1339
|
+
table = Table(title="Matching extensions")
|
|
1340
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
1341
|
+
table.add_column("Name", style="white")
|
|
1342
|
+
table.add_column("Version", style="green")
|
|
1343
|
+
table.add_column("Catalog", style="dim")
|
|
1344
|
+
for ext in name_matches:
|
|
1345
|
+
table.add_row(
|
|
1346
|
+
ext.get("id", ""),
|
|
1347
|
+
ext.get("name", ""),
|
|
1348
|
+
str(ext.get("version", "")),
|
|
1349
|
+
ext.get("_catalog_name", ""),
|
|
1350
|
+
)
|
|
1351
|
+
console.print(table)
|
|
1352
|
+
console.print("\nPlease rerun using the extension ID:")
|
|
1353
|
+
console.print(f" [bold]specify extension {command_name} <extension-id>[/bold]")
|
|
1354
|
+
raise typer.Exit(1)
|
|
1355
|
+
|
|
1356
|
+
# Not found
|
|
1357
|
+
return (None, None)
|
|
1358
|
+
|
|
1359
|
+
except ExtensionError as e:
|
|
1360
|
+
return (None, e)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
@extension_app.command("list")
|
|
1364
|
+
def extension_list(
|
|
1365
|
+
available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"),
|
|
1366
|
+
all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"),
|
|
1367
|
+
):
|
|
1368
|
+
"""List installed extensions."""
|
|
1369
|
+
from .extensions import ExtensionManager
|
|
1370
|
+
|
|
1371
|
+
project_root = _require_specify_project()
|
|
1372
|
+
manager = ExtensionManager(project_root)
|
|
1373
|
+
installed = manager.list_installed()
|
|
1374
|
+
|
|
1375
|
+
if not installed and not (available or all_extensions):
|
|
1376
|
+
console.print("[yellow]No extensions installed.[/yellow]")
|
|
1377
|
+
console.print("\nInstall an extension with:")
|
|
1378
|
+
console.print(" specify extension add <extension-name>")
|
|
1379
|
+
return
|
|
1380
|
+
|
|
1381
|
+
if installed:
|
|
1382
|
+
console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n")
|
|
1383
|
+
|
|
1384
|
+
for ext in installed:
|
|
1385
|
+
status_icon = "✓" if ext["enabled"] else "✗"
|
|
1386
|
+
status_color = "green" if ext["enabled"] else "red"
|
|
1387
|
+
|
|
1388
|
+
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
|
|
1389
|
+
console.print(f" [dim]{ext['id']}[/dim]")
|
|
1390
|
+
console.print(f" {ext['description']}")
|
|
1391
|
+
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
|
|
1392
|
+
console.print()
|
|
1393
|
+
|
|
1394
|
+
if available or all_extensions:
|
|
1395
|
+
console.print("\nInstall an extension:")
|
|
1396
|
+
console.print(" [cyan]specify extension add <name>[/cyan]")
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
@catalog_app.command("list")
|
|
1400
|
+
def catalog_list():
|
|
1401
|
+
"""List all active extension catalogs."""
|
|
1402
|
+
from .extensions import ExtensionCatalog, ValidationError
|
|
1403
|
+
|
|
1404
|
+
project_root = _require_specify_project()
|
|
1405
|
+
catalog = ExtensionCatalog(project_root)
|
|
1406
|
+
|
|
1407
|
+
try:
|
|
1408
|
+
active_catalogs = catalog.get_active_catalogs()
|
|
1409
|
+
except ValidationError as e:
|
|
1410
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1411
|
+
raise typer.Exit(1)
|
|
1412
|
+
|
|
1413
|
+
console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n")
|
|
1414
|
+
for entry in active_catalogs:
|
|
1415
|
+
install_str = (
|
|
1416
|
+
"[green]install allowed[/green]"
|
|
1417
|
+
if entry.install_allowed
|
|
1418
|
+
else "[yellow]discovery only[/yellow]"
|
|
1419
|
+
)
|
|
1420
|
+
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
|
|
1421
|
+
if entry.description:
|
|
1422
|
+
console.print(f" {entry.description}")
|
|
1423
|
+
console.print(f" URL: {entry.url}")
|
|
1424
|
+
console.print(f" Install: {install_str}")
|
|
1425
|
+
console.print()
|
|
1426
|
+
|
|
1427
|
+
config_path = project_root / ".specify" / "extension-catalogs.yml"
|
|
1428
|
+
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
|
|
1429
|
+
if os.environ.get("SPECKIT_CATALOG_URL"):
|
|
1430
|
+
console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
|
|
1431
|
+
else:
|
|
1432
|
+
try:
|
|
1433
|
+
proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None
|
|
1434
|
+
except ValidationError:
|
|
1435
|
+
proj_loaded = False
|
|
1436
|
+
if proj_loaded:
|
|
1437
|
+
console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]")
|
|
1438
|
+
else:
|
|
1439
|
+
try:
|
|
1440
|
+
user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None
|
|
1441
|
+
except ValidationError:
|
|
1442
|
+
user_loaded = False
|
|
1443
|
+
if user_loaded:
|
|
1444
|
+
console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]")
|
|
1445
|
+
else:
|
|
1446
|
+
console.print("[dim]Using built-in default catalog stack.[/dim]")
|
|
1447
|
+
console.print(
|
|
1448
|
+
"[dim]Add .specify/extension-catalogs.yml to customize.[/dim]"
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
@catalog_app.command("add")
|
|
1453
|
+
def catalog_add(
|
|
1454
|
+
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
|
|
1455
|
+
name: str = typer.Option(..., "--name", help="Catalog name"),
|
|
1456
|
+
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
|
|
1457
|
+
install_allowed: bool = typer.Option(
|
|
1458
|
+
False, "--install-allowed/--no-install-allowed",
|
|
1459
|
+
help="Allow extensions from this catalog to be installed",
|
|
1460
|
+
),
|
|
1461
|
+
description: str = typer.Option("", "--description", help="Description of the catalog"),
|
|
1462
|
+
):
|
|
1463
|
+
"""Add a catalog to .specify/extension-catalogs.yml."""
|
|
1464
|
+
from .extensions import ExtensionCatalog, ValidationError
|
|
1465
|
+
|
|
1466
|
+
project_root = _require_specify_project()
|
|
1467
|
+
specify_dir = project_root / ".specify"
|
|
1468
|
+
|
|
1469
|
+
# Validate URL
|
|
1470
|
+
tmp_catalog = ExtensionCatalog(project_root)
|
|
1471
|
+
try:
|
|
1472
|
+
tmp_catalog._validate_catalog_url(url)
|
|
1473
|
+
except ValidationError as e:
|
|
1474
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1475
|
+
raise typer.Exit(1)
|
|
1476
|
+
|
|
1477
|
+
config_path = specify_dir / "extension-catalogs.yml"
|
|
1478
|
+
|
|
1479
|
+
# Load existing config
|
|
1480
|
+
if config_path.exists():
|
|
1481
|
+
try:
|
|
1482
|
+
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
1483
|
+
except Exception as e:
|
|
1484
|
+
config_label = _display_project_path(project_root, config_path)
|
|
1485
|
+
console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
|
|
1486
|
+
raise typer.Exit(1)
|
|
1487
|
+
else:
|
|
1488
|
+
config = {}
|
|
1489
|
+
|
|
1490
|
+
catalogs = config.get("catalogs", [])
|
|
1491
|
+
if not isinstance(catalogs, list):
|
|
1492
|
+
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
|
1493
|
+
raise typer.Exit(1)
|
|
1494
|
+
|
|
1495
|
+
# Check for duplicate name
|
|
1496
|
+
for existing in catalogs:
|
|
1497
|
+
if isinstance(existing, dict) and existing.get("name") == name:
|
|
1498
|
+
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
|
|
1499
|
+
console.print("Use 'specify extension catalog remove' first, or choose a different name.")
|
|
1500
|
+
raise typer.Exit(1)
|
|
1501
|
+
|
|
1502
|
+
catalogs.append({
|
|
1503
|
+
"name": name,
|
|
1504
|
+
"url": url,
|
|
1505
|
+
"priority": priority,
|
|
1506
|
+
"install_allowed": install_allowed,
|
|
1507
|
+
"description": description,
|
|
1508
|
+
})
|
|
1509
|
+
|
|
1510
|
+
config["catalogs"] = catalogs
|
|
1511
|
+
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
1512
|
+
|
|
1513
|
+
install_label = "install allowed" if install_allowed else "discovery only"
|
|
1514
|
+
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
|
1515
|
+
console.print(f" URL: {url}")
|
|
1516
|
+
console.print(f" Priority: {priority}")
|
|
1517
|
+
console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
@catalog_app.command("remove")
|
|
1521
|
+
def catalog_remove(
|
|
1522
|
+
name: str = typer.Argument(help="Catalog name to remove"),
|
|
1523
|
+
):
|
|
1524
|
+
"""Remove a catalog from .specify/extension-catalogs.yml."""
|
|
1525
|
+
project_root = _require_specify_project()
|
|
1526
|
+
specify_dir = project_root / ".specify"
|
|
1527
|
+
|
|
1528
|
+
config_path = specify_dir / "extension-catalogs.yml"
|
|
1529
|
+
if not config_path.exists():
|
|
1530
|
+
console.print("[red]Error:[/red] No catalog config found. Nothing to remove.")
|
|
1531
|
+
raise typer.Exit(1)
|
|
1532
|
+
|
|
1533
|
+
try:
|
|
1534
|
+
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
1535
|
+
except Exception:
|
|
1536
|
+
console.print("[red]Error:[/red] Failed to read catalog config.")
|
|
1537
|
+
raise typer.Exit(1)
|
|
1538
|
+
|
|
1539
|
+
catalogs = config.get("catalogs", [])
|
|
1540
|
+
if not isinstance(catalogs, list):
|
|
1541
|
+
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
|
1542
|
+
raise typer.Exit(1)
|
|
1543
|
+
original_count = len(catalogs)
|
|
1544
|
+
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
|
|
1545
|
+
|
|
1546
|
+
if len(catalogs) == original_count:
|
|
1547
|
+
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
|
|
1548
|
+
raise typer.Exit(1)
|
|
1549
|
+
|
|
1550
|
+
config["catalogs"] = catalogs
|
|
1551
|
+
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
1552
|
+
|
|
1553
|
+
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
|
1554
|
+
if not catalogs:
|
|
1555
|
+
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
@extension_app.command("add")
|
|
1559
|
+
def extension_add(
|
|
1560
|
+
extension: str = typer.Argument(help="Extension name or path"),
|
|
1561
|
+
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
|
|
1562
|
+
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
|
|
1563
|
+
force: bool = typer.Option(False, "--force", help="Overwrite if already installed"),
|
|
1564
|
+
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
|
1565
|
+
):
|
|
1566
|
+
"""Install an extension."""
|
|
1567
|
+
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND
|
|
1568
|
+
|
|
1569
|
+
project_root = _require_specify_project()
|
|
1570
|
+
# Validate priority
|
|
1571
|
+
if priority < 1:
|
|
1572
|
+
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
1573
|
+
raise typer.Exit(1)
|
|
1574
|
+
|
|
1575
|
+
manager = ExtensionManager(project_root)
|
|
1576
|
+
speckit_version = get_speckit_version()
|
|
1577
|
+
|
|
1578
|
+
if force:
|
|
1579
|
+
console.print("[yellow]--force:[/yellow] Will overwrite if already installed")
|
|
1580
|
+
|
|
1581
|
+
# Prompt for URL-based installs BEFORE the spinner so the user can
|
|
1582
|
+
# actually see and respond to the confirmation (the Rich status
|
|
1583
|
+
# spinner overwrites the typer.confirm prompt line, making it appear
|
|
1584
|
+
# as though the command is hung).
|
|
1585
|
+
# Guard with ``not dev`` so that --dev + --from does not show a
|
|
1586
|
+
# confusing confirmation for a URL that will be ignored.
|
|
1587
|
+
if from_url and not dev:
|
|
1588
|
+
from urllib.parse import urlparse
|
|
1589
|
+
from rich.markup import escape as _escape_markup
|
|
1590
|
+
|
|
1591
|
+
parsed = urlparse(from_url)
|
|
1592
|
+
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
1593
|
+
|
|
1594
|
+
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
|
1595
|
+
console.print("[red]Error:[/red] URL must use HTTPS for security.")
|
|
1596
|
+
console.print("HTTP is only allowed for localhost URLs.")
|
|
1597
|
+
raise typer.Exit(1)
|
|
1598
|
+
|
|
1599
|
+
safe_url = _escape_markup(from_url)
|
|
1600
|
+
|
|
1601
|
+
# Warn about untrusted sources — default-deny confirmation
|
|
1602
|
+
console.print()
|
|
1603
|
+
console.print(Panel(
|
|
1604
|
+
f"[bold]You are installing an extension from an external URL that is not\n"
|
|
1605
|
+
f"listed in any of your configured extension catalogs.[/bold]\n\n"
|
|
1606
|
+
f"URL: {safe_url}\n\n"
|
|
1607
|
+
f"Only install extensions from sources you trust.",
|
|
1608
|
+
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
|
|
1609
|
+
border_style="yellow",
|
|
1610
|
+
padding=(1, 2),
|
|
1611
|
+
))
|
|
1612
|
+
console.print()
|
|
1613
|
+
confirm = typer.confirm("Continue with installation?", default=False)
|
|
1614
|
+
if not confirm:
|
|
1615
|
+
console.print("Cancelled")
|
|
1616
|
+
raise typer.Exit(0)
|
|
1617
|
+
|
|
1618
|
+
try:
|
|
1619
|
+
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
|
|
1620
|
+
if dev:
|
|
1621
|
+
# Install from local directory
|
|
1622
|
+
source_path = Path(extension).expanduser().resolve()
|
|
1623
|
+
if not source_path.exists():
|
|
1624
|
+
console.print(f"[red]Error:[/red] Directory not found: {source_path}")
|
|
1625
|
+
raise typer.Exit(1)
|
|
1626
|
+
|
|
1627
|
+
if not (source_path / "extension.yml").exists():
|
|
1628
|
+
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
|
1629
|
+
raise typer.Exit(1)
|
|
1630
|
+
|
|
1631
|
+
if force:
|
|
1632
|
+
console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...")
|
|
1633
|
+
|
|
1634
|
+
manifest = manager.install_from_directory(
|
|
1635
|
+
source_path,
|
|
1636
|
+
speckit_version,
|
|
1637
|
+
priority=priority,
|
|
1638
|
+
link_commands=True,
|
|
1639
|
+
force=force
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
elif from_url:
|
|
1643
|
+
# Install from URL (ZIP file)
|
|
1644
|
+
import urllib.error
|
|
1645
|
+
|
|
1646
|
+
console.print(f"Downloading from {safe_url}...")
|
|
1647
|
+
|
|
1648
|
+
# Download ZIP to temp location
|
|
1649
|
+
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
|
1650
|
+
download_dir.mkdir(parents=True, exist_ok=True)
|
|
1651
|
+
zip_path = download_dir / f"{extension}-url-download.zip"
|
|
1652
|
+
|
|
1653
|
+
try:
|
|
1654
|
+
from specify_cli.authentication.http import open_url as _open_url
|
|
1655
|
+
|
|
1656
|
+
with _open_url(from_url, timeout=60) as response:
|
|
1657
|
+
zip_data = response.read()
|
|
1658
|
+
zip_path.write_bytes(zip_data)
|
|
1659
|
+
|
|
1660
|
+
# Install from downloaded ZIP
|
|
1661
|
+
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
|
1662
|
+
except urllib.error.URLError as e:
|
|
1663
|
+
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
|
|
1664
|
+
raise typer.Exit(1)
|
|
1665
|
+
finally:
|
|
1666
|
+
# Clean up downloaded ZIP
|
|
1667
|
+
if zip_path.exists():
|
|
1668
|
+
zip_path.unlink()
|
|
1669
|
+
|
|
1670
|
+
else:
|
|
1671
|
+
# Try bundled extensions first (shipped with spec-kit)
|
|
1672
|
+
bundled_path = _locate_bundled_extension(extension)
|
|
1673
|
+
if bundled_path is not None:
|
|
1674
|
+
manifest = manager.install_from_directory(
|
|
1675
|
+
bundled_path, speckit_version, priority=priority, force=force
|
|
1676
|
+
)
|
|
1677
|
+
else:
|
|
1678
|
+
# Install from catalog (also resolves display names to IDs)
|
|
1679
|
+
catalog = ExtensionCatalog(project_root)
|
|
1680
|
+
|
|
1681
|
+
# Check if extension exists in catalog (supports both ID and display name)
|
|
1682
|
+
ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add")
|
|
1683
|
+
if catalog_error:
|
|
1684
|
+
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
|
|
1685
|
+
raise typer.Exit(1)
|
|
1686
|
+
if not ext_info:
|
|
1687
|
+
console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog")
|
|
1688
|
+
console.print("\nSearch available extensions:")
|
|
1689
|
+
console.print(" specify extension search")
|
|
1690
|
+
raise typer.Exit(1)
|
|
1691
|
+
|
|
1692
|
+
# If catalog resolved a display name to an ID, check bundled again
|
|
1693
|
+
resolved_id = ext_info['id']
|
|
1694
|
+
if resolved_id != extension:
|
|
1695
|
+
bundled_path = _locate_bundled_extension(resolved_id)
|
|
1696
|
+
if bundled_path is not None:
|
|
1697
|
+
manifest = manager.install_from_directory(
|
|
1698
|
+
bundled_path, speckit_version, priority=priority, force=force
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
if bundled_path is None:
|
|
1702
|
+
# Bundled extensions without a download URL must come from the local package
|
|
1703
|
+
if ext_info.get("bundled") and not ext_info.get("download_url"):
|
|
1704
|
+
console.print(
|
|
1705
|
+
f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
|
|
1706
|
+
f"but could not be found in the installed package."
|
|
1707
|
+
)
|
|
1708
|
+
console.print(
|
|
1709
|
+
"\nThis usually means the spec-kit installation is incomplete or corrupted."
|
|
1710
|
+
)
|
|
1711
|
+
console.print("Try reinstalling spec-kit:")
|
|
1712
|
+
console.print(f" {REINSTALL_COMMAND}")
|
|
1713
|
+
raise typer.Exit(1)
|
|
1714
|
+
|
|
1715
|
+
# Enforce install_allowed policy
|
|
1716
|
+
if not ext_info.get("_install_allowed", True):
|
|
1717
|
+
catalog_name = ext_info.get("_catalog_name", "community")
|
|
1718
|
+
console.print(
|
|
1719
|
+
f"[red]Error:[/red] '{extension}' is available in the "
|
|
1720
|
+
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
|
|
1721
|
+
)
|
|
1722
|
+
console.print(
|
|
1723
|
+
f"\nTo enable installation, add '{extension}' to an approved catalog "
|
|
1724
|
+
f"(install_allowed: true) in .specify/extension-catalogs.yml."
|
|
1725
|
+
)
|
|
1726
|
+
raise typer.Exit(1)
|
|
1727
|
+
|
|
1728
|
+
# Download extension ZIP (use resolved ID, not original argument which may be display name)
|
|
1729
|
+
extension_id = ext_info['id']
|
|
1730
|
+
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
|
|
1731
|
+
zip_path = catalog.download_extension(extension_id)
|
|
1732
|
+
|
|
1733
|
+
try:
|
|
1734
|
+
# Install from downloaded ZIP
|
|
1735
|
+
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
|
1736
|
+
finally:
|
|
1737
|
+
# Clean up downloaded ZIP
|
|
1738
|
+
if zip_path.exists():
|
|
1739
|
+
zip_path.unlink()
|
|
1740
|
+
|
|
1741
|
+
console.print("\n[green]✓[/green] Extension installed successfully!")
|
|
1742
|
+
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
|
|
1743
|
+
console.print(f" {manifest.description}")
|
|
1744
|
+
|
|
1745
|
+
for warning in manifest.warnings:
|
|
1746
|
+
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")
|
|
1747
|
+
|
|
1748
|
+
is_cline = load_init_options(project_root).get("ai") == "cline"
|
|
1749
|
+
|
|
1750
|
+
if is_cline:
|
|
1751
|
+
from specify_cli.integrations.cline import format_cline_command_name
|
|
1752
|
+
|
|
1753
|
+
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
|
1754
|
+
for cmd in manifest.commands:
|
|
1755
|
+
cmd_name = cmd['name']
|
|
1756
|
+
if is_cline:
|
|
1757
|
+
cmd_name = format_cline_command_name(cmd_name)
|
|
1758
|
+
console.print(f" • {cmd_name} - {cmd.get('description', '')}")
|
|
1759
|
+
|
|
1760
|
+
# Report agent skills registration
|
|
1761
|
+
reg_meta = manager.registry.get(manifest.id)
|
|
1762
|
+
reg_skills = reg_meta.get("registered_skills", []) if reg_meta else []
|
|
1763
|
+
# Normalize to guard against corrupted registry entries
|
|
1764
|
+
if not isinstance(reg_skills, list):
|
|
1765
|
+
reg_skills = []
|
|
1766
|
+
if reg_skills:
|
|
1767
|
+
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
|
|
1768
|
+
|
|
1769
|
+
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
|
|
1770
|
+
console.print(f" Check: .specify/extensions/{manifest.id}/")
|
|
1771
|
+
|
|
1772
|
+
except ValidationError as e:
|
|
1773
|
+
console.print(f"\n[red]Validation Error:[/red] {e}")
|
|
1774
|
+
raise typer.Exit(1)
|
|
1775
|
+
except CompatibilityError as e:
|
|
1776
|
+
console.print(f"\n[red]Compatibility Error:[/red] {e}")
|
|
1777
|
+
raise typer.Exit(1)
|
|
1778
|
+
except ExtensionError as e:
|
|
1779
|
+
console.print(f"\n[red]Error:[/red] {e}")
|
|
1780
|
+
raise typer.Exit(1)
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
@extension_app.command("remove")
|
|
1784
|
+
def extension_remove(
|
|
1785
|
+
extension: str = typer.Argument(help="Extension ID or name to remove"),
|
|
1786
|
+
keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"),
|
|
1787
|
+
force: bool = typer.Option(False, "--force", help="Skip confirmation"),
|
|
1788
|
+
):
|
|
1789
|
+
"""Uninstall an extension."""
|
|
1790
|
+
from .extensions import ExtensionManager
|
|
1791
|
+
|
|
1792
|
+
project_root = _require_specify_project()
|
|
1793
|
+
manager = ExtensionManager(project_root)
|
|
1794
|
+
|
|
1795
|
+
# Resolve extension ID from argument (handles ambiguous names)
|
|
1796
|
+
installed = manager.list_installed()
|
|
1797
|
+
extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
|
|
1798
|
+
|
|
1799
|
+
# Get extension info for command and skill counts
|
|
1800
|
+
ext_manifest = manager.get_extension(extension_id)
|
|
1801
|
+
reg_meta = manager.registry.get(extension_id)
|
|
1802
|
+
# Derive cmd_count from the registry's registered_commands (includes aliases)
|
|
1803
|
+
# rather than from the manifest (primary commands only). Use max() across
|
|
1804
|
+
# agents to get the per-agent count; sum() would double-count since users
|
|
1805
|
+
# think in logical commands, not per-agent file counts.
|
|
1806
|
+
# Use get() without a default so we can distinguish "key missing" (fall back
|
|
1807
|
+
# to manifest) from "key present but empty dict" (zero commands registered).
|
|
1808
|
+
registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None
|
|
1809
|
+
if isinstance(registered_commands, dict):
|
|
1810
|
+
cmd_count = max(
|
|
1811
|
+
(len(v) for v in registered_commands.values() if isinstance(v, list)),
|
|
1812
|
+
default=0,
|
|
1813
|
+
)
|
|
1814
|
+
else:
|
|
1815
|
+
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
|
|
1816
|
+
raw_skills = reg_meta.get("registered_skills") if reg_meta else None
|
|
1817
|
+
skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0
|
|
1818
|
+
|
|
1819
|
+
# Confirm removal
|
|
1820
|
+
if not force:
|
|
1821
|
+
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
|
1822
|
+
console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent")
|
|
1823
|
+
if skill_count:
|
|
1824
|
+
console.print(f" • {skill_count} agent skill(s)")
|
|
1825
|
+
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")
|
|
1826
|
+
if not keep_config:
|
|
1827
|
+
console.print(" • Config files (will be backed up)")
|
|
1828
|
+
console.print()
|
|
1829
|
+
|
|
1830
|
+
confirm = typer.confirm("Continue?")
|
|
1831
|
+
if not confirm:
|
|
1832
|
+
console.print("Cancelled")
|
|
1833
|
+
raise typer.Exit(0)
|
|
1834
|
+
|
|
1835
|
+
# Remove extension
|
|
1836
|
+
success = manager.remove(extension_id, keep_config=keep_config)
|
|
1837
|
+
|
|
1838
|
+
if success:
|
|
1839
|
+
console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully")
|
|
1840
|
+
if keep_config:
|
|
1841
|
+
console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/")
|
|
1842
|
+
else:
|
|
1843
|
+
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/")
|
|
1844
|
+
console.print(f"\nTo reinstall: specify extension add {extension_id}")
|
|
1845
|
+
else:
|
|
1846
|
+
console.print("[red]Error:[/red] Failed to remove extension")
|
|
1847
|
+
raise typer.Exit(1)
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
@extension_app.command("search")
|
|
1851
|
+
def extension_search(
|
|
1852
|
+
query: str = typer.Argument(None, help="Search query (optional)"),
|
|
1853
|
+
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
|
|
1854
|
+
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
|
|
1855
|
+
verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"),
|
|
1856
|
+
):
|
|
1857
|
+
"""Search for available extensions in catalog."""
|
|
1858
|
+
from .extensions import ExtensionCatalog, ExtensionError
|
|
1859
|
+
|
|
1860
|
+
project_root = _require_specify_project()
|
|
1861
|
+
catalog = ExtensionCatalog(project_root)
|
|
1862
|
+
|
|
1863
|
+
try:
|
|
1864
|
+
console.print("🔍 Searching extension catalog...")
|
|
1865
|
+
results = catalog.search(query=query, tag=tag, author=author, verified_only=verified)
|
|
1866
|
+
|
|
1867
|
+
if not results:
|
|
1868
|
+
console.print("\n[yellow]No extensions found matching criteria[/yellow]")
|
|
1869
|
+
if query or tag or author or verified:
|
|
1870
|
+
console.print("\nTry:")
|
|
1871
|
+
console.print(" • Broader search terms")
|
|
1872
|
+
console.print(" • Remove filters")
|
|
1873
|
+
console.print(" • specify extension search (show all)")
|
|
1874
|
+
raise typer.Exit(0)
|
|
1875
|
+
|
|
1876
|
+
console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n")
|
|
1877
|
+
|
|
1878
|
+
for ext in results:
|
|
1879
|
+
# Extension header
|
|
1880
|
+
verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else ""
|
|
1881
|
+
console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}")
|
|
1882
|
+
console.print(f" {ext['description']}")
|
|
1883
|
+
|
|
1884
|
+
# Metadata
|
|
1885
|
+
console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}")
|
|
1886
|
+
if ext.get('tags'):
|
|
1887
|
+
tags_str = ", ".join(ext['tags'])
|
|
1888
|
+
console.print(f" [dim]Tags:[/dim] {tags_str}")
|
|
1889
|
+
|
|
1890
|
+
# Source catalog
|
|
1891
|
+
catalog_name = ext.get("_catalog_name", "")
|
|
1892
|
+
install_allowed = ext.get("_install_allowed", True)
|
|
1893
|
+
if catalog_name:
|
|
1894
|
+
if install_allowed:
|
|
1895
|
+
console.print(f" [dim]Catalog:[/dim] {catalog_name}")
|
|
1896
|
+
else:
|
|
1897
|
+
console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]")
|
|
1898
|
+
|
|
1899
|
+
# Stats
|
|
1900
|
+
stats = []
|
|
1901
|
+
if ext.get('downloads') is not None:
|
|
1902
|
+
stats.append(f"Downloads: {ext['downloads']:,}")
|
|
1903
|
+
if ext.get('stars') is not None:
|
|
1904
|
+
stats.append(f"Stars: {ext['stars']}")
|
|
1905
|
+
if stats:
|
|
1906
|
+
console.print(f" [dim]{' | '.join(stats)}[/dim]")
|
|
1907
|
+
|
|
1908
|
+
# Links
|
|
1909
|
+
if ext.get('repository'):
|
|
1910
|
+
console.print(f" [dim]Repository:[/dim] {ext['repository']}")
|
|
1911
|
+
|
|
1912
|
+
# Install command (show warning if not installable)
|
|
1913
|
+
if install_allowed:
|
|
1914
|
+
console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}")
|
|
1915
|
+
else:
|
|
1916
|
+
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
|
|
1917
|
+
console.print(
|
|
1918
|
+
f" Add to an approved catalog with install_allowed: true, "
|
|
1919
|
+
f"or install from a ZIP URL: specify extension add {ext['id']} --from <zip-url>"
|
|
1920
|
+
)
|
|
1921
|
+
console.print()
|
|
1922
|
+
|
|
1923
|
+
except ExtensionError as e:
|
|
1924
|
+
console.print(f"\n[red]Error:[/red] {e}")
|
|
1925
|
+
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
|
|
1926
|
+
raise typer.Exit(1)
|
|
1927
|
+
|
|
1928
|
+
|
|
1929
|
+
@extension_app.command("info")
|
|
1930
|
+
def extension_info(
|
|
1931
|
+
extension: str = typer.Argument(help="Extension ID or name"),
|
|
1932
|
+
):
|
|
1933
|
+
"""Show detailed information about an extension."""
|
|
1934
|
+
from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority
|
|
1935
|
+
|
|
1936
|
+
project_root = _require_specify_project()
|
|
1937
|
+
catalog = ExtensionCatalog(project_root)
|
|
1938
|
+
manager = ExtensionManager(project_root)
|
|
1939
|
+
installed = manager.list_installed()
|
|
1940
|
+
|
|
1941
|
+
# Try to resolve from installed extensions first (by ID or name)
|
|
1942
|
+
# Use allow_not_found=True since the extension may be catalog-only
|
|
1943
|
+
resolved_installed_id, resolved_installed_name = _resolve_installed_extension(
|
|
1944
|
+
extension, installed, "info", allow_not_found=True
|
|
1945
|
+
)
|
|
1946
|
+
|
|
1947
|
+
# Try catalog lookup (with error handling)
|
|
1948
|
+
# If we resolved an installed extension by display name, use its ID for catalog lookup
|
|
1949
|
+
# to ensure we get the correct catalog entry (not a different extension with same name)
|
|
1950
|
+
lookup_key = resolved_installed_id if resolved_installed_id else extension
|
|
1951
|
+
ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info")
|
|
1952
|
+
|
|
1953
|
+
# Case 1: Found in catalog - show full catalog info
|
|
1954
|
+
if ext_info:
|
|
1955
|
+
_print_extension_info(ext_info, manager)
|
|
1956
|
+
return
|
|
1957
|
+
|
|
1958
|
+
# Case 2: Installed locally but catalog lookup failed or not in catalog
|
|
1959
|
+
if resolved_installed_id:
|
|
1960
|
+
# Get local manifest info
|
|
1961
|
+
ext_manifest = manager.get_extension(resolved_installed_id)
|
|
1962
|
+
metadata = manager.registry.get(resolved_installed_id)
|
|
1963
|
+
metadata_is_dict = isinstance(metadata, dict)
|
|
1964
|
+
if not metadata_is_dict:
|
|
1965
|
+
console.print(
|
|
1966
|
+
"[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; "
|
|
1967
|
+
"some information may be unavailable."
|
|
1968
|
+
)
|
|
1969
|
+
version = metadata.get("version", "unknown") if metadata_is_dict else "unknown"
|
|
1970
|
+
|
|
1971
|
+
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})")
|
|
1972
|
+
console.print(f"ID: {resolved_installed_id}")
|
|
1973
|
+
console.print()
|
|
1974
|
+
|
|
1975
|
+
if ext_manifest:
|
|
1976
|
+
console.print(f"{ext_manifest.description}")
|
|
1977
|
+
console.print()
|
|
1978
|
+
# Author is optional in extension.yml, safely retrieve it
|
|
1979
|
+
author = ext_manifest.data.get("extension", {}).get("author")
|
|
1980
|
+
if author:
|
|
1981
|
+
console.print(f"[dim]Author:[/dim] {author}")
|
|
1982
|
+
console.print()
|
|
1983
|
+
|
|
1984
|
+
if ext_manifest.commands:
|
|
1985
|
+
console.print("[bold]Commands:[/bold]")
|
|
1986
|
+
for cmd in ext_manifest.commands:
|
|
1987
|
+
console.print(f" • {cmd['name']}: {cmd.get('description', '')}")
|
|
1988
|
+
console.print()
|
|
1989
|
+
|
|
1990
|
+
# Show catalog status
|
|
1991
|
+
if catalog_error:
|
|
1992
|
+
console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}")
|
|
1993
|
+
console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]")
|
|
1994
|
+
else:
|
|
1995
|
+
console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)")
|
|
1996
|
+
|
|
1997
|
+
console.print()
|
|
1998
|
+
console.print("[green]✓ Installed[/green]")
|
|
1999
|
+
priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None)
|
|
2000
|
+
console.print(f"[dim]Priority:[/dim] {priority}")
|
|
2001
|
+
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
|
|
2002
|
+
return
|
|
2003
|
+
|
|
2004
|
+
# Case 3: Not found anywhere
|
|
2005
|
+
if catalog_error:
|
|
2006
|
+
console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}")
|
|
2007
|
+
console.print("\nTry again when online, or use the extension ID directly.")
|
|
2008
|
+
else:
|
|
2009
|
+
console.print(f"[red]Error:[/red] Extension '{extension}' not found")
|
|
2010
|
+
console.print("\nTry: specify extension search")
|
|
2011
|
+
raise typer.Exit(1)
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
def _print_extension_info(ext_info: dict, manager):
|
|
2015
|
+
"""Print formatted extension info from catalog data."""
|
|
2016
|
+
from .extensions import normalize_priority
|
|
2017
|
+
|
|
2018
|
+
# Header
|
|
2019
|
+
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
|
2020
|
+
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
|
2021
|
+
console.print(f"ID: {ext_info['id']}")
|
|
2022
|
+
console.print()
|
|
2023
|
+
|
|
2024
|
+
# Description
|
|
2025
|
+
console.print(f"{ext_info['description']}")
|
|
2026
|
+
console.print()
|
|
2027
|
+
|
|
2028
|
+
# Author and License
|
|
2029
|
+
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
|
|
2030
|
+
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
|
|
2031
|
+
|
|
2032
|
+
# Source catalog
|
|
2033
|
+
if ext_info.get("_catalog_name"):
|
|
2034
|
+
install_allowed = ext_info.get("_install_allowed", True)
|
|
2035
|
+
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
|
|
2036
|
+
console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}")
|
|
2037
|
+
console.print()
|
|
2038
|
+
|
|
2039
|
+
# Requirements
|
|
2040
|
+
if ext_info.get('requires'):
|
|
2041
|
+
console.print("[bold]Requirements:[/bold]")
|
|
2042
|
+
reqs = ext_info['requires']
|
|
2043
|
+
if reqs.get('speckit_version'):
|
|
2044
|
+
console.print(f" • Spec Kit: {reqs['speckit_version']}")
|
|
2045
|
+
if reqs.get('tools'):
|
|
2046
|
+
for tool in reqs['tools']:
|
|
2047
|
+
tool_name = tool['name']
|
|
2048
|
+
tool_version = tool.get('version', 'any')
|
|
2049
|
+
required = " (required)" if tool.get('required') else " (optional)"
|
|
2050
|
+
console.print(f" • {tool_name}: {tool_version}{required}")
|
|
2051
|
+
console.print()
|
|
2052
|
+
|
|
2053
|
+
# Provides
|
|
2054
|
+
if ext_info.get('provides'):
|
|
2055
|
+
console.print("[bold]Provides:[/bold]")
|
|
2056
|
+
provides = ext_info['provides']
|
|
2057
|
+
if provides.get('commands'):
|
|
2058
|
+
console.print(f" • Commands: {provides['commands']}")
|
|
2059
|
+
if provides.get('hooks'):
|
|
2060
|
+
console.print(f" • Hooks: {provides['hooks']}")
|
|
2061
|
+
console.print()
|
|
2062
|
+
|
|
2063
|
+
# Tags
|
|
2064
|
+
if ext_info.get('tags'):
|
|
2065
|
+
tags_str = ", ".join(ext_info['tags'])
|
|
2066
|
+
console.print(f"[bold]Tags:[/bold] {tags_str}")
|
|
2067
|
+
console.print()
|
|
2068
|
+
|
|
2069
|
+
# Statistics
|
|
2070
|
+
stats = []
|
|
2071
|
+
if ext_info.get('downloads') is not None:
|
|
2072
|
+
stats.append(f"Downloads: {ext_info['downloads']:,}")
|
|
2073
|
+
if ext_info.get('stars') is not None:
|
|
2074
|
+
stats.append(f"Stars: {ext_info['stars']}")
|
|
2075
|
+
if stats:
|
|
2076
|
+
console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}")
|
|
2077
|
+
console.print()
|
|
2078
|
+
|
|
2079
|
+
# Links
|
|
2080
|
+
console.print("[bold]Links:[/bold]")
|
|
2081
|
+
if ext_info.get('repository'):
|
|
2082
|
+
console.print(f" • Repository: {ext_info['repository']}")
|
|
2083
|
+
if ext_info.get('homepage'):
|
|
2084
|
+
console.print(f" • Homepage: {ext_info['homepage']}")
|
|
2085
|
+
if ext_info.get('documentation'):
|
|
2086
|
+
console.print(f" • Documentation: {ext_info['documentation']}")
|
|
2087
|
+
if ext_info.get('changelog'):
|
|
2088
|
+
console.print(f" • Changelog: {ext_info['changelog']}")
|
|
2089
|
+
console.print()
|
|
2090
|
+
|
|
2091
|
+
# Installation status and command
|
|
2092
|
+
is_installed = manager.registry.is_installed(ext_info['id'])
|
|
2093
|
+
install_allowed = ext_info.get("_install_allowed", True)
|
|
2094
|
+
if is_installed:
|
|
2095
|
+
console.print("[green]✓ Installed[/green]")
|
|
2096
|
+
metadata = manager.registry.get(ext_info['id'])
|
|
2097
|
+
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
|
|
2098
|
+
console.print(f"[dim]Priority:[/dim] {priority}")
|
|
2099
|
+
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
|
2100
|
+
elif install_allowed:
|
|
2101
|
+
console.print("[yellow]Not installed[/yellow]")
|
|
2102
|
+
console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}")
|
|
2103
|
+
else:
|
|
2104
|
+
catalog_name = ext_info.get("_catalog_name", "community")
|
|
2105
|
+
console.print("[yellow]Not installed[/yellow]")
|
|
2106
|
+
console.print(
|
|
2107
|
+
f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog "
|
|
2108
|
+
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
|
|
2109
|
+
f"with install_allowed: true to enable installation."
|
|
2110
|
+
)
|
|
2111
|
+
|
|
2112
|
+
|
|
2113
|
+
@extension_app.command("update")
|
|
2114
|
+
def extension_update(
|
|
2115
|
+
extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"),
|
|
2116
|
+
):
|
|
2117
|
+
"""Update extension(s) to latest version."""
|
|
2118
|
+
from .extensions import (
|
|
2119
|
+
ExtensionManager,
|
|
2120
|
+
ExtensionCatalog,
|
|
2121
|
+
ExtensionError,
|
|
2122
|
+
ValidationError,
|
|
2123
|
+
CommandRegistrar,
|
|
2124
|
+
HookExecutor,
|
|
2125
|
+
normalize_priority,
|
|
2126
|
+
)
|
|
2127
|
+
from packaging import version as pkg_version
|
|
2128
|
+
import shutil
|
|
2129
|
+
|
|
2130
|
+
project_root = _require_specify_project()
|
|
2131
|
+
manager = ExtensionManager(project_root)
|
|
2132
|
+
catalog = ExtensionCatalog(project_root)
|
|
2133
|
+
speckit_version = get_speckit_version()
|
|
2134
|
+
|
|
2135
|
+
try:
|
|
2136
|
+
# Get list of extensions to update
|
|
2137
|
+
installed = manager.list_installed()
|
|
2138
|
+
if extension:
|
|
2139
|
+
# Update specific extension - resolve ID from argument (handles ambiguous names)
|
|
2140
|
+
extension_id, _ = _resolve_installed_extension(extension, installed, "update")
|
|
2141
|
+
extensions_to_update = [extension_id]
|
|
2142
|
+
else:
|
|
2143
|
+
# Update all extensions
|
|
2144
|
+
extensions_to_update = [ext["id"] for ext in installed]
|
|
2145
|
+
|
|
2146
|
+
if not extensions_to_update:
|
|
2147
|
+
console.print("[yellow]No extensions installed[/yellow]")
|
|
2148
|
+
raise typer.Exit(0)
|
|
2149
|
+
|
|
2150
|
+
console.print("🔄 Checking for updates...\n")
|
|
2151
|
+
|
|
2152
|
+
updates_available = []
|
|
2153
|
+
|
|
2154
|
+
for ext_id in extensions_to_update:
|
|
2155
|
+
# Get installed version
|
|
2156
|
+
metadata = manager.registry.get(ext_id)
|
|
2157
|
+
if metadata is None or not isinstance(metadata, dict) or "version" not in metadata:
|
|
2158
|
+
console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)")
|
|
2159
|
+
continue
|
|
2160
|
+
try:
|
|
2161
|
+
installed_version = pkg_version.Version(metadata["version"])
|
|
2162
|
+
except pkg_version.InvalidVersion:
|
|
2163
|
+
console.print(
|
|
2164
|
+
f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)"
|
|
2165
|
+
)
|
|
2166
|
+
continue
|
|
2167
|
+
|
|
2168
|
+
# Get catalog info
|
|
2169
|
+
ext_info = catalog.get_extension_info(ext_id)
|
|
2170
|
+
if not ext_info:
|
|
2171
|
+
console.print(f"⚠ {ext_id}: Not found in catalog (skipping)")
|
|
2172
|
+
continue
|
|
2173
|
+
|
|
2174
|
+
# Check if installation is allowed from this catalog
|
|
2175
|
+
if not ext_info.get("_install_allowed", True):
|
|
2176
|
+
console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)")
|
|
2177
|
+
continue
|
|
2178
|
+
|
|
2179
|
+
try:
|
|
2180
|
+
catalog_version = pkg_version.Version(ext_info["version"])
|
|
2181
|
+
except pkg_version.InvalidVersion:
|
|
2182
|
+
console.print(
|
|
2183
|
+
f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)"
|
|
2184
|
+
)
|
|
2185
|
+
continue
|
|
2186
|
+
|
|
2187
|
+
if catalog_version > installed_version:
|
|
2188
|
+
updates_available.append(
|
|
2189
|
+
{
|
|
2190
|
+
"id": ext_id,
|
|
2191
|
+
"name": ext_info.get("name", ext_id), # Display name for status messages
|
|
2192
|
+
"installed": str(installed_version),
|
|
2193
|
+
"available": str(catalog_version),
|
|
2194
|
+
"download_url": ext_info.get("download_url"),
|
|
2195
|
+
}
|
|
2196
|
+
)
|
|
2197
|
+
else:
|
|
2198
|
+
console.print(f"✓ {ext_id}: Up to date (v{installed_version})")
|
|
2199
|
+
|
|
2200
|
+
if not updates_available:
|
|
2201
|
+
console.print("\n[green]All extensions are up to date![/green]")
|
|
2202
|
+
raise typer.Exit(0)
|
|
2203
|
+
|
|
2204
|
+
# Show available updates
|
|
2205
|
+
console.print("\n[bold]Updates available:[/bold]\n")
|
|
2206
|
+
for update in updates_available:
|
|
2207
|
+
console.print(
|
|
2208
|
+
f" • {update['id']}: {update['installed']} → {update['available']}"
|
|
2209
|
+
)
|
|
2210
|
+
|
|
2211
|
+
console.print()
|
|
2212
|
+
confirm = typer.confirm("Update these extensions?")
|
|
2213
|
+
if not confirm:
|
|
2214
|
+
console.print("Cancelled")
|
|
2215
|
+
raise typer.Exit(0)
|
|
2216
|
+
|
|
2217
|
+
# Perform updates with atomic backup/restore
|
|
2218
|
+
console.print()
|
|
2219
|
+
updated_extensions = []
|
|
2220
|
+
failed_updates = []
|
|
2221
|
+
registrar = CommandRegistrar()
|
|
2222
|
+
hook_executor = HookExecutor(project_root)
|
|
2223
|
+
from .agents import CommandRegistrar as _AgentReg # used in backup and rollback paths
|
|
2224
|
+
|
|
2225
|
+
# UNSET sentinel: backup not yet captured (exception before backup step)
|
|
2226
|
+
UNSET = object()
|
|
2227
|
+
|
|
2228
|
+
for update in updates_available:
|
|
2229
|
+
extension_id = update["id"]
|
|
2230
|
+
ext_name = update["name"] # Use display name for user-facing messages
|
|
2231
|
+
console.print(f"📦 Updating {ext_name}...")
|
|
2232
|
+
|
|
2233
|
+
# Backup paths
|
|
2234
|
+
backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
|
|
2235
|
+
backup_ext_dir = backup_base / "extension"
|
|
2236
|
+
backup_commands_dir = backup_base / "commands"
|
|
2237
|
+
backup_config_dir = backup_base / "config"
|
|
2238
|
+
|
|
2239
|
+
# Store backup state
|
|
2240
|
+
backup_registry_entry = None # None means registry entry not yet captured
|
|
2241
|
+
backup_installed = UNSET # Original installed list from extensions.yml
|
|
2242
|
+
backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured
|
|
2243
|
+
backed_up_command_files = {}
|
|
2244
|
+
|
|
2245
|
+
try:
|
|
2246
|
+
# 1. Backup registry entry (always, even if extension dir doesn't exist)
|
|
2247
|
+
backup_registry_entry = manager.registry.get(extension_id)
|
|
2248
|
+
|
|
2249
|
+
# 2. Backup extension directory
|
|
2250
|
+
extension_dir = manager.extensions_dir / extension_id
|
|
2251
|
+
if extension_dir.exists():
|
|
2252
|
+
backup_base.mkdir(parents=True, exist_ok=True)
|
|
2253
|
+
if backup_ext_dir.exists():
|
|
2254
|
+
shutil.rmtree(backup_ext_dir)
|
|
2255
|
+
shutil.copytree(extension_dir, backup_ext_dir)
|
|
2256
|
+
|
|
2257
|
+
# Backup config files separately so they can be restored
|
|
2258
|
+
# after a successful install (install_from_directory clears dest dir).
|
|
2259
|
+
config_files = list(extension_dir.glob("*-config.yml")) + list(
|
|
2260
|
+
extension_dir.glob("*-config.local.yml")
|
|
2261
|
+
)
|
|
2262
|
+
for cfg_file in config_files:
|
|
2263
|
+
backup_config_dir.mkdir(parents=True, exist_ok=True)
|
|
2264
|
+
shutil.copy2(cfg_file, backup_config_dir / cfg_file.name)
|
|
2265
|
+
|
|
2266
|
+
# 3. Backup command files for all agents
|
|
2267
|
+
registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {}
|
|
2268
|
+
for agent_name, cmd_names in registered_commands.items():
|
|
2269
|
+
if agent_name not in registrar.AGENT_CONFIGS:
|
|
2270
|
+
continue
|
|
2271
|
+
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
|
2272
|
+
commands_dir = _AgentReg._resolve_agent_dir(
|
|
2273
|
+
agent_name, agent_config, project_root
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
for cmd_name in cmd_names:
|
|
2277
|
+
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
|
|
2278
|
+
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
|
2279
|
+
if cmd_file.exists():
|
|
2280
|
+
backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name
|
|
2281
|
+
backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2282
|
+
shutil.copy2(cmd_file, backup_cmd_path)
|
|
2283
|
+
backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)
|
|
2284
|
+
|
|
2285
|
+
# Also backup copilot prompt files
|
|
2286
|
+
if agent_name == "copilot":
|
|
2287
|
+
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
|
2288
|
+
if prompt_file.exists():
|
|
2289
|
+
backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name
|
|
2290
|
+
backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2291
|
+
shutil.copy2(prompt_file, backup_prompt_path)
|
|
2292
|
+
backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
|
|
2293
|
+
|
|
2294
|
+
# 4. Backup hooks and installed list from extensions.yml
|
|
2295
|
+
# get_project_config() always normalizes installed->[] and hooks->{},
|
|
2296
|
+
# so no sentinel is needed to distinguish key-absent from key-empty.
|
|
2297
|
+
config = hook_executor.get_project_config()
|
|
2298
|
+
if isinstance(config, dict):
|
|
2299
|
+
import copy
|
|
2300
|
+
# Deep-copy so nested mapping entries (e.g. version-pin dicts)
|
|
2301
|
+
# are not affected by in-place mutations during the update.
|
|
2302
|
+
backup_installed = copy.deepcopy(config.get("installed", []))
|
|
2303
|
+
backup_hooks = {}
|
|
2304
|
+
for hook_name, hook_list in config.get("hooks", {}).items():
|
|
2305
|
+
if not isinstance(hook_list, list):
|
|
2306
|
+
continue
|
|
2307
|
+
ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id]
|
|
2308
|
+
if ext_hooks:
|
|
2309
|
+
backup_hooks[hook_name] = ext_hooks
|
|
2310
|
+
|
|
2311
|
+
# 5. Download new version
|
|
2312
|
+
zip_path = catalog.download_extension(extension_id)
|
|
2313
|
+
try:
|
|
2314
|
+
# 6. Validate extension ID from ZIP BEFORE modifying installation
|
|
2315
|
+
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
|
|
2316
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
2317
|
+
import yaml
|
|
2318
|
+
manifest_data = None
|
|
2319
|
+
namelist = zf.namelist()
|
|
2320
|
+
|
|
2321
|
+
# First try root-level extension.yml
|
|
2322
|
+
if "extension.yml" in namelist:
|
|
2323
|
+
with zf.open("extension.yml") as f:
|
|
2324
|
+
manifest_data = yaml.safe_load(f) or {}
|
|
2325
|
+
else:
|
|
2326
|
+
# Look for extension.yml in a single top-level subdirectory
|
|
2327
|
+
# (e.g., "repo-name-branch/extension.yml")
|
|
2328
|
+
manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
|
|
2329
|
+
if len(manifest_paths) == 1:
|
|
2330
|
+
with zf.open(manifest_paths[0]) as f:
|
|
2331
|
+
manifest_data = yaml.safe_load(f) or {}
|
|
2332
|
+
|
|
2333
|
+
if manifest_data is None:
|
|
2334
|
+
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
|
|
2335
|
+
|
|
2336
|
+
zip_extension_id = manifest_data.get("extension", {}).get("id")
|
|
2337
|
+
if zip_extension_id != extension_id:
|
|
2338
|
+
raise ValueError(
|
|
2339
|
+
f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
|
|
2340
|
+
)
|
|
2341
|
+
|
|
2342
|
+
# 7. Remove old extension (handles command file cleanup and registry removal)
|
|
2343
|
+
manager.remove(extension_id, keep_config=True)
|
|
2344
|
+
|
|
2345
|
+
# 8. Install new version
|
|
2346
|
+
_ = manager.install_from_zip(zip_path, speckit_version)
|
|
2347
|
+
|
|
2348
|
+
# Restore user config files from backup after successful install.
|
|
2349
|
+
new_extension_dir = manager.extensions_dir / extension_id
|
|
2350
|
+
if backup_config_dir.exists() and new_extension_dir.exists():
|
|
2351
|
+
for cfg_file in backup_config_dir.iterdir():
|
|
2352
|
+
if cfg_file.is_file():
|
|
2353
|
+
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
|
2354
|
+
|
|
2355
|
+
# 9. Restore metadata from backup (installed_at, enabled state)
|
|
2356
|
+
if backup_registry_entry and isinstance(backup_registry_entry, dict):
|
|
2357
|
+
# Copy current registry entry to avoid mutating internal
|
|
2358
|
+
# registry state before explicit restore().
|
|
2359
|
+
current_metadata = manager.registry.get(extension_id)
|
|
2360
|
+
if current_metadata is None or not isinstance(current_metadata, dict):
|
|
2361
|
+
raise RuntimeError(
|
|
2362
|
+
f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete"
|
|
2363
|
+
)
|
|
2364
|
+
new_metadata = dict(current_metadata)
|
|
2365
|
+
|
|
2366
|
+
# Preserve the original installation timestamp
|
|
2367
|
+
if "installed_at" in backup_registry_entry:
|
|
2368
|
+
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
|
|
2369
|
+
|
|
2370
|
+
# Preserve the original priority (normalized to handle corruption)
|
|
2371
|
+
if "priority" in backup_registry_entry:
|
|
2372
|
+
new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
|
|
2373
|
+
|
|
2374
|
+
# If extension was disabled before update, disable it again
|
|
2375
|
+
if not backup_registry_entry.get("enabled", True):
|
|
2376
|
+
new_metadata["enabled"] = False
|
|
2377
|
+
|
|
2378
|
+
# Use restore() instead of update() because update() always
|
|
2379
|
+
# preserves the existing installed_at, ignoring our override
|
|
2380
|
+
manager.registry.restore(extension_id, new_metadata)
|
|
2381
|
+
|
|
2382
|
+
# Also disable hooks in extensions.yml if extension was disabled
|
|
2383
|
+
if not backup_registry_entry.get("enabled", True):
|
|
2384
|
+
config = hook_executor.get_project_config()
|
|
2385
|
+
if "hooks" in config:
|
|
2386
|
+
for hook_name in config["hooks"]:
|
|
2387
|
+
for hook in config["hooks"][hook_name]:
|
|
2388
|
+
if hook.get("extension") == extension_id:
|
|
2389
|
+
hook["enabled"] = False
|
|
2390
|
+
hook_executor.save_project_config(config)
|
|
2391
|
+
finally:
|
|
2392
|
+
# Clean up downloaded ZIP
|
|
2393
|
+
if zip_path.exists():
|
|
2394
|
+
zip_path.unlink()
|
|
2395
|
+
|
|
2396
|
+
# 10. Clean up backup on success
|
|
2397
|
+
if backup_base.exists():
|
|
2398
|
+
shutil.rmtree(backup_base)
|
|
2399
|
+
|
|
2400
|
+
console.print(f" [green]✓[/green] Updated to v{update['available']}")
|
|
2401
|
+
updated_extensions.append(ext_name)
|
|
2402
|
+
|
|
2403
|
+
except KeyboardInterrupt:
|
|
2404
|
+
raise
|
|
2405
|
+
except Exception as e:
|
|
2406
|
+
console.print(f" [red]✗[/red] Failed: {e}")
|
|
2407
|
+
failed_updates.append((ext_name, str(e)))
|
|
2408
|
+
|
|
2409
|
+
# Rollback on failure
|
|
2410
|
+
console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...")
|
|
2411
|
+
|
|
2412
|
+
try:
|
|
2413
|
+
# Restore extension directory
|
|
2414
|
+
# Only perform destructive rollback if backup exists (meaning we
|
|
2415
|
+
# actually modified the extension). This avoids deleting a valid
|
|
2416
|
+
# installation when failure happened before changes were made.
|
|
2417
|
+
extension_dir = manager.extensions_dir / extension_id
|
|
2418
|
+
if backup_ext_dir.exists():
|
|
2419
|
+
if extension_dir.exists():
|
|
2420
|
+
shutil.rmtree(extension_dir)
|
|
2421
|
+
shutil.copytree(backup_ext_dir, extension_dir)
|
|
2422
|
+
|
|
2423
|
+
# Remove any NEW command files created by failed install
|
|
2424
|
+
# (files that weren't in the original backup)
|
|
2425
|
+
try:
|
|
2426
|
+
new_registry_entry = manager.registry.get(extension_id)
|
|
2427
|
+
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
|
|
2428
|
+
new_registered_commands = {}
|
|
2429
|
+
else:
|
|
2430
|
+
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
|
2431
|
+
for agent_name, cmd_names in new_registered_commands.items():
|
|
2432
|
+
if agent_name not in registrar.AGENT_CONFIGS:
|
|
2433
|
+
continue
|
|
2434
|
+
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
|
2435
|
+
commands_dir = _AgentReg._resolve_agent_dir(
|
|
2436
|
+
agent_name, agent_config, project_root
|
|
2437
|
+
)
|
|
2438
|
+
|
|
2439
|
+
for cmd_name in cmd_names:
|
|
2440
|
+
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
|
|
2441
|
+
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
|
2442
|
+
# Delete if it exists and wasn't in our backup
|
|
2443
|
+
if cmd_file.exists() and str(cmd_file) not in backed_up_command_files:
|
|
2444
|
+
cmd_file.unlink()
|
|
2445
|
+
|
|
2446
|
+
# Also handle copilot prompt files
|
|
2447
|
+
if agent_name == "copilot":
|
|
2448
|
+
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
|
2449
|
+
if prompt_file.exists() and str(prompt_file) not in backed_up_command_files:
|
|
2450
|
+
prompt_file.unlink()
|
|
2451
|
+
except KeyError:
|
|
2452
|
+
pass # No new registry entry exists, nothing to clean up
|
|
2453
|
+
|
|
2454
|
+
# Restore backed up command files
|
|
2455
|
+
for original_path, backup_path in backed_up_command_files.items():
|
|
2456
|
+
backup_file = Path(backup_path)
|
|
2457
|
+
if backup_file.exists():
|
|
2458
|
+
original_file = Path(original_path)
|
|
2459
|
+
original_file.parent.mkdir(parents=True, exist_ok=True)
|
|
2460
|
+
shutil.copy2(backup_file, original_file)
|
|
2461
|
+
|
|
2462
|
+
# Restore metadata in extensions.yml (hooks and installed list).
|
|
2463
|
+
# Only run if backup step 4 was reached (backup_hooks is not None);
|
|
2464
|
+
# otherwise we have no safe baseline to restore from and could corrupt
|
|
2465
|
+
# the config by removing pre-existing hooks.
|
|
2466
|
+
if backup_hooks is not None:
|
|
2467
|
+
config = hook_executor.get_project_config()
|
|
2468
|
+
if not isinstance(config, dict):
|
|
2469
|
+
config = {}
|
|
2470
|
+
|
|
2471
|
+
modified = False
|
|
2472
|
+
|
|
2473
|
+
# 1. Restore hooks in extensions.yml
|
|
2474
|
+
if not isinstance(config.get("hooks"), dict):
|
|
2475
|
+
config["hooks"] = {}
|
|
2476
|
+
modified = True
|
|
2477
|
+
|
|
2478
|
+
# Remove any hooks for this extension added by the failed install
|
|
2479
|
+
for hook_name in list(config["hooks"].keys()):
|
|
2480
|
+
hooks_list = config["hooks"][hook_name]
|
|
2481
|
+
if not isinstance(hooks_list, list):
|
|
2482
|
+
config["hooks"][hook_name] = []
|
|
2483
|
+
modified = True
|
|
2484
|
+
continue
|
|
2485
|
+
|
|
2486
|
+
original_len = len(hooks_list)
|
|
2487
|
+
config["hooks"][hook_name] = [
|
|
2488
|
+
h for h in hooks_list
|
|
2489
|
+
if isinstance(h, dict) and h.get("extension") != extension_id
|
|
2490
|
+
]
|
|
2491
|
+
if len(config["hooks"][hook_name]) != original_len:
|
|
2492
|
+
modified = True
|
|
2493
|
+
|
|
2494
|
+
# Add back the backed-up hooks
|
|
2495
|
+
if backup_hooks:
|
|
2496
|
+
for hook_name, hooks in backup_hooks.items():
|
|
2497
|
+
if not isinstance(config["hooks"].get(hook_name), list):
|
|
2498
|
+
config["hooks"][hook_name] = []
|
|
2499
|
+
config["hooks"][hook_name].extend(hooks)
|
|
2500
|
+
modified = True
|
|
2501
|
+
|
|
2502
|
+
# 2. Restore installed list in extensions.yml
|
|
2503
|
+
if backup_installed is not UNSET:
|
|
2504
|
+
if config.get("installed") != backup_installed:
|
|
2505
|
+
config["installed"] = backup_installed
|
|
2506
|
+
modified = True
|
|
2507
|
+
|
|
2508
|
+
if modified:
|
|
2509
|
+
hook_executor.save_project_config(config)
|
|
2510
|
+
|
|
2511
|
+
# Restore registry entry (use restore() since entry was removed)
|
|
2512
|
+
if backup_registry_entry:
|
|
2513
|
+
manager.registry.restore(extension_id, backup_registry_entry)
|
|
2514
|
+
|
|
2515
|
+
console.print(" [green]✓[/green] Rollback successful")
|
|
2516
|
+
# Clean up backup directory only on successful rollback
|
|
2517
|
+
if backup_base.exists():
|
|
2518
|
+
shutil.rmtree(backup_base)
|
|
2519
|
+
except Exception as rollback_error:
|
|
2520
|
+
console.print(f" [red]✗[/red] Rollback failed: {rollback_error}")
|
|
2521
|
+
console.print(f" [dim]Backup preserved at: {backup_base}[/dim]")
|
|
2522
|
+
|
|
2523
|
+
# Summary
|
|
2524
|
+
console.print()
|
|
2525
|
+
if updated_extensions:
|
|
2526
|
+
console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)")
|
|
2527
|
+
if failed_updates:
|
|
2528
|
+
console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):")
|
|
2529
|
+
for ext_name, error in failed_updates:
|
|
2530
|
+
console.print(f" • {ext_name}: {error}")
|
|
2531
|
+
raise typer.Exit(1)
|
|
2532
|
+
|
|
2533
|
+
except ValidationError as e:
|
|
2534
|
+
console.print(f"\n[red]Validation Error:[/red] {e}")
|
|
2535
|
+
raise typer.Exit(1)
|
|
2536
|
+
except ExtensionError as e:
|
|
2537
|
+
console.print(f"\n[red]Error:[/red] {e}")
|
|
2538
|
+
raise typer.Exit(1)
|
|
2539
|
+
|
|
2540
|
+
|
|
2541
|
+
@extension_app.command("enable")
|
|
2542
|
+
def extension_enable(
|
|
2543
|
+
extension: str = typer.Argument(help="Extension ID or name to enable"),
|
|
2544
|
+
):
|
|
2545
|
+
"""Enable a disabled extension."""
|
|
2546
|
+
from .extensions import ExtensionManager, HookExecutor
|
|
2547
|
+
|
|
2548
|
+
project_root = _require_specify_project()
|
|
2549
|
+
manager = ExtensionManager(project_root)
|
|
2550
|
+
hook_executor = HookExecutor(project_root)
|
|
2551
|
+
|
|
2552
|
+
# Resolve extension ID from argument (handles ambiguous names)
|
|
2553
|
+
installed = manager.list_installed()
|
|
2554
|
+
extension_id, display_name = _resolve_installed_extension(extension, installed, "enable")
|
|
2555
|
+
|
|
2556
|
+
# Update registry
|
|
2557
|
+
metadata = manager.registry.get(extension_id)
|
|
2558
|
+
if metadata is None or not isinstance(metadata, dict):
|
|
2559
|
+
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
|
2560
|
+
raise typer.Exit(1)
|
|
2561
|
+
|
|
2562
|
+
if metadata.get("enabled", True):
|
|
2563
|
+
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
|
|
2564
|
+
raise typer.Exit(0)
|
|
2565
|
+
|
|
2566
|
+
manager.registry.update(extension_id, {"enabled": True})
|
|
2567
|
+
|
|
2568
|
+
# Enable hooks in extensions.yml
|
|
2569
|
+
config = hook_executor.get_project_config()
|
|
2570
|
+
if "hooks" in config:
|
|
2571
|
+
for hook_name in config["hooks"]:
|
|
2572
|
+
for hook in config["hooks"][hook_name]:
|
|
2573
|
+
if hook.get("extension") == extension_id:
|
|
2574
|
+
hook["enabled"] = True
|
|
2575
|
+
hook_executor.save_project_config(config)
|
|
2576
|
+
|
|
2577
|
+
console.print(f"[green]✓[/green] Extension '{display_name}' enabled")
|
|
2578
|
+
|
|
2579
|
+
|
|
2580
|
+
@extension_app.command("disable")
|
|
2581
|
+
def extension_disable(
|
|
2582
|
+
extension: str = typer.Argument(help="Extension ID or name to disable"),
|
|
2583
|
+
):
|
|
2584
|
+
"""Disable an extension without removing it."""
|
|
2585
|
+
from .extensions import ExtensionManager, HookExecutor
|
|
2586
|
+
|
|
2587
|
+
project_root = _require_specify_project()
|
|
2588
|
+
manager = ExtensionManager(project_root)
|
|
2589
|
+
hook_executor = HookExecutor(project_root)
|
|
2590
|
+
|
|
2591
|
+
# Resolve extension ID from argument (handles ambiguous names)
|
|
2592
|
+
installed = manager.list_installed()
|
|
2593
|
+
extension_id, display_name = _resolve_installed_extension(extension, installed, "disable")
|
|
2594
|
+
|
|
2595
|
+
# Update registry
|
|
2596
|
+
metadata = manager.registry.get(extension_id)
|
|
2597
|
+
if metadata is None or not isinstance(metadata, dict):
|
|
2598
|
+
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
|
2599
|
+
raise typer.Exit(1)
|
|
2600
|
+
|
|
2601
|
+
if not metadata.get("enabled", True):
|
|
2602
|
+
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
|
|
2603
|
+
raise typer.Exit(0)
|
|
2604
|
+
|
|
2605
|
+
manager.registry.update(extension_id, {"enabled": False})
|
|
2606
|
+
|
|
2607
|
+
# Disable hooks in extensions.yml
|
|
2608
|
+
config = hook_executor.get_project_config()
|
|
2609
|
+
if "hooks" in config:
|
|
2610
|
+
for hook_name in config["hooks"]:
|
|
2611
|
+
for hook in config["hooks"][hook_name]:
|
|
2612
|
+
if hook.get("extension") == extension_id:
|
|
2613
|
+
hook["enabled"] = False
|
|
2614
|
+
hook_executor.save_project_config(config)
|
|
2615
|
+
|
|
2616
|
+
console.print(f"[green]✓[/green] Extension '{display_name}' disabled")
|
|
2617
|
+
console.print("\nCommands will no longer be available. Hooks will not execute.")
|
|
2618
|
+
console.print(f"To re-enable: specify extension enable {extension_id}")
|
|
2619
|
+
|
|
2620
|
+
|
|
2621
|
+
@extension_app.command("set-priority")
|
|
2622
|
+
def extension_set_priority(
|
|
2623
|
+
extension: str = typer.Argument(help="Extension ID or name"),
|
|
2624
|
+
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
|
|
2625
|
+
):
|
|
2626
|
+
"""Set the resolution priority of an installed extension."""
|
|
2627
|
+
from .extensions import ExtensionManager
|
|
2628
|
+
|
|
2629
|
+
project_root = _require_specify_project()
|
|
2630
|
+
# Validate priority
|
|
2631
|
+
if priority < 1:
|
|
2632
|
+
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
2633
|
+
raise typer.Exit(1)
|
|
2634
|
+
|
|
2635
|
+
manager = ExtensionManager(project_root)
|
|
2636
|
+
|
|
2637
|
+
# Resolve extension ID from argument (handles ambiguous names)
|
|
2638
|
+
installed = manager.list_installed()
|
|
2639
|
+
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
|
|
2640
|
+
|
|
2641
|
+
# Get current metadata
|
|
2642
|
+
metadata = manager.registry.get(extension_id)
|
|
2643
|
+
if metadata is None or not isinstance(metadata, dict):
|
|
2644
|
+
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
|
2645
|
+
raise typer.Exit(1)
|
|
2646
|
+
|
|
2647
|
+
from .extensions import normalize_priority
|
|
2648
|
+
raw_priority = metadata.get("priority")
|
|
2649
|
+
# Only skip if the stored value is already a valid int equal to requested priority
|
|
2650
|
+
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
|
2651
|
+
if isinstance(raw_priority, int) and raw_priority == priority:
|
|
2652
|
+
console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
|
|
2653
|
+
raise typer.Exit(0)
|
|
2654
|
+
|
|
2655
|
+
old_priority = normalize_priority(raw_priority)
|
|
2656
|
+
|
|
2657
|
+
# Update priority
|
|
2658
|
+
manager.registry.update(extension_id, {"priority": priority})
|
|
2659
|
+
|
|
2660
|
+
console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}")
|
|
2661
|
+
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
|
2662
|
+
|
|
2663
|
+
|
|
2664
|
+
# ===== Workflow Commands =====
|
|
2665
|
+
|
|
2666
|
+
workflow_app = typer.Typer(
|
|
2667
|
+
name="workflow",
|
|
2668
|
+
help="Manage and run automation workflows",
|
|
2669
|
+
add_completion=False,
|
|
2670
|
+
)
|
|
2671
|
+
app.add_typer(workflow_app, name="workflow")
|
|
2672
|
+
|
|
2673
|
+
workflow_catalog_app = typer.Typer(
|
|
2674
|
+
name="catalog",
|
|
2675
|
+
help="Manage workflow catalogs",
|
|
2676
|
+
add_completion=False,
|
|
2677
|
+
)
|
|
2678
|
+
workflow_app.add_typer(workflow_catalog_app, name="catalog")
|
|
2679
|
+
|
|
2680
|
+
|
|
2681
|
+
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
|
|
2682
|
+
"""Parse repeated ``key=value`` CLI inputs into a dict.
|
|
2683
|
+
|
|
2684
|
+
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
|
|
2685
|
+
on any entry missing ``=``.
|
|
2686
|
+
"""
|
|
2687
|
+
inputs: dict[str, Any] = {}
|
|
2688
|
+
for kv in input_values or []:
|
|
2689
|
+
if "=" not in kv:
|
|
2690
|
+
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
|
|
2691
|
+
raise typer.Exit(1)
|
|
2692
|
+
key, _, value = kv.partition("=")
|
|
2693
|
+
inputs[key.strip()] = value.strip()
|
|
2694
|
+
return inputs
|
|
2695
|
+
|
|
2696
|
+
|
|
2697
|
+
def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
|
2698
|
+
"""Machine-readable summary of a run/resume outcome."""
|
|
2699
|
+
return {
|
|
2700
|
+
"run_id": state.run_id,
|
|
2701
|
+
"workflow_id": state.workflow_id,
|
|
2702
|
+
"status": state.status.value,
|
|
2703
|
+
"current_step_id": state.current_step_id,
|
|
2704
|
+
"current_step_index": state.current_step_index,
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
|
|
2708
|
+
def _emit_workflow_json(payload: dict[str, Any]) -> None:
|
|
2709
|
+
"""Write a workflow payload as machine-readable JSON to stdout.
|
|
2710
|
+
|
|
2711
|
+
Uses the builtin ``print`` rather than ``console.print`` so Rich
|
|
2712
|
+
markup interpretation, syntax highlighting, and line-wrapping can
|
|
2713
|
+
never alter the emitted JSON.
|
|
2714
|
+
"""
|
|
2715
|
+
print(json.dumps(payload, indent=2))
|
|
2716
|
+
|
|
2717
|
+
|
|
2718
|
+
@contextlib.contextmanager
|
|
2719
|
+
def _stdout_to_stderr_when(active: bool):
|
|
2720
|
+
"""Redirect everything written to stdout onto stderr while *active*.
|
|
2721
|
+
|
|
2722
|
+
Suppressing the banner and the step-start callback is not enough to
|
|
2723
|
+
keep a ``--json`` stream clean: individual steps may still write to
|
|
2724
|
+
stdout while the engine runs — the gate step prints its prompt,
|
|
2725
|
+
and the prompt step runs a subprocess that inherits the process's
|
|
2726
|
+
stdout file descriptor. Either would corrupt the single JSON object.
|
|
2727
|
+
|
|
2728
|
+
Redirecting at the file-descriptor level (``dup2``) captures both
|
|
2729
|
+
Python-level writes and inherited-fd subprocess output, so step
|
|
2730
|
+
progress lands on stderr (still visible to a human) while stdout
|
|
2731
|
+
carries only the emitted JSON. A no-op when *active* is false.
|
|
2732
|
+
"""
|
|
2733
|
+
if not active:
|
|
2734
|
+
yield
|
|
2735
|
+
return
|
|
2736
|
+
sys.stdout.flush()
|
|
2737
|
+
saved_stdout_fd = os.dup(1)
|
|
2738
|
+
try:
|
|
2739
|
+
os.dup2(2, 1) # fd 1 (stdout) now points at fd 2 (stderr)
|
|
2740
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
2741
|
+
yield
|
|
2742
|
+
finally:
|
|
2743
|
+
sys.stdout.flush()
|
|
2744
|
+
os.dup2(saved_stdout_fd, 1) # restore the real stdout
|
|
2745
|
+
os.close(saved_stdout_fd)
|
|
2746
|
+
|
|
2747
|
+
|
|
2748
|
+
@workflow_app.command("run")
|
|
2749
|
+
def workflow_run(
|
|
2750
|
+
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
|
|
2751
|
+
input_values: list[str] | None = typer.Option(
|
|
2752
|
+
None, "--input", "-i", help="Input values as key=value pairs"
|
|
2753
|
+
),
|
|
2754
|
+
json_output: bool = typer.Option(
|
|
2755
|
+
False,
|
|
2756
|
+
"--json",
|
|
2757
|
+
help="Emit the run outcome as a single JSON object instead of formatted text.",
|
|
2758
|
+
),
|
|
2759
|
+
):
|
|
2760
|
+
"""Run a workflow from an installed ID or local YAML path."""
|
|
2761
|
+
from .workflows.engine import WorkflowEngine
|
|
2762
|
+
|
|
2763
|
+
source_path = Path(source).expanduser()
|
|
2764
|
+
is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file()
|
|
2765
|
+
|
|
2766
|
+
if is_file_source:
|
|
2767
|
+
# When running a YAML file directly, use cwd as project root
|
|
2768
|
+
# without requiring a .specify/ project directory.
|
|
2769
|
+
project_root = Path.cwd()
|
|
2770
|
+
specify_dir = project_root / ".specify"
|
|
2771
|
+
if specify_dir.is_symlink():
|
|
2772
|
+
console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory")
|
|
2773
|
+
raise typer.Exit(1)
|
|
2774
|
+
if specify_dir.exists() and not specify_dir.is_dir():
|
|
2775
|
+
console.print("[red]Error:[/red] .specify path exists but is not a directory")
|
|
2776
|
+
raise typer.Exit(1)
|
|
2777
|
+
else:
|
|
2778
|
+
project_root = _require_specify_project()
|
|
2779
|
+
|
|
2780
|
+
engine = WorkflowEngine(project_root)
|
|
2781
|
+
if not json_output:
|
|
2782
|
+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
|
2783
|
+
|
|
2784
|
+
try:
|
|
2785
|
+
definition = engine.load_workflow(source_path if is_file_source else source)
|
|
2786
|
+
except FileNotFoundError:
|
|
2787
|
+
console.print(f"[red]Error:[/red] Workflow not found: {source}")
|
|
2788
|
+
raise typer.Exit(1)
|
|
2789
|
+
except ValueError as exc:
|
|
2790
|
+
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
|
|
2791
|
+
raise typer.Exit(1)
|
|
2792
|
+
|
|
2793
|
+
# Validate
|
|
2794
|
+
errors = engine.validate(definition)
|
|
2795
|
+
if errors:
|
|
2796
|
+
console.print("[red]Workflow validation failed:[/red]")
|
|
2797
|
+
for err in errors:
|
|
2798
|
+
console.print(f" • {err}")
|
|
2799
|
+
raise typer.Exit(1)
|
|
2800
|
+
|
|
2801
|
+
# Parse inputs
|
|
2802
|
+
inputs = _parse_input_values(input_values)
|
|
2803
|
+
|
|
2804
|
+
if not json_output:
|
|
2805
|
+
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
|
|
2806
|
+
console.print(f"[dim]Version: {definition.version}[/dim]\n")
|
|
2807
|
+
|
|
2808
|
+
try:
|
|
2809
|
+
with _stdout_to_stderr_when(json_output):
|
|
2810
|
+
state = engine.execute(definition, inputs)
|
|
2811
|
+
except ValueError as exc:
|
|
2812
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
2813
|
+
raise typer.Exit(1)
|
|
2814
|
+
except Exception as exc:
|
|
2815
|
+
console.print(f"[red]Workflow failed:[/red] {exc}")
|
|
2816
|
+
raise typer.Exit(1)
|
|
2817
|
+
|
|
2818
|
+
if json_output:
|
|
2819
|
+
_emit_workflow_json(_workflow_run_payload(state))
|
|
2820
|
+
return
|
|
2821
|
+
|
|
2822
|
+
status_colors = {
|
|
2823
|
+
"completed": "green",
|
|
2824
|
+
"paused": "yellow",
|
|
2825
|
+
"failed": "red",
|
|
2826
|
+
"aborted": "red",
|
|
2827
|
+
}
|
|
2828
|
+
color = status_colors.get(state.status.value, "white")
|
|
2829
|
+
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
|
2830
|
+
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
|
|
2831
|
+
|
|
2832
|
+
if state.status.value == "paused":
|
|
2833
|
+
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
|
|
2834
|
+
|
|
2835
|
+
|
|
2836
|
+
@workflow_app.command("resume")
|
|
2837
|
+
def workflow_resume(
|
|
2838
|
+
run_id: str = typer.Argument(..., help="Run ID to resume"),
|
|
2839
|
+
input_values: list[str] | None = typer.Option(
|
|
2840
|
+
None, "--input", "-i", help="Updated input values as key=value pairs"
|
|
2841
|
+
),
|
|
2842
|
+
json_output: bool = typer.Option(
|
|
2843
|
+
False,
|
|
2844
|
+
"--json",
|
|
2845
|
+
help="Emit the resume outcome as a single JSON object instead of formatted text.",
|
|
2846
|
+
),
|
|
2847
|
+
):
|
|
2848
|
+
"""Resume a paused or failed workflow run."""
|
|
2849
|
+
from .workflows.engine import WorkflowEngine
|
|
2850
|
+
|
|
2851
|
+
project_root = _require_specify_project()
|
|
2852
|
+
engine = WorkflowEngine(project_root)
|
|
2853
|
+
if not json_output:
|
|
2854
|
+
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
|
2855
|
+
|
|
2856
|
+
inputs = _parse_input_values(input_values)
|
|
2857
|
+
|
|
2858
|
+
try:
|
|
2859
|
+
with _stdout_to_stderr_when(json_output):
|
|
2860
|
+
state = engine.resume(run_id, inputs or None)
|
|
2861
|
+
except FileNotFoundError:
|
|
2862
|
+
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
|
2863
|
+
raise typer.Exit(1)
|
|
2864
|
+
except ValueError as exc:
|
|
2865
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
2866
|
+
raise typer.Exit(1)
|
|
2867
|
+
except Exception as exc:
|
|
2868
|
+
console.print(f"[red]Resume failed:[/red] {exc}")
|
|
2869
|
+
raise typer.Exit(1)
|
|
2870
|
+
|
|
2871
|
+
if json_output:
|
|
2872
|
+
_emit_workflow_json(_workflow_run_payload(state))
|
|
2873
|
+
return
|
|
2874
|
+
|
|
2875
|
+
status_colors = {
|
|
2876
|
+
"completed": "green",
|
|
2877
|
+
"paused": "yellow",
|
|
2878
|
+
"failed": "red",
|
|
2879
|
+
"aborted": "red",
|
|
2880
|
+
}
|
|
2881
|
+
color = status_colors.get(state.status.value, "white")
|
|
2882
|
+
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
|
2883
|
+
|
|
2884
|
+
|
|
2885
|
+
@workflow_app.command("status")
|
|
2886
|
+
def workflow_status(
|
|
2887
|
+
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
|
|
2888
|
+
json_output: bool = typer.Option(
|
|
2889
|
+
False,
|
|
2890
|
+
"--json",
|
|
2891
|
+
help="Emit run status as a single JSON object instead of formatted text.",
|
|
2892
|
+
),
|
|
2893
|
+
):
|
|
2894
|
+
"""Show workflow run status."""
|
|
2895
|
+
from .workflows.engine import WorkflowEngine
|
|
2896
|
+
|
|
2897
|
+
project_root = _require_specify_project()
|
|
2898
|
+
engine = WorkflowEngine(project_root)
|
|
2899
|
+
|
|
2900
|
+
if run_id:
|
|
2901
|
+
try:
|
|
2902
|
+
from .workflows.engine import RunState
|
|
2903
|
+
state = RunState.load(run_id, project_root)
|
|
2904
|
+
except FileNotFoundError:
|
|
2905
|
+
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
|
2906
|
+
raise typer.Exit(1)
|
|
2907
|
+
|
|
2908
|
+
if json_output:
|
|
2909
|
+
# Build on the shared run/resume payload so the common fields
|
|
2910
|
+
# (including current_step_index) stay identical across commands.
|
|
2911
|
+
payload = {
|
|
2912
|
+
**_workflow_run_payload(state),
|
|
2913
|
+
"created_at": state.created_at,
|
|
2914
|
+
"updated_at": state.updated_at,
|
|
2915
|
+
"steps": {
|
|
2916
|
+
sid: sd.get("status", "unknown")
|
|
2917
|
+
for sid, sd in state.step_results.items()
|
|
2918
|
+
},
|
|
2919
|
+
}
|
|
2920
|
+
_emit_workflow_json(payload)
|
|
2921
|
+
return
|
|
2922
|
+
|
|
2923
|
+
status_colors = {
|
|
2924
|
+
"completed": "green",
|
|
2925
|
+
"paused": "yellow",
|
|
2926
|
+
"failed": "red",
|
|
2927
|
+
"aborted": "red",
|
|
2928
|
+
"running": "blue",
|
|
2929
|
+
"created": "dim",
|
|
2930
|
+
}
|
|
2931
|
+
color = status_colors.get(state.status.value, "white")
|
|
2932
|
+
|
|
2933
|
+
console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]")
|
|
2934
|
+
console.print(f" Workflow: {state.workflow_id}")
|
|
2935
|
+
console.print(f" Status: [{color}]{state.status.value}[/{color}]")
|
|
2936
|
+
console.print(f" Created: {state.created_at}")
|
|
2937
|
+
console.print(f" Updated: {state.updated_at}")
|
|
2938
|
+
|
|
2939
|
+
if state.current_step_id:
|
|
2940
|
+
console.print(f" Current: {state.current_step_id}")
|
|
2941
|
+
|
|
2942
|
+
if state.step_results:
|
|
2943
|
+
console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]")
|
|
2944
|
+
for step_id, step_data in state.step_results.items():
|
|
2945
|
+
s = step_data.get("status", "unknown")
|
|
2946
|
+
sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white")
|
|
2947
|
+
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
|
|
2948
|
+
else:
|
|
2949
|
+
runs = engine.list_runs()
|
|
2950
|
+
|
|
2951
|
+
if json_output:
|
|
2952
|
+
payload = {
|
|
2953
|
+
"runs": [
|
|
2954
|
+
{
|
|
2955
|
+
"run_id": r["run_id"],
|
|
2956
|
+
"workflow_id": r.get("workflow_id"),
|
|
2957
|
+
"status": r.get("status", "unknown"),
|
|
2958
|
+
"updated_at": r.get("updated_at"),
|
|
2959
|
+
}
|
|
2960
|
+
for r in runs
|
|
2961
|
+
]
|
|
2962
|
+
}
|
|
2963
|
+
_emit_workflow_json(payload)
|
|
2964
|
+
return
|
|
2965
|
+
|
|
2966
|
+
if not runs:
|
|
2967
|
+
console.print("[yellow]No workflow runs found.[/yellow]")
|
|
2968
|
+
return
|
|
2969
|
+
|
|
2970
|
+
console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n")
|
|
2971
|
+
for run_data in runs:
|
|
2972
|
+
s = run_data.get("status", "unknown")
|
|
2973
|
+
sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white")
|
|
2974
|
+
console.print(
|
|
2975
|
+
f" [{sc}]●[/{sc}] {run_data['run_id']} "
|
|
2976
|
+
f"{run_data.get('workflow_id', '?')} "
|
|
2977
|
+
f"[{sc}]{s}[/{sc}] "
|
|
2978
|
+
f"[dim]{run_data.get('updated_at', '?')}[/dim]"
|
|
2979
|
+
)
|
|
2980
|
+
|
|
2981
|
+
|
|
2982
|
+
@workflow_app.command("list")
|
|
2983
|
+
def workflow_list():
|
|
2984
|
+
"""List installed workflows."""
|
|
2985
|
+
from .workflows.catalog import WorkflowRegistry
|
|
2986
|
+
|
|
2987
|
+
project_root = _require_specify_project()
|
|
2988
|
+
registry = WorkflowRegistry(project_root)
|
|
2989
|
+
installed = registry.list()
|
|
2990
|
+
|
|
2991
|
+
if not installed:
|
|
2992
|
+
console.print("[yellow]No workflows installed.[/yellow]")
|
|
2993
|
+
console.print("\nInstall a workflow with:")
|
|
2994
|
+
console.print(" [cyan]specify workflow add <workflow-id>[/cyan]")
|
|
2995
|
+
return
|
|
2996
|
+
|
|
2997
|
+
console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n")
|
|
2998
|
+
for wf_id, wf_data in installed.items():
|
|
2999
|
+
console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}")
|
|
3000
|
+
desc = wf_data.get("description", "")
|
|
3001
|
+
if desc:
|
|
3002
|
+
console.print(f" {desc}")
|
|
3003
|
+
console.print()
|
|
3004
|
+
|
|
3005
|
+
|
|
3006
|
+
@workflow_app.command("add")
|
|
3007
|
+
def workflow_add(
|
|
3008
|
+
source: str = typer.Argument(..., help="Workflow ID, URL, or local path"),
|
|
3009
|
+
):
|
|
3010
|
+
"""Install a workflow from catalog, URL, or local path."""
|
|
3011
|
+
from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError
|
|
3012
|
+
from .workflows.engine import WorkflowDefinition
|
|
3013
|
+
|
|
3014
|
+
project_root = _require_specify_project()
|
|
3015
|
+
registry = WorkflowRegistry(project_root)
|
|
3016
|
+
workflows_dir = project_root / ".specify" / "workflows"
|
|
3017
|
+
|
|
3018
|
+
def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
|
|
3019
|
+
"""Validate and install a workflow from a local YAML file."""
|
|
3020
|
+
try:
|
|
3021
|
+
definition = WorkflowDefinition.from_yaml(yaml_path)
|
|
3022
|
+
except (ValueError, yaml.YAMLError) as exc:
|
|
3023
|
+
console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}")
|
|
3024
|
+
raise typer.Exit(1)
|
|
3025
|
+
if not definition.id or not definition.id.strip():
|
|
3026
|
+
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
|
|
3027
|
+
raise typer.Exit(1)
|
|
3028
|
+
|
|
3029
|
+
from .workflows.engine import validate_workflow
|
|
3030
|
+
errors = validate_workflow(definition)
|
|
3031
|
+
if errors:
|
|
3032
|
+
console.print("[red]Error:[/red] Workflow validation failed:")
|
|
3033
|
+
for err in errors:
|
|
3034
|
+
console.print(f" \u2022 {err}")
|
|
3035
|
+
raise typer.Exit(1)
|
|
3036
|
+
|
|
3037
|
+
dest_dir = workflows_dir / definition.id
|
|
3038
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
3039
|
+
import shutil
|
|
3040
|
+
shutil.copy2(yaml_path, dest_dir / "workflow.yml")
|
|
3041
|
+
registry.add(definition.id, {
|
|
3042
|
+
"name": definition.name,
|
|
3043
|
+
"version": definition.version,
|
|
3044
|
+
"description": definition.description,
|
|
3045
|
+
"source": source_label,
|
|
3046
|
+
})
|
|
3047
|
+
console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed")
|
|
3048
|
+
|
|
3049
|
+
# Try as URL (http/https)
|
|
3050
|
+
if source.startswith("http://") or source.startswith("https://"):
|
|
3051
|
+
from ipaddress import ip_address
|
|
3052
|
+
from urllib.parse import urlparse
|
|
3053
|
+
from specify_cli.authentication.http import open_url as _open_url
|
|
3054
|
+
|
|
3055
|
+
parsed_src = urlparse(source)
|
|
3056
|
+
src_host = parsed_src.hostname or ""
|
|
3057
|
+
src_loopback = src_host == "localhost"
|
|
3058
|
+
if not src_loopback:
|
|
3059
|
+
try:
|
|
3060
|
+
src_loopback = ip_address(src_host).is_loopback
|
|
3061
|
+
except ValueError:
|
|
3062
|
+
# Host is not an IP literal (e.g., a DNS name); keep default non-loopback.
|
|
3063
|
+
pass
|
|
3064
|
+
if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback):
|
|
3065
|
+
console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.")
|
|
3066
|
+
raise typer.Exit(1)
|
|
3067
|
+
|
|
3068
|
+
import tempfile
|
|
3069
|
+
try:
|
|
3070
|
+
with _open_url(source, timeout=30) as resp:
|
|
3071
|
+
final_url = resp.geturl()
|
|
3072
|
+
final_parsed = urlparse(final_url)
|
|
3073
|
+
final_host = final_parsed.hostname or ""
|
|
3074
|
+
final_lb = final_host == "localhost"
|
|
3075
|
+
if not final_lb:
|
|
3076
|
+
try:
|
|
3077
|
+
final_lb = ip_address(final_host).is_loopback
|
|
3078
|
+
except ValueError:
|
|
3079
|
+
# Redirect host is not an IP literal; keep loopback as determined above.
|
|
3080
|
+
pass
|
|
3081
|
+
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
|
|
3082
|
+
console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}")
|
|
3083
|
+
raise typer.Exit(1)
|
|
3084
|
+
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
|
|
3085
|
+
tmp.write(resp.read())
|
|
3086
|
+
tmp_path = Path(tmp.name)
|
|
3087
|
+
except typer.Exit:
|
|
3088
|
+
raise
|
|
3089
|
+
except Exception as exc:
|
|
3090
|
+
console.print(f"[red]Error:[/red] Failed to download workflow: {exc}")
|
|
3091
|
+
raise typer.Exit(1)
|
|
3092
|
+
try:
|
|
3093
|
+
_validate_and_install_local(tmp_path, source)
|
|
3094
|
+
finally:
|
|
3095
|
+
tmp_path.unlink(missing_ok=True)
|
|
3096
|
+
return
|
|
3097
|
+
|
|
3098
|
+
# Try as a local file/directory
|
|
3099
|
+
source_path = Path(source)
|
|
3100
|
+
if source_path.exists():
|
|
3101
|
+
if source_path.is_file() and source_path.suffix in (".yml", ".yaml"):
|
|
3102
|
+
_validate_and_install_local(source_path, str(source_path))
|
|
3103
|
+
return
|
|
3104
|
+
elif source_path.is_dir():
|
|
3105
|
+
wf_file = source_path / "workflow.yml"
|
|
3106
|
+
if not wf_file.exists():
|
|
3107
|
+
console.print(f"[red]Error:[/red] No workflow.yml found in {source}")
|
|
3108
|
+
raise typer.Exit(1)
|
|
3109
|
+
_validate_and_install_local(wf_file, str(source_path))
|
|
3110
|
+
return
|
|
3111
|
+
|
|
3112
|
+
# Try from catalog
|
|
3113
|
+
catalog = WorkflowCatalog(project_root)
|
|
3114
|
+
try:
|
|
3115
|
+
info = catalog.get_workflow_info(source)
|
|
3116
|
+
except WorkflowCatalogError as exc:
|
|
3117
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
3118
|
+
raise typer.Exit(1)
|
|
3119
|
+
|
|
3120
|
+
if not info:
|
|
3121
|
+
console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog")
|
|
3122
|
+
raise typer.Exit(1)
|
|
3123
|
+
|
|
3124
|
+
if not info.get("_install_allowed", True):
|
|
3125
|
+
console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog")
|
|
3126
|
+
console.print("Direct installation is not enabled for this catalog source.")
|
|
3127
|
+
raise typer.Exit(1)
|
|
3128
|
+
|
|
3129
|
+
workflow_url = info.get("url")
|
|
3130
|
+
if not workflow_url:
|
|
3131
|
+
console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog")
|
|
3132
|
+
raise typer.Exit(1)
|
|
3133
|
+
|
|
3134
|
+
# Validate URL scheme (HTTPS required, HTTP allowed for localhost only)
|
|
3135
|
+
from ipaddress import ip_address
|
|
3136
|
+
from urllib.parse import urlparse
|
|
3137
|
+
|
|
3138
|
+
parsed_url = urlparse(workflow_url)
|
|
3139
|
+
url_host = parsed_url.hostname or ""
|
|
3140
|
+
is_loopback = False
|
|
3141
|
+
if url_host == "localhost":
|
|
3142
|
+
is_loopback = True
|
|
3143
|
+
else:
|
|
3144
|
+
try:
|
|
3145
|
+
is_loopback = ip_address(url_host).is_loopback
|
|
3146
|
+
except ValueError:
|
|
3147
|
+
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
|
|
3148
|
+
pass
|
|
3149
|
+
if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback):
|
|
3150
|
+
console.print(
|
|
3151
|
+
f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. "
|
|
3152
|
+
"Only HTTPS URLs are allowed, except HTTP for localhost/loopback."
|
|
3153
|
+
)
|
|
3154
|
+
raise typer.Exit(1)
|
|
3155
|
+
|
|
3156
|
+
workflow_dir = workflows_dir / source
|
|
3157
|
+
# Validate that source is a safe directory name (no path traversal)
|
|
3158
|
+
try:
|
|
3159
|
+
workflow_dir.resolve().relative_to(workflows_dir.resolve())
|
|
3160
|
+
except ValueError:
|
|
3161
|
+
console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}")
|
|
3162
|
+
raise typer.Exit(1)
|
|
3163
|
+
workflow_file = workflow_dir / "workflow.yml"
|
|
3164
|
+
|
|
3165
|
+
try:
|
|
3166
|
+
from specify_cli.authentication.http import open_url as _open_url
|
|
3167
|
+
|
|
3168
|
+
workflow_dir.mkdir(parents=True, exist_ok=True)
|
|
3169
|
+
with _open_url(workflow_url, timeout=30) as response:
|
|
3170
|
+
# Validate final URL after redirects
|
|
3171
|
+
final_url = response.geturl()
|
|
3172
|
+
final_parsed = urlparse(final_url)
|
|
3173
|
+
final_host = final_parsed.hostname or ""
|
|
3174
|
+
final_loopback = final_host == "localhost"
|
|
3175
|
+
if not final_loopback:
|
|
3176
|
+
try:
|
|
3177
|
+
final_loopback = ip_address(final_host).is_loopback
|
|
3178
|
+
except ValueError:
|
|
3179
|
+
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
|
|
3180
|
+
pass
|
|
3181
|
+
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback):
|
|
3182
|
+
if workflow_dir.exists():
|
|
3183
|
+
import shutil
|
|
3184
|
+
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
3185
|
+
console.print(
|
|
3186
|
+
f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}"
|
|
3187
|
+
)
|
|
3188
|
+
raise typer.Exit(1)
|
|
3189
|
+
workflow_file.write_bytes(response.read())
|
|
3190
|
+
except Exception as exc:
|
|
3191
|
+
if workflow_dir.exists():
|
|
3192
|
+
import shutil
|
|
3193
|
+
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
3194
|
+
console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}")
|
|
3195
|
+
raise typer.Exit(1)
|
|
3196
|
+
|
|
3197
|
+
# Validate the downloaded workflow before registering
|
|
3198
|
+
try:
|
|
3199
|
+
definition = WorkflowDefinition.from_yaml(workflow_file)
|
|
3200
|
+
except (ValueError, yaml.YAMLError) as exc:
|
|
3201
|
+
import shutil
|
|
3202
|
+
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
3203
|
+
console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}")
|
|
3204
|
+
raise typer.Exit(1)
|
|
3205
|
+
|
|
3206
|
+
from .workflows.engine import validate_workflow
|
|
3207
|
+
errors = validate_workflow(definition)
|
|
3208
|
+
if errors:
|
|
3209
|
+
import shutil
|
|
3210
|
+
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
3211
|
+
console.print("[red]Error:[/red] Downloaded workflow validation failed:")
|
|
3212
|
+
for err in errors:
|
|
3213
|
+
console.print(f" \u2022 {err}")
|
|
3214
|
+
raise typer.Exit(1)
|
|
3215
|
+
|
|
3216
|
+
# Enforce that the workflow's internal ID matches the catalog key
|
|
3217
|
+
if definition.id and definition.id != source:
|
|
3218
|
+
import shutil
|
|
3219
|
+
shutil.rmtree(workflow_dir, ignore_errors=True)
|
|
3220
|
+
console.print(
|
|
3221
|
+
f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) "
|
|
3222
|
+
f"does not match catalog key ({source!r}). "
|
|
3223
|
+
f"The catalog entry may be misconfigured."
|
|
3224
|
+
)
|
|
3225
|
+
raise typer.Exit(1)
|
|
3226
|
+
|
|
3227
|
+
registry.add(source, {
|
|
3228
|
+
"name": definition.name or info.get("name", source),
|
|
3229
|
+
"version": definition.version or info.get("version", "0.0.0"),
|
|
3230
|
+
"description": definition.description or info.get("description", ""),
|
|
3231
|
+
"source": "catalog",
|
|
3232
|
+
"catalog_name": info.get("_catalog_name", ""),
|
|
3233
|
+
"url": workflow_url,
|
|
3234
|
+
})
|
|
3235
|
+
console.print(f"[green]✓[/green] Workflow '{info.get('name', source)}' installed from catalog")
|
|
3236
|
+
|
|
3237
|
+
|
|
3238
|
+
@workflow_app.command("remove")
|
|
3239
|
+
def workflow_remove(
|
|
3240
|
+
workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"),
|
|
3241
|
+
):
|
|
3242
|
+
"""Uninstall a workflow."""
|
|
3243
|
+
from .workflows.catalog import WorkflowRegistry
|
|
3244
|
+
|
|
3245
|
+
project_root = _require_specify_project()
|
|
3246
|
+
registry = WorkflowRegistry(project_root)
|
|
3247
|
+
|
|
3248
|
+
if not registry.is_installed(workflow_id):
|
|
3249
|
+
console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed")
|
|
3250
|
+
raise typer.Exit(1)
|
|
3251
|
+
|
|
3252
|
+
# Remove workflow files
|
|
3253
|
+
workflow_dir = project_root / ".specify" / "workflows" / workflow_id
|
|
3254
|
+
if workflow_dir.exists():
|
|
3255
|
+
import shutil
|
|
3256
|
+
shutil.rmtree(workflow_dir)
|
|
3257
|
+
|
|
3258
|
+
registry.remove(workflow_id)
|
|
3259
|
+
console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed")
|
|
3260
|
+
|
|
3261
|
+
|
|
3262
|
+
@workflow_app.command("search")
|
|
3263
|
+
def workflow_search(
|
|
3264
|
+
query: str | None = typer.Argument(None, help="Search query"),
|
|
3265
|
+
tag: str | None = typer.Option(None, "--tag", help="Filter by tag"),
|
|
3266
|
+
):
|
|
3267
|
+
"""Search workflow catalogs."""
|
|
3268
|
+
from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError
|
|
3269
|
+
|
|
3270
|
+
project_root = _require_specify_project()
|
|
3271
|
+
catalog = WorkflowCatalog(project_root)
|
|
3272
|
+
|
|
3273
|
+
try:
|
|
3274
|
+
results = catalog.search(query=query, tag=tag)
|
|
3275
|
+
except WorkflowCatalogError as exc:
|
|
3276
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
3277
|
+
raise typer.Exit(1)
|
|
3278
|
+
|
|
3279
|
+
if not results:
|
|
3280
|
+
console.print("[yellow]No workflows found.[/yellow]")
|
|
3281
|
+
return
|
|
3282
|
+
|
|
3283
|
+
console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n")
|
|
3284
|
+
for wf in results:
|
|
3285
|
+
console.print(f" [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}")
|
|
3286
|
+
desc = wf.get("description", "")
|
|
3287
|
+
if desc:
|
|
3288
|
+
console.print(f" {desc}")
|
|
3289
|
+
tags = wf.get("tags", [])
|
|
3290
|
+
if tags:
|
|
3291
|
+
console.print(f" [dim]Tags: {', '.join(tags)}[/dim]")
|
|
3292
|
+
console.print()
|
|
3293
|
+
|
|
3294
|
+
|
|
3295
|
+
@workflow_app.command("info")
|
|
3296
|
+
def workflow_info(
|
|
3297
|
+
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
3298
|
+
):
|
|
3299
|
+
"""Show workflow details and step graph."""
|
|
3300
|
+
from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError
|
|
3301
|
+
from .workflows.engine import WorkflowEngine
|
|
3302
|
+
|
|
3303
|
+
project_root = _require_specify_project()
|
|
3304
|
+
|
|
3305
|
+
# Check installed first
|
|
3306
|
+
registry = WorkflowRegistry(project_root)
|
|
3307
|
+
installed = registry.get(workflow_id)
|
|
3308
|
+
|
|
3309
|
+
engine = WorkflowEngine(project_root)
|
|
3310
|
+
|
|
3311
|
+
definition = None
|
|
3312
|
+
try:
|
|
3313
|
+
definition = engine.load_workflow(workflow_id)
|
|
3314
|
+
except FileNotFoundError:
|
|
3315
|
+
# Local workflow definition not found on disk; fall back to
|
|
3316
|
+
# catalog/registry lookup below.
|
|
3317
|
+
pass
|
|
3318
|
+
|
|
3319
|
+
if definition:
|
|
3320
|
+
console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
|
|
3321
|
+
console.print(f" Version: {definition.version}")
|
|
3322
|
+
if definition.author:
|
|
3323
|
+
console.print(f" Author: {definition.author}")
|
|
3324
|
+
if definition.description:
|
|
3325
|
+
console.print(f" Description: {definition.description}")
|
|
3326
|
+
if definition.default_integration:
|
|
3327
|
+
console.print(f" Integration: {definition.default_integration}")
|
|
3328
|
+
if installed:
|
|
3329
|
+
console.print(" [green]Installed[/green]")
|
|
3330
|
+
|
|
3331
|
+
if definition.inputs:
|
|
3332
|
+
console.print("\n [bold]Inputs:[/bold]")
|
|
3333
|
+
for name, inp in definition.inputs.items():
|
|
3334
|
+
if isinstance(inp, dict):
|
|
3335
|
+
req = "required" if inp.get("required") else "optional"
|
|
3336
|
+
console.print(f" {name} ({inp.get('type', 'string')}) — {req}")
|
|
3337
|
+
|
|
3338
|
+
if definition.steps:
|
|
3339
|
+
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
|
|
3340
|
+
for step in definition.steps:
|
|
3341
|
+
stype = step.get("type", "command")
|
|
3342
|
+
console.print(f" → {step.get('id', '?')} [{stype}]")
|
|
3343
|
+
return
|
|
3344
|
+
|
|
3345
|
+
# Try catalog
|
|
3346
|
+
catalog = WorkflowCatalog(project_root)
|
|
3347
|
+
try:
|
|
3348
|
+
info = catalog.get_workflow_info(workflow_id)
|
|
3349
|
+
except WorkflowCatalogError:
|
|
3350
|
+
info = None
|
|
3351
|
+
|
|
3352
|
+
if info:
|
|
3353
|
+
console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})")
|
|
3354
|
+
console.print(f" Version: {info.get('version', '?')}")
|
|
3355
|
+
if info.get("description"):
|
|
3356
|
+
console.print(f" Description: {info['description']}")
|
|
3357
|
+
if info.get("tags"):
|
|
3358
|
+
console.print(f" Tags: {', '.join(info['tags'])}")
|
|
3359
|
+
console.print(" [yellow]Not installed[/yellow]")
|
|
3360
|
+
else:
|
|
3361
|
+
console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found")
|
|
3362
|
+
raise typer.Exit(1)
|
|
3363
|
+
|
|
3364
|
+
|
|
3365
|
+
@workflow_catalog_app.command("list")
|
|
3366
|
+
def workflow_catalog_list():
|
|
3367
|
+
"""List configured workflow catalog sources."""
|
|
3368
|
+
from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError
|
|
3369
|
+
|
|
3370
|
+
project_root = _require_specify_project()
|
|
3371
|
+
catalog = WorkflowCatalog(project_root)
|
|
3372
|
+
|
|
3373
|
+
try:
|
|
3374
|
+
configs = catalog.get_catalog_configs()
|
|
3375
|
+
except WorkflowCatalogError as exc:
|
|
3376
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
3377
|
+
raise typer.Exit(1)
|
|
3378
|
+
|
|
3379
|
+
console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n")
|
|
3380
|
+
for i, cfg in enumerate(configs):
|
|
3381
|
+
install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]"
|
|
3382
|
+
console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
|
|
3383
|
+
console.print(f" {cfg['url']}")
|
|
3384
|
+
if cfg.get("description"):
|
|
3385
|
+
console.print(f" [dim]{cfg['description']}[/dim]")
|
|
3386
|
+
console.print()
|
|
3387
|
+
|
|
3388
|
+
|
|
3389
|
+
@workflow_catalog_app.command("add")
|
|
3390
|
+
def workflow_catalog_add(
|
|
3391
|
+
url: str = typer.Argument(..., help="Catalog URL to add"),
|
|
3392
|
+
name: str = typer.Option(None, "--name", help="Catalog name"),
|
|
3393
|
+
):
|
|
3394
|
+
"""Add a workflow catalog source."""
|
|
3395
|
+
from .workflows.catalog import WorkflowCatalog, WorkflowValidationError
|
|
3396
|
+
|
|
3397
|
+
project_root = _require_specify_project()
|
|
3398
|
+
catalog = WorkflowCatalog(project_root)
|
|
3399
|
+
try:
|
|
3400
|
+
catalog.add_catalog(url, name)
|
|
3401
|
+
except WorkflowValidationError as exc:
|
|
3402
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
3403
|
+
raise typer.Exit(1)
|
|
3404
|
+
|
|
3405
|
+
console.print(f"[green]✓[/green] Catalog source added: {url}")
|
|
3406
|
+
|
|
3407
|
+
|
|
3408
|
+
@workflow_catalog_app.command("remove")
|
|
3409
|
+
def workflow_catalog_remove(
|
|
3410
|
+
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
|
|
3411
|
+
):
|
|
3412
|
+
"""Remove a workflow catalog source by index."""
|
|
3413
|
+
from .workflows.catalog import WorkflowCatalog, WorkflowValidationError
|
|
3414
|
+
|
|
3415
|
+
project_root = _require_specify_project()
|
|
3416
|
+
catalog = WorkflowCatalog(project_root)
|
|
3417
|
+
try:
|
|
3418
|
+
removed_name = catalog.remove_catalog(index)
|
|
3419
|
+
except WorkflowValidationError as exc:
|
|
3420
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
3421
|
+
raise typer.Exit(1)
|
|
3422
|
+
|
|
3423
|
+
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")
|
|
3424
|
+
|
|
3425
|
+
|
|
3426
|
+
def main():
|
|
3427
|
+
# On Windows the default stdout/stderr code page (e.g. cp1252) cannot encode
|
|
3428
|
+
# the Rich banner and box-drawing glyphs, so the CLI crashes with
|
|
3429
|
+
# UnicodeEncodeError whenever output is not a UTF-8 TTY (piped, redirected to
|
|
3430
|
+
# a file, or running under a legacy code page). Force UTF-8 with graceful
|
|
3431
|
+
# replacement so output degrades instead of aborting. No-op on POSIX.
|
|
3432
|
+
if sys.platform == "win32":
|
|
3433
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
3434
|
+
try:
|
|
3435
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
3436
|
+
except (AttributeError, ValueError, OSError):
|
|
3437
|
+
pass
|
|
3438
|
+
app()
|
|
3439
|
+
|
|
3440
|
+
if __name__ == "__main__":
|
|
3441
|
+
main()
|