specify-cli 1.2.0__py3-none-any.whl → 1.2.2__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/_invocation_style.py +12 -1
- specify_cli/bundles/command_init.py +6 -1
- specify_cli/bundles/manifest.py +11 -0
- specify_cli/command_init.py +9 -21
- specify_cli/command_mcp.py +1 -1
- specify_cli/core_pack/scripts/bash/check-prerequisites.sh +6 -0
- specify_cli/core_pack/scripts/bash/setup-plan.sh +12 -8
- specify_cli/core_pack/scripts/powershell/check-prerequisites.ps1 +10 -1
- specify_cli/core_pack/scripts/powershell/setup-plan.ps1 +13 -11
- specify_cli/core_pack/scripts/python/check_prerequisites.py +10 -0
- specify_cli/core_pack/scripts/python/setup_plan.py +19 -2
- specify_cli/events/__init__.py +30 -11
- specify_cli/extensions/_command_update_transaction.py +15 -0
- specify_cli/extensions/_commands.py +32 -0
- specify_cli/extensions/catalog/command_remove.py +32 -11
- specify_cli/extensions/command_add.py +1 -14
- specify_cli/extensions/command_info.py +6 -21
- specify_cli/integrations/_capture.py +536 -0
- specify_cli/integrations/_lifecycle.py +4 -17
- specify_cli/integrations/base.py +32 -1
- specify_cli/integrations/command_switch.py +21 -0
- specify_cli/integrations/command_uninstall.py +44 -1
- specify_cli/integrations/copilot/__init__.py +17 -1
- specify_cli/integrations/zcode/__init__.py +30 -0
- specify_cli/presets/catalog/command_remove.py +8 -1
- specify_cli/workflows/_commands.py +18 -0
- specify_cli/workflows/_execution.py +40 -10
- specify_cli/workflows/command_resume.py +6 -0
- specify_cli/workflows/command_run.py +2 -0
- specify_cli/workflows/command_update.py +2 -0
- specify_cli/workflows/engine.py +5 -0
- specify_cli/workflows/step/_capture.py +114 -0
- specify_cli/workflows/step/command/__init__.py +84 -3
- specify_cli/workflows/step/prompt/__init__.py +55 -8
- {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/METADATA +39 -7
- {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/RECORD +39 -37
- {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/WHEEL +0 -0
- {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/entry_points.txt +0 -0
- {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/licenses/LICENSE +0 -0
specify_cli/_invocation_style.py
CHANGED
|
@@ -13,7 +13,18 @@ DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-cod
|
|
|
13
13
|
|
|
14
14
|
# Agents that always render /speckit-<name>, regardless of ai_skills.
|
|
15
15
|
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset(
|
|
16
|
-
{
|
|
16
|
+
{
|
|
17
|
+
"devin",
|
|
18
|
+
"docker-agent",
|
|
19
|
+
"droid",
|
|
20
|
+
"dsh",
|
|
21
|
+
"grok",
|
|
22
|
+
"mcode",
|
|
23
|
+
"muse",
|
|
24
|
+
"qodercli",
|
|
25
|
+
"trae",
|
|
26
|
+
"zed",
|
|
27
|
+
}
|
|
17
28
|
)
|
|
18
29
|
|
|
19
30
|
# Agents that render /speckit-<name> only when ai_skills is enabled.
|
specify_cli/bundles/manifest.py
CHANGED
|
@@ -211,6 +211,17 @@ class BundleManifest:
|
|
|
211
211
|
errors.append(
|
|
212
212
|
f"{ref.kind[:-1]} '{ref.id}' has invalid version '{ref.version}'."
|
|
213
213
|
)
|
|
214
|
+
# Preset and extension installs refuse a priority below 1, which
|
|
215
|
+
# would otherwise surface only mid-install, after earlier components.
|
|
216
|
+
if (
|
|
217
|
+
ref.kind in ("extensions", "presets")
|
|
218
|
+
and ref.priority is not None
|
|
219
|
+
and ref.priority < 1
|
|
220
|
+
):
|
|
221
|
+
errors.append(
|
|
222
|
+
f"{ref.kind[:-1]} '{ref.id}' priority must be 1 or higher, "
|
|
223
|
+
f"got {ref.priority}."
|
|
224
|
+
)
|
|
214
225
|
|
|
215
226
|
for ref in self.presets:
|
|
216
227
|
if ref.priority is None:
|
specify_cli/command_init.py
CHANGED
|
@@ -1077,8 +1077,6 @@ def register(app: typer.Typer) -> None:
|
|
|
1077
1077
|
muse_skill_mode = selected_ai == "muse" and _is_skills_integration
|
|
1078
1078
|
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
|
|
1079
1079
|
dsh_skill_mode = selected_ai == "dsh" and _is_skills_integration
|
|
1080
|
-
cline_skill_mode = selected_ai == "cline"
|
|
1081
|
-
forge_skill_mode = selected_ai == "forge"
|
|
1082
1080
|
bob_skill_mode = selected_ai == "bob" and _is_skills_integration
|
|
1083
1081
|
native_skill_mode = (
|
|
1084
1082
|
codex_skill_mode
|
|
@@ -1155,28 +1153,18 @@ def register(app: typer.Typer) -> None:
|
|
|
1155
1153
|
step_num += 1
|
|
1156
1154
|
usage_label = "skills" if native_skill_mode else "slash commands"
|
|
1157
1155
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1156
|
+
invoke_prefix = _invoke_prefix_for_integration(
|
|
1157
|
+
resolved_integration,
|
|
1158
|
+
resolved_integration.key,
|
|
1159
|
+
integration_parsed_options or None,
|
|
1160
|
+
project_path,
|
|
1161
|
+
)
|
|
1162
|
+
invoke_separator = resolved_integration.effective_invoke_separator(
|
|
1163
|
+
integration_parsed_options or None, project_root=project_path
|
|
1161
1164
|
)
|
|
1162
|
-
|
|
1163
|
-
# `_is_skills_integration` means the integration is installed in
|
|
1164
|
-
# skills mode, which is the semantic equivalent of `ai_skills_enabled`
|
|
1165
|
-
# used by `is_slash_skills_agent()`.
|
|
1166
|
-
_ai_skills_enabled = _is_skills_integration
|
|
1167
1165
|
|
|
1168
1166
|
def _display_cmd(name: str) -> str:
|
|
1169
|
-
|
|
1170
|
-
return f"$speckit-{name}"
|
|
1171
|
-
if kimi_skill_mode:
|
|
1172
|
-
return f"/skill:speckit-{name}"
|
|
1173
|
-
if (
|
|
1174
|
-
_is_slash_skills_agent(selected_ai, _ai_skills_enabled)
|
|
1175
|
-
or cline_skill_mode
|
|
1176
|
-
or forge_skill_mode
|
|
1177
|
-
):
|
|
1178
|
-
return f"/speckit-{name}"
|
|
1179
|
-
return f"/speckit.{name}"
|
|
1167
|
+
return f"{invoke_prefix}speckit{invoke_separator}{name}"
|
|
1180
1168
|
|
|
1181
1169
|
steps_lines.append(
|
|
1182
1170
|
f"{step_num}. Start using {usage_label} with your coding agent:"
|
specify_cli/command_mcp.py
CHANGED
|
@@ -148,6 +148,12 @@ if [[ ! -f "$IMPL_PLAN" ]]; then
|
|
|
148
148
|
exit 1
|
|
149
149
|
fi
|
|
150
150
|
|
|
151
|
+
if [[ ! -s "$IMPL_PLAN" ]]; then
|
|
152
|
+
echo "ERROR: plan.md is empty in $FEATURE_DIR" >&2
|
|
153
|
+
echo "Populate it, or remove the empty file and rerun $(format_speckit_command plan "$REPO_ROOT") with a valid plan template." >&2
|
|
154
|
+
exit 1
|
|
155
|
+
fi
|
|
156
|
+
|
|
151
157
|
# Check for spec.md if required
|
|
152
158
|
if $REQUIRE_SPEC && [[ ! -f "$FEATURE_SPEC" ]]; then
|
|
153
159
|
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
|
@@ -43,7 +43,15 @@ if [[ -f "$IMPL_PLAN" ]]; then
|
|
|
43
43
|
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
|
|
44
44
|
fi
|
|
45
45
|
else
|
|
46
|
-
|
|
46
|
+
template_file=$(mktemp)
|
|
47
|
+
trap 'rm -f "$template_file"' EXIT
|
|
48
|
+
if resolve_template_content "plan-template" "$REPO_ROOT" > "$template_file"; then
|
|
49
|
+
if [[ ! -s "$template_file" ]]; then
|
|
50
|
+
echo "ERROR: Plan template is empty; no new plan.md was created." >&2
|
|
51
|
+
echo "Restore a valid plan template and rerun plan setup." >&2
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
cat "$template_file" > "$IMPL_PLAN"
|
|
47
55
|
if $JSON_MODE; then
|
|
48
56
|
echo "Copied plan template to $IMPL_PLAN" >&2
|
|
49
57
|
else
|
|
@@ -51,16 +59,12 @@ else
|
|
|
51
59
|
fi
|
|
52
60
|
else
|
|
53
61
|
resolve_status=$?
|
|
54
|
-
rm -f "$IMPL_PLAN"
|
|
55
62
|
if [ "$resolve_status" -ne 1 ]; then
|
|
56
63
|
exit "$resolve_status"
|
|
57
64
|
fi
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
echo "Warning: Plan template not found"
|
|
62
|
-
fi
|
|
63
|
-
touch "$IMPL_PLAN"
|
|
65
|
+
echo "ERROR: Plan template not found; no new plan.md was created." >&2
|
|
66
|
+
echo "Restore a valid plan template and rerun plan setup." >&2
|
|
67
|
+
exit 1
|
|
64
68
|
fi
|
|
65
69
|
fi
|
|
66
70
|
|
|
@@ -101,13 +101,22 @@ if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
|
|
101
101
|
exit 1
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
|
104
|
+
if (-not (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf)) {
|
|
105
105
|
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
|
106
106
|
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
|
107
107
|
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
|
|
108
108
|
exit 1
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
if ((Get-Item -LiteralPath $paths.IMPL_PLAN).Length -eq 0) {
|
|
112
|
+
[Console]::Error.WriteLine("ERROR: plan.md is empty in $($paths.FEATURE_DIR)")
|
|
113
|
+
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
|
114
|
+
[Console]::Error.WriteLine(
|
|
115
|
+
"Populate it, or remove the empty file and rerun $planCommand with a valid plan template."
|
|
116
|
+
)
|
|
117
|
+
exit 1
|
|
118
|
+
}
|
|
119
|
+
|
|
111
120
|
# Check for spec.md if required
|
|
112
121
|
if ($RequireSpec -and -not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
|
|
113
122
|
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
|
|
@@ -39,7 +39,7 @@ if (-not $paths) {
|
|
|
39
39
|
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
|
40
40
|
|
|
41
41
|
# Copy plan template if plan doesn't already exist
|
|
42
|
-
if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
|
42
|
+
if (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf) {
|
|
43
43
|
if ($Json) {
|
|
44
44
|
[Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy")
|
|
45
45
|
} else {
|
|
@@ -48,6 +48,13 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
|
|
48
48
|
} else {
|
|
49
49
|
$content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
|
50
50
|
if ($null -ne $content) {
|
|
51
|
+
if ($content.Length -eq 0) {
|
|
52
|
+
[Console]::Error.WriteLine(
|
|
53
|
+
"ERROR: Plan template is empty; no new plan.md was created."
|
|
54
|
+
)
|
|
55
|
+
[Console]::Error.WriteLine("Restore a valid plan template and rerun plan setup.")
|
|
56
|
+
exit 1
|
|
57
|
+
}
|
|
51
58
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
52
59
|
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
|
|
53
60
|
# Emit the copy status like the bash twin (setup-plan.sh); route to stderr
|
|
@@ -58,16 +65,11 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
|
|
58
65
|
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
|
59
66
|
}
|
|
60
67
|
} else {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
} else {
|
|
67
|
-
Write-Output "Warning: Plan template not found"
|
|
68
|
-
}
|
|
69
|
-
# Create a basic plan file if template doesn't exist
|
|
70
|
-
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
|
68
|
+
[Console]::Error.WriteLine(
|
|
69
|
+
"ERROR: Plan template not found; no new plan.md was created."
|
|
70
|
+
)
|
|
71
|
+
[Console]::Error.WriteLine("Restore a valid plan template and rerun plan setup.")
|
|
72
|
+
exit 1
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
@@ -236,6 +236,16 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
236
236
|
)
|
|
237
237
|
return 1
|
|
238
238
|
|
|
239
|
+
if paths.impl_plan.stat().st_size == 0:
|
|
240
|
+
print(f"ERROR: plan.md is empty in {paths.feature_dir}", file=sys.stderr)
|
|
241
|
+
print(
|
|
242
|
+
"Populate it, or remove the empty file and rerun "
|
|
243
|
+
f"{format_speckit_command('plan', paths.repo_root)} "
|
|
244
|
+
"with a valid plan template.",
|
|
245
|
+
file=sys.stderr,
|
|
246
|
+
)
|
|
247
|
+
return 1
|
|
248
|
+
|
|
239
249
|
if args.require_spec and not paths.feature_spec.is_file():
|
|
240
250
|
print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr)
|
|
241
251
|
print(
|
|
@@ -70,11 +70,28 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
70
70
|
print(f"Error: {exc}", file=sys.stderr)
|
|
71
71
|
return 1
|
|
72
72
|
if template_content is not None:
|
|
73
|
+
if template_content == "":
|
|
74
|
+
print(
|
|
75
|
+
"ERROR: Plan template is empty; no new plan.md was created.",
|
|
76
|
+
file=sys.stderr,
|
|
77
|
+
)
|
|
78
|
+
print(
|
|
79
|
+
"Restore a valid plan template and rerun plan setup.",
|
|
80
|
+
file=sys.stderr,
|
|
81
|
+
)
|
|
82
|
+
return 1
|
|
73
83
|
paths.impl_plan.write_bytes(template_content.encode("utf-8"))
|
|
74
84
|
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
|
|
75
85
|
else:
|
|
76
|
-
print(
|
|
77
|
-
|
|
86
|
+
print(
|
|
87
|
+
"ERROR: Plan template not found; no new plan.md was created.",
|
|
88
|
+
file=sys.stderr,
|
|
89
|
+
)
|
|
90
|
+
print(
|
|
91
|
+
"Restore a valid plan template and rerun plan setup.",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
return 1
|
|
78
95
|
|
|
79
96
|
if json_mode:
|
|
80
97
|
sys.stdout.write(
|
specify_cli/events/__init__.py
CHANGED
|
@@ -32,6 +32,8 @@ import typer
|
|
|
32
32
|
from ..integrations._file_changes import unlink as _unlink_file, write_text as _write_text
|
|
33
33
|
|
|
34
34
|
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Iterable
|
|
36
|
+
|
|
35
37
|
from ..integrations.base import IntegrationBase
|
|
36
38
|
from ..integrations.manifest import IntegrationManifest
|
|
37
39
|
|
|
@@ -2153,13 +2155,15 @@ def _remove_opencode_entries(config_path: Path) -> bool:
|
|
|
2153
2155
|
if existing is None:
|
|
2154
2156
|
return False
|
|
2155
2157
|
plugins = existing.get("plugin", [])
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2158
|
+
ref = "./.opencode/plugin/speckit-events.ts"
|
|
2159
|
+
# No Spec Kit plugin ref to strip: leave the user's file untouched.
|
|
2160
|
+
if not isinstance(plugins, list) or ref not in plugins:
|
|
2161
|
+
return False
|
|
2162
|
+
plugins = [p for p in plugins if p != ref]
|
|
2163
|
+
if plugins:
|
|
2164
|
+
existing["plugin"] = plugins
|
|
2165
|
+
else:
|
|
2166
|
+
existing.pop("plugin", None)
|
|
2163
2167
|
if not existing:
|
|
2164
2168
|
_unlink_file(config_path, missing_ok=True)
|
|
2165
2169
|
return True
|
|
@@ -2376,8 +2380,9 @@ def _remove_copilot_entries(dst: Path) -> bool:
|
|
|
2376
2380
|
if not isinstance(existing, dict):
|
|
2377
2381
|
return False
|
|
2378
2382
|
hooks = existing.get("hooks", {})
|
|
2379
|
-
|
|
2380
|
-
|
|
2383
|
+
# Nothing Specify-owned to strip: leave the user's file untouched.
|
|
2384
|
+
if not isinstance(hooks, dict) or not _has_marked_entry(hooks.values()):
|
|
2385
|
+
return False
|
|
2381
2386
|
cleaned: dict[str, list] = {}
|
|
2382
2387
|
for event, entries in hooks.items():
|
|
2383
2388
|
if not isinstance(entries, list):
|
|
@@ -2505,7 +2510,8 @@ def _remove_json_root_entries(dst: Path) -> bool:
|
|
|
2505
2510
|
existing = _load_user_json(dst)
|
|
2506
2511
|
if existing is None:
|
|
2507
2512
|
return False
|
|
2508
|
-
|
|
2513
|
+
# Nothing Specify-owned to strip: leave the user's file untouched.
|
|
2514
|
+
if not isinstance(existing, dict) or not _has_marked_entry(existing.values()):
|
|
2509
2515
|
return False
|
|
2510
2516
|
cleaned: dict[str, Any] = {}
|
|
2511
2517
|
for event, entries in existing.items():
|
|
@@ -2627,7 +2633,10 @@ def _remove_json_entries(dst: Path) -> bool:
|
|
|
2627
2633
|
if existing is None:
|
|
2628
2634
|
return False
|
|
2629
2635
|
hooks = existing.get("hooks", {})
|
|
2630
|
-
|
|
2636
|
+
# Nothing Specify-owned to strip: leave the user's file untouched, so a
|
|
2637
|
+
# no-op event refresh neither reformats nor deletes it (the JSON
|
|
2638
|
+
# counterpart of the TOML guard added for #4563).
|
|
2639
|
+
if not isinstance(hooks, dict) or not _has_marked_entry(hooks.values()):
|
|
2631
2640
|
return False
|
|
2632
2641
|
cleaned: dict[str, list] = {}
|
|
2633
2642
|
for event, entries in hooks.items():
|
|
@@ -2671,6 +2680,16 @@ def _has_marker(entry: Any) -> bool:
|
|
|
2671
2680
|
return False
|
|
2672
2681
|
|
|
2673
2682
|
|
|
2683
|
+
def _has_marked_entry(groups: Iterable[Any]) -> bool:
|
|
2684
|
+
"""Return True if any list among *groups* holds a Specify-marked entry."""
|
|
2685
|
+
return any(
|
|
2686
|
+
_has_marker(entry)
|
|
2687
|
+
for entries in groups
|
|
2688
|
+
if isinstance(entries, list)
|
|
2689
|
+
for entry in entries
|
|
2690
|
+
)
|
|
2691
|
+
|
|
2692
|
+
|
|
2674
2693
|
def register(app: typer.Typer) -> None:
|
|
2675
2694
|
"""Attach the event command group to the root application."""
|
|
2676
2695
|
from . import command_run # noqa: F401 -- registers handler
|
|
@@ -564,6 +564,10 @@ def run_update_command(extension: str | None) -> None:
|
|
|
564
564
|
if cfg_file.is_file():
|
|
565
565
|
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
|
566
566
|
|
|
567
|
+
# Scaffold config templates the new version declares, as
|
|
568
|
+
# `extension add` does; the configs restored above are kept.
|
|
569
|
+
scaffolded, _, unscaffolded = manager.scaffold_config(extension_id)
|
|
570
|
+
|
|
567
571
|
# 9. Restore metadata from backup (installed_at, enabled state)
|
|
568
572
|
if backup_registry_entry and isinstance(backup_registry_entry, dict):
|
|
569
573
|
init_options = _commands.load_init_options(project_root)
|
|
@@ -630,6 +634,17 @@ def run_update_command(extension: str | None) -> None:
|
|
|
630
634
|
console.print(
|
|
631
635
|
f" [green]✓[/green] Updated to v{update.available}"
|
|
632
636
|
)
|
|
637
|
+
config_home = f".specify/extensions/{_escape_markup(extension_id)}"
|
|
638
|
+
for cfg in scaffolded:
|
|
639
|
+
console.print(
|
|
640
|
+
f" Config scaffolded: {config_home}/{_escape_markup(cfg)}"
|
|
641
|
+
)
|
|
642
|
+
if unscaffolded:
|
|
643
|
+
console.print(
|
|
644
|
+
" [yellow]Warning:[/yellow] Config templates not scaffolded: "
|
|
645
|
+
f"{_escape_markup(', '.join(unscaffolded))}. "
|
|
646
|
+
"Verify the extension manifest and template files."
|
|
647
|
+
)
|
|
633
648
|
if cleanup_error is not None:
|
|
634
649
|
console.print(
|
|
635
650
|
" [yellow]Warning:[/yellow] Could not fully remove "
|
|
@@ -121,6 +121,38 @@ def _refresh_events_and_warn(project_root: Path) -> None:
|
|
|
121
121
|
console.print(f" {key}: {_escape_markup(detail)}")
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
def _registered_command_name(project_root: Path, command_name: str) -> str:
|
|
125
|
+
"""Return *command_name* the way the project's active agent registers it.
|
|
126
|
+
|
|
127
|
+
Skills projects register extension commands as ``speckit-<ext>-<cmd>``
|
|
128
|
+
skills, which needs no integration lookup, so installed adapters stay
|
|
129
|
+
unloaded. In command mode, agents whose command references use ``-``
|
|
130
|
+
(such as Cline, Forge, and Junie) register the same hyphenated name; the
|
|
131
|
+
others, and agents that are not loaded, keep the manifest's dotted name.
|
|
132
|
+
Deriving this from the integration keeps command listings in step with
|
|
133
|
+
registration instead of a list of agents.
|
|
134
|
+
"""
|
|
135
|
+
from .._init_options import is_ai_skills_enabled
|
|
136
|
+
from ..integrations import get_integration
|
|
137
|
+
from . import ExtensionManager
|
|
138
|
+
|
|
139
|
+
opts = load_init_options(project_root)
|
|
140
|
+
selected_ai = opts.get("ai") if isinstance(opts, dict) else None
|
|
141
|
+
if not isinstance(selected_ai, str) or not selected_ai:
|
|
142
|
+
return command_name
|
|
143
|
+
# Check skills mode before the integration: generic's command-mode
|
|
144
|
+
# separator applies to both of its layouts, and inspection commands such
|
|
145
|
+
# as `extension info` do not load installed adapters.
|
|
146
|
+
if is_ai_skills_enabled(opts):
|
|
147
|
+
return ExtensionManager._skill_name_for_command(command_name)
|
|
148
|
+
integration = get_integration(selected_ai)
|
|
149
|
+
if integration is None or (
|
|
150
|
+
integration.invoke_separator_for_mode(False) != "-"
|
|
151
|
+
):
|
|
152
|
+
return command_name
|
|
153
|
+
return ExtensionManager._skill_name_for_command(command_name)
|
|
154
|
+
|
|
155
|
+
|
|
124
156
|
def install_extension_from_url(
|
|
125
157
|
manager,
|
|
126
158
|
project_root: Path,
|
|
@@ -5,6 +5,8 @@ Registered by ``catalog.register()``; shared catalog helpers live in
|
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import os
|
|
9
|
+
|
|
8
10
|
import typer
|
|
9
11
|
from rich.markup import escape as _escape_markup
|
|
10
12
|
|
|
@@ -48,19 +50,38 @@ def catalog_remove(
|
|
|
48
50
|
_commands.console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.")
|
|
49
51
|
raise typer.Exit(1)
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
if catalogs:
|
|
54
|
+
config["catalogs"] = catalogs
|
|
55
|
+
config_path.write_text(
|
|
56
|
+
_commands.yaml.safe_dump(
|
|
57
|
+
config,
|
|
58
|
+
default_flow_style=False,
|
|
59
|
+
sort_keys=False,
|
|
60
|
+
allow_unicode=True,
|
|
61
|
+
),
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
# The catalog loader rejects an existing config with no entries, so
|
|
66
|
+
# delete the file instead of writing ``catalogs: []``.
|
|
67
|
+
try:
|
|
68
|
+
config_path.unlink(missing_ok=True)
|
|
69
|
+
except OSError as error:
|
|
70
|
+
config_label = _escape_markup(
|
|
71
|
+
str(_commands._display_project_path(project_root, config_path))
|
|
72
|
+
)
|
|
73
|
+
_commands.console.print(
|
|
74
|
+
f"[red]Error:[/red] Failed to delete {config_label}: "
|
|
75
|
+
f"{_escape_markup(str(error))}"
|
|
76
|
+
)
|
|
77
|
+
raise typer.Exit(1)
|
|
61
78
|
|
|
62
79
|
_commands.console.print(f"[green]✓[/green] Removed catalog '{safe_name}'")
|
|
63
80
|
if not catalogs:
|
|
81
|
+
if os.environ.get("SPECKIT_CATALOG_URL"):
|
|
82
|
+
fallback = "the SPECKIT_CATALOG_URL environment variable still takes precedence"
|
|
83
|
+
else:
|
|
84
|
+
fallback = "user configuration or built-in defaults will be used"
|
|
64
85
|
_commands.console.print(
|
|
65
|
-
"\n[dim]
|
|
86
|
+
f"\n[dim]Project catalog override removed; {fallback}.[/dim]"
|
|
66
87
|
)
|
|
@@ -297,22 +297,9 @@ def extension_add(
|
|
|
297
297
|
for warning in manifest.warnings:
|
|
298
298
|
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
|
|
299
299
|
|
|
300
|
-
selected_ai = _commands.load_init_options(project_root).get("ai")
|
|
301
|
-
is_cline = selected_ai == "cline"
|
|
302
|
-
is_forge = selected_ai == "forge"
|
|
303
|
-
|
|
304
|
-
if is_cline:
|
|
305
|
-
from specify_cli.integrations.cline import format_cline_command_name
|
|
306
|
-
if is_forge:
|
|
307
|
-
from specify_cli.integrations.forge import format_forge_command_name
|
|
308
|
-
|
|
309
300
|
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
|
310
301
|
for cmd in manifest.commands:
|
|
311
|
-
cmd_name = cmd['name']
|
|
312
|
-
if is_cline:
|
|
313
|
-
cmd_name = format_cline_command_name(cmd_name)
|
|
314
|
-
elif is_forge:
|
|
315
|
-
cmd_name = format_forge_command_name(cmd_name)
|
|
302
|
+
cmd_name = _commands._registered_command_name(project_root, cmd['name'])
|
|
316
303
|
console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}")
|
|
317
304
|
|
|
318
305
|
# Report agent skills registration
|
|
@@ -123,29 +123,14 @@ def extension_info(
|
|
|
123
123
|
_commands.console.print()
|
|
124
124
|
|
|
125
125
|
if ext_manifest.commands:
|
|
126
|
-
# Print each command the way the active agent registers it
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
# names shown here don't match what the user actually types.
|
|
132
|
-
selected_ai = _commands.load_init_options(project_root).get("ai")
|
|
133
|
-
if selected_ai == "cline":
|
|
134
|
-
from specify_cli.integrations.cline import (
|
|
135
|
-
format_cline_command_name as _format_command_name,
|
|
136
|
-
)
|
|
137
|
-
elif selected_ai == "forge":
|
|
138
|
-
from specify_cli.integrations.forge import (
|
|
139
|
-
format_forge_command_name as _format_command_name,
|
|
140
|
-
)
|
|
141
|
-
else:
|
|
142
|
-
_format_command_name = None
|
|
143
|
-
|
|
126
|
+
# Print each command the way the active agent registers it
|
|
127
|
+
# (e.g. Codex registers `speckit-jira-sync`, not the manifest's
|
|
128
|
+
# dotted `speckit.jira.sync`), matching `extension add`'s
|
|
129
|
+
# "Provided commands" listing, so the names shown here are the
|
|
130
|
+
# ones the user actually types.
|
|
144
131
|
_commands.console.print("[bold]Commands:[/bold]")
|
|
145
132
|
for cmd in ext_manifest.commands:
|
|
146
|
-
cmd_name = cmd['name']
|
|
147
|
-
if _format_command_name is not None:
|
|
148
|
-
cmd_name = _format_command_name(cmd_name)
|
|
133
|
+
cmd_name = _commands._registered_command_name(project_root, cmd['name'])
|
|
149
134
|
_commands.console.print(f" • {_escape_markup(str(cmd_name))}: {_escape_markup(str(cmd.get('description', '')))}")
|
|
150
135
|
_commands.console.print()
|
|
151
136
|
|