mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
5 Commits
v0.8.6
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9167f4904e | ||
|
|
2d5e63005d | ||
|
|
793632089a | ||
|
|
c0bf5d0c64 | ||
|
|
77e605da6b |
@@ -212,6 +212,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
@@ -484,7 +485,7 @@ specify init --here --force
|
||||
|
||||

|
||||
|
||||
You will be prompted to select the coding agent integration you are using. You can also proactively specify it directly in the terminal:
|
||||
In an interactive terminal, you will be prompted to select the coding agent integration you are using. In non-interactive sessions, such as CI or piped runs, `specify init` defaults to GitHub Copilot unless you pass `--integration`. You can also proactively specify the integration directly in the terminal:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --integration copilot
|
||||
|
||||
@@ -41,6 +41,8 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here
|
||||
|
||||
### Specify Integration
|
||||
|
||||
Interactive terminals prompt you to choose a coding agent integration during initialization. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot unless you pass `--integration`.
|
||||
|
||||
You can proactively specify your coding agent integration during initialization:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -28,6 +28,8 @@ Creates a new Spec Kit project with the necessary directory structure, templates
|
||||
|
||||
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
|
||||
|
||||
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-05-06T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -580,6 +580,38 @@
|
||||
"created_at": "2026-03-29T00:00:00Z",
|
||||
"updated_at": "2026-03-29T00:00:00Z"
|
||||
},
|
||||
"cost": {
|
||||
"name": "Cost Tracker",
|
||||
"id": "cost",
|
||||
"description": "Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-cost/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-cost",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-cost",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-cost/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-cost/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"cost",
|
||||
"budget",
|
||||
"tokens",
|
||||
"visibility",
|
||||
"finance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-03T00:00:00Z",
|
||||
"updated_at": "2026-05-05T00:00:00Z"
|
||||
},
|
||||
"diagram": {
|
||||
"name": "Spec Diagram",
|
||||
"id": "diagram",
|
||||
@@ -2905,7 +2937,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering"
|
||||
|
||||
# Create Feature Branch
|
||||
|
||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow.
|
||||
|
||||
## User Input
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.6"
|
||||
version = "0.8.7.dev0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -90,6 +90,7 @@ def _build_agent_config() -> dict[str, dict[str, Any]]:
|
||||
return config
|
||||
|
||||
AGENT_CONFIG = _build_agent_config()
|
||||
DEFAULT_INIT_INTEGRATION = "copilot"
|
||||
|
||||
AI_ASSISTANT_ALIASES = {
|
||||
"kiro": "kiro-cli",
|
||||
@@ -152,6 +153,9 @@ def _build_ai_deprecation_warning(
|
||||
f"Use [bold]{replacement}[/bold] instead."
|
||||
)
|
||||
|
||||
def _stdin_is_interactive() -> bool:
|
||||
return sys.stdin.isatty()
|
||||
|
||||
SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
||||
|
||||
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
|
||||
@@ -995,7 +999,8 @@ def init(
|
||||
|
||||
This command will:
|
||||
1. Check that required tools are installed (git is optional)
|
||||
2. Let you choose your coding agent integration
|
||||
2. Let you choose your coding agent integration, or default to Copilot
|
||||
in non-interactive sessions
|
||||
3. Download template from GitHub (or use bundled assets with --offline)
|
||||
4. Initialize a fresh git repository (if not --no-git and no existing repo)
|
||||
5. Optionally set up coding agent integration commands
|
||||
@@ -1162,13 +1167,19 @@ def init(
|
||||
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
selected_ai = ai_assistant
|
||||
elif not _stdin_is_interactive():
|
||||
console.print(
|
||||
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
|
||||
"Use --integration to choose a different agent.[/dim]"
|
||||
)
|
||||
selected_ai = DEFAULT_INIT_INTEGRATION
|
||||
else:
|
||||
# Create options dict for selection (agent_key: display_name)
|
||||
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
|
||||
selected_ai = select_with_arrows(
|
||||
ai_choices,
|
||||
"Choose your coding agent integration:",
|
||||
"copilot"
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
)
|
||||
|
||||
# Auto-promote interactively selected agents to the integration path
|
||||
@@ -1233,7 +1244,7 @@ def init(
|
||||
else:
|
||||
default_script = "ps" if os.name == "nt" else "sh"
|
||||
|
||||
if sys.stdin.isatty():
|
||||
if _stdin_is_interactive():
|
||||
selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script)
|
||||
else:
|
||||
selected_script = default_script
|
||||
|
||||
@@ -7,12 +7,12 @@ command files into agent-specific directories in the correct format.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
import platform
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@@ -25,7 +25,16 @@ def _build_agent_configs() -> dict[str, Any]:
|
||||
if key == "generic":
|
||||
continue
|
||||
if integration.registrar_config:
|
||||
configs[key] = dict(integration.registrar_config)
|
||||
config = dict(integration.registrar_config)
|
||||
# Propagate invoke_separator from the integration class when the
|
||||
# registrar_config dict doesn't already declare it explicitly.
|
||||
# SkillsIntegration subclasses (claude, codex, …) set
|
||||
# invoke_separator="-" as a class attribute but omit it from
|
||||
# registrar_config, so without this they would fall back to "."
|
||||
# when register_commands() resolves __SPECKIT_COMMAND_*__ tokens.
|
||||
if "invoke_separator" not in config:
|
||||
config["invoke_separator"] = integration.invoke_separator
|
||||
configs[key] = config
|
||||
return configs
|
||||
|
||||
|
||||
@@ -419,9 +428,7 @@ class CommandRegistrar:
|
||||
normalized = Path(os.path.normpath(candidate))
|
||||
base_normalized = Path(os.path.normpath(base))
|
||||
if not normalized.is_relative_to(base_normalized):
|
||||
raise ValueError(
|
||||
f"Output path {candidate!r} escapes directory {base!r}"
|
||||
)
|
||||
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
@@ -471,7 +478,10 @@ class CommandRegistrar:
|
||||
|
||||
if frontmatter.get("strategy") == "wrap":
|
||||
from .presets import _substitute_core_template
|
||||
body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self)
|
||||
|
||||
body, core_frontmatter = _substitute_core_template(
|
||||
body, cmd_name, project_root, self
|
||||
)
|
||||
frontmatter = dict(frontmatter)
|
||||
for key in ("scripts", "agent_scripts"):
|
||||
if key not in frontmatter and key in core_frontmatter:
|
||||
@@ -492,6 +502,16 @@ class CommandRegistrar:
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
|
||||
# Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator.
|
||||
# The separator is sourced from agent_config (populated by _build_agent_configs,
|
||||
# which propagates each integration's invoke_separator class attribute).
|
||||
# Deferred import of IntegrationBase avoids a circular import at module load
|
||||
# (base.py itself imports CommandRegistrar lazily).
|
||||
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
|
||||
|
||||
_sep = agent_config.get("invoke_separator", ".")
|
||||
body = IntegrationBase.resolve_command_refs(body, _sep)
|
||||
|
||||
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||
|
||||
if agent_config["extension"] == "/SKILL.md":
|
||||
@@ -505,12 +525,22 @@ class CommandRegistrar:
|
||||
project_root,
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
|
||||
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
|
||||
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root
|
||||
)
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
output = self.render_markdown_command(
|
||||
frontmatter, body, source_id, context_note
|
||||
)
|
||||
elif agent_config["format"] == "toml":
|
||||
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
|
||||
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root
|
||||
)
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
)
|
||||
output = self.render_toml_command(frontmatter, body, source_id)
|
||||
elif agent_config["format"] == "yaml":
|
||||
output = self.render_yaml_command(
|
||||
@@ -685,8 +715,11 @@ class CommandRegistrar:
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name, commands, source_id,
|
||||
source_dir, project_root,
|
||||
agent_name,
|
||||
commands,
|
||||
source_id,
|
||||
source_dir,
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
)
|
||||
if registered:
|
||||
|
||||
@@ -87,8 +87,10 @@ class ForgeIntegration(MarkdownIntegration):
|
||||
"strip_frontmatter_keys": ["handoffs"],
|
||||
"inject_name": True,
|
||||
"format_name": format_forge_command_name, # Custom name formatter
|
||||
"invoke_separator": "-",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
invoke_separator = "-"
|
||||
|
||||
def setup(
|
||||
self,
|
||||
@@ -133,6 +135,7 @@ class ForgeIntegration(MarkdownIntegration):
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
context_file=self.context_file or "",
|
||||
invoke_separator=self.invoke_separator,
|
||||
)
|
||||
|
||||
# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are
|
||||
|
||||
@@ -81,6 +81,29 @@ class TestInitIntegrationFlag:
|
||||
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
assert shared_manifest.exists()
|
||||
|
||||
def test_noninteractive_init_defaults_to_copilot(self, tmp_path, monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
import specify_cli
|
||||
|
||||
def fail_select(*_args, **_kwargs):
|
||||
raise AssertionError("non-interactive init should not open the integration picker")
|
||||
|
||||
monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select)
|
||||
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "noninteractive"
|
||||
result = runner.invoke(app, [
|
||||
"init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
||||
|
||||
def test_ai_copilot_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -196,7 +196,10 @@ class TestClaudeIntegration:
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
with patch("specify_cli.select_with_arrows", return_value="claude"):
|
||||
with (
|
||||
patch("specify_cli._stdin_is_interactive", return_value=True),
|
||||
patch("specify_cli.select_with_arrows", return_value="claude"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
|
||||
@@ -141,6 +141,7 @@ class TestForgeIntegration:
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
import re
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
@@ -157,6 +158,11 @@ class TestForgeIntegration:
|
||||
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
|
||||
# Frontmatter sections should be stripped
|
||||
assert "\nscripts:\n" not in content
|
||||
# Check Forge-specific: command references use hyphen notation, not dot notation
|
||||
assert not re.search(r"/speckit\.[a-z]", content), (
|
||||
f"{cmd_file.name} contains dot-notation command reference (/speckit.<cmd>); "
|
||||
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility"
|
||||
)
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan command must reference forge's context file."""
|
||||
@@ -224,6 +230,33 @@ class TestForgeIntegration:
|
||||
"checklist should contain {{parameters}} in User Input section"
|
||||
)
|
||||
|
||||
def test_command_refs_use_hyphen_notation(self, tmp_path):
|
||||
"""Verify all generated Forge command files use /speckit-foo, not /speckit.foo."""
|
||||
import re
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
|
||||
files_with_refs = []
|
||||
files_with_dot_refs = []
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
if re.search(r"/speckit-[a-z]", content):
|
||||
files_with_refs.append(cmd_file.name)
|
||||
if re.search(r"/speckit\.[a-z]", content):
|
||||
files_with_dot_refs.append(cmd_file.name)
|
||||
|
||||
assert files_with_dot_refs == [], (
|
||||
f"Files contain dot-notation command references: {files_with_dot_refs}. "
|
||||
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility."
|
||||
)
|
||||
assert len(files_with_refs) > 0, (
|
||||
"Expected at least one generated Forge command to contain /speckit-<cmd> reference, "
|
||||
"but none were found. Check that __SPECKIT_COMMAND_*__ tokens are being resolved."
|
||||
)
|
||||
|
||||
def test_name_field_uses_hyphenated_format(self, tmp_path):
|
||||
"""Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan)."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
@@ -401,3 +434,48 @@ class TestForgeCommandRegistrar:
|
||||
assert "name:" not in content, (
|
||||
"Windsurf should not inject name field - format_name callback should be Forge-only"
|
||||
)
|
||||
|
||||
def test_git_extension_command_uses_hyphen_notation(self, tmp_path):
|
||||
"""Verify the git extension's feature command uses /speckit-specify (not /speckit.specify) for Forge."""
|
||||
from pathlib import Path
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Locate the real git extension command source file
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
ext_dir = repo_root / "extensions" / "git"
|
||||
cmd_source = ext_dir / "commands" / "speckit.git.feature.md"
|
||||
assert cmd_source.exists(), (
|
||||
f"Git extension command source not found at {cmd_source}. "
|
||||
"Ensure extensions/git/commands/speckit.git.feature.md exists."
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{
|
||||
"name": "speckit.git.feature",
|
||||
"file": "commands/speckit.git.feature.md",
|
||||
}
|
||||
]
|
||||
|
||||
registered = registrar.register_commands(
|
||||
"forge",
|
||||
commands,
|
||||
"git",
|
||||
ext_dir,
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert "speckit.git.feature" in registered
|
||||
|
||||
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.git.feature.md"
|
||||
assert forge_cmd.exists(), "Expected Forge command file was not created"
|
||||
|
||||
content = forge_cmd.read_text(encoding="utf-8")
|
||||
assert "/speckit-specify" in content, (
|
||||
"Expected '/speckit-specify' (hyphen) in generated Forge git.feature command body, "
|
||||
"but it was not found. Check that __SPECKIT_COMMAND_SPECIFY__ is resolved correctly."
|
||||
)
|
||||
assert "/speckit.specify" not in content, (
|
||||
"Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. "
|
||||
"Forge requires hyphen notation for ZSH compatibility."
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ from pathlib import Path
|
||||
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP
|
||||
from specify_cli.extensions import CommandRegistrar
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@@ -199,3 +198,88 @@ class TestAgentConfigConsistency:
|
||||
def test_ai_help_includes_goose(self):
|
||||
"""CLI help text for --ai should include goose."""
|
||||
assert "goose" in AI_ASSISTANT_HELP
|
||||
|
||||
# --- invoke_separator propagation checks ---
|
||||
|
||||
def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self):
|
||||
"""Skills-based agents must expose invoke_separator='-' in AGENT_CONFIGS.
|
||||
|
||||
SkillsIntegration sets ``invoke_separator = "-"`` as a class attribute,
|
||||
but individual skills integrations (claude, codex, …) do not repeat it in
|
||||
their ``registrar_config`` dicts. ``_build_agent_configs()`` must
|
||||
propagate the class attribute so that ``register_commands()`` resolves
|
||||
``__SPECKIT_COMMAND_*__`` tokens with the correct hyphen separator.
|
||||
"""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
skills_agents = [
|
||||
key for key, c in cfg.items() if c.get("extension") == "/SKILL.md"
|
||||
]
|
||||
assert skills_agents, (
|
||||
"Expected at least one skills-based agent in AGENT_CONFIGS"
|
||||
)
|
||||
for agent in skills_agents:
|
||||
assert cfg[agent].get("invoke_separator") == "-", (
|
||||
f"Skills agent '{agent}' has invoke_separator="
|
||||
f"{cfg[agent].get('invoke_separator')!r} in AGENT_CONFIGS; "
|
||||
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
|
||||
)
|
||||
|
||||
def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path):
|
||||
"""__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit-<cmd>
|
||||
when registered for a skills-based agent (e.g. claude).
|
||||
|
||||
Regression guard: before the fix, _build_agent_configs() did not
|
||||
propagate invoke_separator from the integration class, so
|
||||
register_commands() fell back to '.' and emitted /speckit.specify instead
|
||||
of /speckit-specify for skills agents.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
ext_dir = repo_root / "extensions" / "git"
|
||||
cmd_source = ext_dir / "commands" / "speckit.git.feature.md"
|
||||
assert cmd_source.exists(), (
|
||||
f"Git extension command source not found at {cmd_source}"
|
||||
)
|
||||
assert "__SPECKIT_COMMAND_SPECIFY__" in cmd_source.read_text(
|
||||
encoding="utf-8"
|
||||
), (
|
||||
"Expected __SPECKIT_COMMAND_SPECIFY__ token in speckit.git.feature.md; "
|
||||
"check that the file uses the token rather than a hard-coded ref."
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{"name": "speckit.git.feature", "file": "commands/speckit.git.feature.md"}
|
||||
]
|
||||
|
||||
registered = registrar.register_commands(
|
||||
"claude",
|
||||
commands,
|
||||
"git",
|
||||
ext_dir,
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert "speckit.git.feature" in registered
|
||||
skill_file = (
|
||||
tmp_path / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
|
||||
)
|
||||
assert skill_file.exists(), (
|
||||
f"Expected Claude skill file not found at {skill_file}"
|
||||
)
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "/speckit-specify" in content, (
|
||||
"Expected '/speckit-specify' (hyphen) in generated Claude skill for git.feature; "
|
||||
"__SPECKIT_COMMAND_SPECIFY__ was not resolved with the correct separator."
|
||||
)
|
||||
# Negative lookbehind (?<![a-zA-Z0-9_]) excludes file-path occurrences
|
||||
# such as 'source: git:commands/speckit.git.feature.md' in frontmatter,
|
||||
# where the '/' is a path separator preceded by a word character.
|
||||
assert not re.search(r"(?<![a-zA-Z0-9_])/speckit\.[a-z]", content), (
|
||||
"Found dot-notation command ref (/speckit.<cmd>) in generated Claude skill. "
|
||||
"Skills agents must use hyphen notation."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user