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.
Files changed (39) hide show
  1. specify_cli/_invocation_style.py +12 -1
  2. specify_cli/bundles/command_init.py +6 -1
  3. specify_cli/bundles/manifest.py +11 -0
  4. specify_cli/command_init.py +9 -21
  5. specify_cli/command_mcp.py +1 -1
  6. specify_cli/core_pack/scripts/bash/check-prerequisites.sh +6 -0
  7. specify_cli/core_pack/scripts/bash/setup-plan.sh +12 -8
  8. specify_cli/core_pack/scripts/powershell/check-prerequisites.ps1 +10 -1
  9. specify_cli/core_pack/scripts/powershell/setup-plan.ps1 +13 -11
  10. specify_cli/core_pack/scripts/python/check_prerequisites.py +10 -0
  11. specify_cli/core_pack/scripts/python/setup_plan.py +19 -2
  12. specify_cli/events/__init__.py +30 -11
  13. specify_cli/extensions/_command_update_transaction.py +15 -0
  14. specify_cli/extensions/_commands.py +32 -0
  15. specify_cli/extensions/catalog/command_remove.py +32 -11
  16. specify_cli/extensions/command_add.py +1 -14
  17. specify_cli/extensions/command_info.py +6 -21
  18. specify_cli/integrations/_capture.py +536 -0
  19. specify_cli/integrations/_lifecycle.py +4 -17
  20. specify_cli/integrations/base.py +32 -1
  21. specify_cli/integrations/command_switch.py +21 -0
  22. specify_cli/integrations/command_uninstall.py +44 -1
  23. specify_cli/integrations/copilot/__init__.py +17 -1
  24. specify_cli/integrations/zcode/__init__.py +30 -0
  25. specify_cli/presets/catalog/command_remove.py +8 -1
  26. specify_cli/workflows/_commands.py +18 -0
  27. specify_cli/workflows/_execution.py +40 -10
  28. specify_cli/workflows/command_resume.py +6 -0
  29. specify_cli/workflows/command_run.py +2 -0
  30. specify_cli/workflows/command_update.py +2 -0
  31. specify_cli/workflows/engine.py +5 -0
  32. specify_cli/workflows/step/_capture.py +114 -0
  33. specify_cli/workflows/step/command/__init__.py +84 -3
  34. specify_cli/workflows/step/prompt/__init__.py +55 -8
  35. {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/METADATA +39 -7
  36. {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/RECORD +39 -37
  37. {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/WHEEL +0 -0
  38. {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/entry_points.txt +0 -0
  39. {specify_cli-1.2.0.dist-info → specify_cli-1.2.2.dist-info}/licenses/LICENSE +0 -0
@@ -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
- {"devin", "droid", "dsh", "grok", "mcode", "muse", "qodercli", "trae", "zed"}
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.
@@ -48,4 +48,9 @@ def bundle_init(
48
48
  f"{_escape_markup(str(project_root))}."
49
49
  )
50
50
  if bundle:
51
- bundle_install(bundle, integration=integration, offline=offline)
51
+ bundle_install(
52
+ bundle,
53
+ integration=integration,
54
+ offline=offline,
55
+ refresh=False,
56
+ )
@@ -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:
@@ -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
- from ._invocation_style import (
1159
- is_dollar_skills_agent as _is_dollar_skills_agent,
1160
- is_slash_skills_agent as _is_slash_skills_agent,
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
- if _is_dollar_skills_agent(selected_ai, _ai_skills_enabled):
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:"
@@ -6,7 +6,7 @@ import typer
6
6
 
7
7
 
8
8
  def mcp() -> None:
9
- """Run the experimental version-only MCP server over stdio."""
9
+ """Run the experimental MCP server over stdio."""
10
10
  from .mcp_server import run_stdio_server
11
11
 
12
12
  run_stdio_server()
@@ -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
- if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then
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
- if $JSON_MODE; then
59
- echo "Warning: Plan template not found" >&2
60
- else
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
- # Match the bash twin's wording and stream routing (stderr in -Json so
62
- # stdout stays pure JSON, stdout otherwise), consistent with the sibling
63
- # "Copied plan template" message above.
64
- if ($Json) {
65
- [Console]::Error.WriteLine("Warning: Plan template not found")
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("Warning: Plan template not found", file=status_stream)
77
- paths.impl_plan.touch()
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(
@@ -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
- if isinstance(plugins, list):
2157
- ref = "./.opencode/plugin/speckit-events.ts"
2158
- plugins = [p for p in plugins if p != ref]
2159
- if plugins:
2160
- existing["plugin"] = plugins
2161
- else:
2162
- existing.pop("plugin", None)
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
- if not isinstance(hooks, dict):
2380
- hooks = {}
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
- if not isinstance(existing, dict):
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
- if not isinstance(hooks, dict):
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
- config["catalogs"] = catalogs
52
- config_path.write_text(
53
- _commands.yaml.safe_dump(
54
- config,
55
- default_flow_style=False,
56
- sort_keys=False,
57
- allow_unicode=True,
58
- ),
59
- encoding="utf-8",
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]No catalogs remain in config. Built-in defaults will be used.[/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
- # Cline and Forge hyphenate command names (e.g. Forge invokes
128
- # `/speckit-jira-sync`, not the manifest's dotted
129
- # `speckit.jira.sync`), so mirror the same formatting used by
130
- # `extension add`'s "Provided commands" listing — otherwise the
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