Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot]
4986422c42 chore: bump version to 0.9.1 2026-06-02 12:32:53 +00:00
Quratulain-bilal
442a581358 fix(cli): pin UTF-8 encoding on init-options and .extensionignore I/O (#2686)
* fix(cli): pin UTF-8 encoding on init-options and .extensionignore I/O

``Path.read_text`` / ``Path.write_text`` default to the system locale
codec, which is cp1252 / gb2312 / cp932 on Windows. Two user-facing
file paths in spec-kit were calling them without an explicit
``encoding=`` argument:

  - ``src/specify_cli/__init__.py:400,412`` —
    ``save_init_options`` / ``load_init_options`` for
    ``.specify/init-options.json``. A peer machine with a different
    default locale (or a UTF-8 Unix CI runner reading a file written on
    a cp1252 Windows host) cannot decode the file, raising
    ``UnicodeDecodeError``. ``UnicodeDecodeError`` is a subclass of
    ``ValueError`` — not ``OSError`` / ``json.JSONDecodeError`` — so
    the existing fall-back ``except`` tuple in ``load_init_options``
    also misses it and the error propagates raw to the CLI.

  - ``src/specify_cli/extensions.py:764`` — ``.extensionignore``
    pattern reader. The very next line already normalises
    backslashes "so Windows-authored files work", proving the codebase
    expects Windows authors to write this file. Multibyte UTF-8
    patterns (Chinese filenames, accented directory names) silently
    mojibake when the host locale is not UTF-8, so the patterns fail
    to match and unintended files are shipped with the extension.

The sibling integration-catalog reader at
``src/specify_cli/integrations/catalog.py:150,156,193,202,374``
already pins ``encoding="utf-8"`` everywhere. PR #2280 fixed the
symmetric PowerShell-template BOM bug. This change brings the two
remaining drifted paths in line with that precedent.

Regression tests:

  - ``tests/test_presets.py::TestInitOptions`` — parametrized non-ASCII
    round-trip (CJK, Latin-1, Greek, emoji) plus a corrupted-file case
    that asserts the existing "fall back to {}" contract still holds
    when a peer file contains bytes invalid as UTF-8.
  - ``tests/test_extensions.py::TestExtensionIgnore`` — Japanese
    (``ドキュメント/``) and Latin-1 (``café/``) ignore patterns
    correctly exclude their directories during install.

* fix(cli): wrap .extensionignore decode error and tighten UTF-8 contract

Addresses Copilot review feedback on this PR.

Three issues, three fixes:

1. ``save_init_options`` now writes JSON with ``ensure_ascii=False``.
   Without that flag, ``json.dumps`` emits ASCII-only ``\uXXXX``
   escapes, which means the ``encoding="utf-8"`` pin on the
   surrounding ``Path.write_text`` makes no observable difference for
   any value we currently write. Flipping ``ensure_ascii`` makes the
   non-ASCII bytes hit the file directly, so the encoding pin becomes
   the thing that decides between cp1252 garbage and clean UTF-8 on
   Windows. The comment above the call now describes the real reason
   instead of the previously-misleading rationale Copilot flagged.

2. ``test_save_load_round_trip_preserves_non_ascii`` was a no-op under
   the old ``ensure_ascii=True`` writer (Copilot's second comment).
   Added ``test_save_writes_real_utf8_bytes`` that asserts the on-disk
   bytes contain the UTF-8 encoding of ``café`` (``0xC3 0xA9``), not
   its JSON escape form ``é``. Removing either
   ``ensure_ascii=False`` or ``encoding="utf-8"`` from the writer now
   breaks this test — the contract is pinned.

3. ``.extensionignore`` reader wraps ``UnicodeDecodeError`` as
   ``ValidationError`` with a pointer to the offending byte
   (Copilot's third comment). Mirrors
   ``ExtensionManifest._load_yaml``'s existing handler for
   ``extension.yml``. Adds
   ``test_extensionignore_invalid_utf8_raises_validation_error``
   asserting installation aborts with the wrapped error instead of a
   raw Python traceback.
2026-06-02 07:19:11 -05:00
Teknium
ed10b32014 docs: list Hermes in supported integrations table (#2768)
The Hermes Agent integration ships in the CLI (src/specify_cli/integrations/hermes/)
and is registered in the catalog, but the supported-agents table in the
integrations reference omitted it. Add the row so the docs match the shipped
integration.
2026-06-01 15:04:04 -05:00
WOLIKIMCHENG
14da893e4f fix(copilot): resolve active spec template (#2765)
Co-authored-by: root <kinsonnee@gmail.com>
2026-06-01 14:49:02 -05:00
Manfred Riem
39925ac084 fix: add missing agent-context extension entries to Cline _expected_files (#2797)
TestClineIntegration._expected_files() overrides the base-class version but
was not updated when the bundled agent-context extension files were added to
test_integration_base_markdown.py, causing test_complete_file_inventory_sh
and test_complete_file_inventory_ps to fail.

Fixes #2796
2026-06-01 14:31:25 -05:00
Manfred Riem
866424385c Add spec-kit-linear extension to community catalog (#2795)
* Add spec-kit-linear extension to community catalog

Add linear extension submitted by @ashbrener to:\n- extensions/catalog.community.json\n- docs/community/extensions.md\n\nCloses #2778

* Address PR review feedback for spec-kit-linear entry

- Use Unicode arrow (→) in catalog/docs description\n- Move docs row to alphabetical Spec section

* Address follow-up review naming/order feedback

- Use human-friendly display name: Linear Integration\n- Move docs row to alphabetical L section
2026-06-01 11:50:59 -05:00
Pedro Barbosa
44aac9f6e4 feat: add native Cline integration (#2508)
* test: strip ansi to make asserts work

* feat: add native Cline integration
2026-06-01 11:20:48 -05:00
bigsmartben
4230685e26 Update workflow-preset community catalog entry (#2756) 2026-06-01 11:08:14 -05:00
Manfred Riem
258dd8e380 chore: release 0.9.0, begin 0.9.1.dev0 development (#2794)
* chore: bump version to 0.9.0

* chore: begin 0.9.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-01 10:46:11 -05:00
Manfred Riem
122a794d83 Add RAG Azure Builder extension to community catalog (#2793)
Add rag-azure-builder extension submitted by @Sertxito to:\n- extensions/catalog.community.json\n- docs/community/extensions.md\n\nCloses #2665
2026-06-01 10:45:50 -05:00
23 changed files with 1051 additions and 41 deletions

View File

@@ -2,6 +2,34 @@
<!-- insert new changelog below this comment -->
## [0.9.1] - 2026-06-02
### Changed
- fix(cli): pin UTF-8 encoding on init-options and .extensionignore I/O (#2686)
- docs: list Hermes in supported integrations table (#2768)
- fix(copilot): resolve active spec template (#2765)
- fix: add missing agent-context extension entries to Cline _expected_files (#2797)
- Add spec-kit-linear extension to community catalog (#2795)
- feat: add native Cline integration (#2508)
- Update workflow-preset community catalog entry (#2756)
- chore: release 0.9.0, begin 0.9.1.dev0 development (#2794)
- Add RAG Azure Builder extension to community catalog (#2793)
## [0.9.0] - 2026-06-01
### Changed
- chore: recompile workflow lock files (#2774)
- Add Multi-Sites Spec Kit extension to community catalog (#2791)
- Update Product Spec Extension to v0.8.3 (#2790)
- Publish May 2026 Newsletter (#2787)
- fix: move URL install confirmation prompt before spinner (#2783) (#2784)
- Update Reqnroll BDD extension to v1.1.0 (#2775)
- Extract agent context updates into bundled agent-context extension (#2546)
- chore(deps): bump actions/setup-dotnet from 5.2.0 to 5.3.0 (#2755)
- chore: release 0.8.18, begin 0.8.19.dev0 development (#2766)
## [0.8.18] - 2026-05-29
### Changed

View File

@@ -56,6 +56,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear](https://github.com/ashbrener/spec-kit-linear) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
@@ -83,6 +84,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |

View File

@@ -27,6 +27,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 23 templates, 7 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md).

View File

@@ -10,6 +10,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` |
| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent |
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
@@ -18,6 +19,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | |
| [Junie](https://junie.jetbrains.com/) | `junie` | |

View File

@@ -1246,6 +1246,39 @@
"created_at": "2026-03-17T00:00:00Z",
"updated_at": "2026-03-17T00:00:00Z"
},
"linear": {
"name": "Linear Integration",
"id": "linear",
"description": "Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional).",
"author": "Ash Brener",
"version": "0.2.0",
"download_url": "https://github.com/ashbrener/spec-kit-linear/archive/refs/tags/v0.2.0.zip",
"repository": "https://github.com/ashbrener/spec-kit-linear",
"homepage": "https://github.com/ashbrener/spec-kit-linear",
"documentation": "https://github.com/ashbrener/spec-kit-linear/blob/main/README.md",
"changelog": "https://github.com/ashbrener/spec-kit-linear/releases",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 5,
"hooks": 6
},
"tags": [
"issue-tracking",
"linear",
"tasks-sync",
"lifecycle-mirror",
"memory",
"cross-repo"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
},
"m365": {
"name": "Microsoft 365 Integration",
"id": "m365",
@@ -2081,6 +2114,38 @@
"created_at": "2026-04-01T00:00:00Z",
"updated_at": "2026-04-01T00:00:00Z"
},
"rag-azure-builder": {
"name": "RAG Azure Builder",
"id": "rag-azure-builder",
"description": "Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows.",
"author": "Sertxito",
"version": "1.2.0",
"download_url": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder/archive/refs/tags/v1.2.0.zip",
"repository": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder",
"homepage": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder",
"documentation": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder#readme",
"changelog": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
},
"provides": {
"commands": 5,
"hooks": 0
},
"tags": [
"azure",
"rag",
"search",
"onboarding",
"cost-optimization"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
},
"ralph": {
"name": "Ralph Loop",
"id": "ralph",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-29T00:00:00Z",
"updated_at": "2026-05-13T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -12,6 +12,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "anthropic"]
},
"cline": {
"id": "cline",
"name": "Cline",
"version": "1.0.0",
"description": "Cline IDE integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"copilot": {
"id": "copilot",
"name": "GitHub Copilot",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-05-27T00:00:00Z",
"updated_at": "2026-05-28T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -593,11 +593,11 @@
"workflow-preset": {
"name": "Workflow Preset",
"id": "workflow-preset",
"version": "1.2.0",
"version": "1.3.1",
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration.",
"author": "bigsmartben",
"repository": "https://github.com/bigsmartben/spec-kit-workflow-preset",
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/archive/refs/tags/v1.2.0.zip",
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.1/spec-kit-workflow-preset-v1.3.1.zip",
"homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset",
"documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md",
"license": "MIT",
@@ -605,8 +605,8 @@
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"templates": 23,
"commands": 7
"templates": 22,
"commands": 8
},
"tags": [
"behavior",
@@ -616,7 +616,7 @@
"handoff"
],
"created_at": "2026-05-27T00:00:00Z",
"updated_at": "2026-05-27T00:00:00Z"
"updated_at": "2026-05-28T00:00:00Z"
}
}
}

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.8.19.dev0"
version = "0.9.1"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [

View File

@@ -287,7 +287,28 @@ def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
"""
dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(json.dumps(options, indent=2, sort_keys=True))
# Write JSON as real UTF-8 instead of ``\uXXXX`` escape sequences
# (``ensure_ascii=False``) and pin the file encoding to match.
#
# The default ``json.dumps`` output is ASCII-only — any non-ASCII
# character is encoded as a ``\uXXXX`` escape — so without the
# ``ensure_ascii=False`` flip below the encoding pin alone would be
# a no-op for any payload we plausibly write today. We pair the two
# so the on-disk bytes match a human's expectation of "this file is
# UTF-8" (greppable, readable in editors that don't decode JSON
# escapes, friendly to peers running ``cat`` or ``Get-Content``) and
# so the encoding pin is a real contract instead of a future hedge.
#
# ``Path.write_text`` without ``encoding=`` falls back to the system
# locale codec (cp1252 / gb2312 / cp932 on Windows), which would
# mis-encode non-ASCII bytes locally and produce a file a peer with
# a different locale couldn't decode. The sibling integration-
# catalog writer in ``integrations/catalog.py`` pins
# ``encoding="utf-8"`` for the same reason.
dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
encoding="utf-8",
)
def load_init_options(project_path: Path) -> dict[str, Any]:
@@ -299,8 +320,17 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
# Match the explicit UTF-8 used by ``save_init_options``; without
# it ``read_text`` falls back to the system codec on Windows and
# raises ``UnicodeDecodeError`` on any file containing the
# multi-byte UTF-8 sequences ``save_init_options`` now writes
# directly. ``UnicodeDecodeError`` is a subclass of
# ``ValueError``, not ``OSError`` / ``json.JSONDecodeError``, so
# it must be listed explicitly here to preserve the existing
# "fall back to empty dict" contract for corrupted / foreign-
# codec files.
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return {}
@@ -3241,9 +3271,17 @@ def extension_add(
for warning in manifest.warnings:
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")
is_cline = load_init_options(project_root).get("ai") == "cline"
if is_cline:
from specify_cli.integrations.cline import format_cline_command_name
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
console.print(f"{cmd['name']} - {cmd.get('description', '')}")
cmd_name = cmd['name']
if is_cline:
cmd_name = format_cline_command_name(cmd_name)
console.print(f"{cmd_name} - {cmd.get('description', '')}")
# Report agent skills registration
reg_meta = manager.registry.get(manifest.id)

View File

@@ -67,6 +67,33 @@ class CommandRegistrar:
except ImportError:
pass # Circular import during module init; retry on next access
@staticmethod
def _hyphenate_frontmatter_refs(val: Any) -> Any:
"""Recursively find any dotted references starting with speckit. and hyphenate them."""
if isinstance(val, dict):
return {
k: CommandRegistrar._hyphenate_frontmatter_refs(v)
for k, v in val.items()
}
elif isinstance(val, list):
return [CommandRegistrar._hyphenate_frontmatter_refs(x) for x in val]
elif isinstance(val, str):
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
val,
)
return val
@staticmethod
def _hyphenate_body_refs(body: str) -> str:
"""Hyphenate dotted speckit references in command body text."""
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
body,
)
@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter from Markdown content.
@@ -408,6 +435,9 @@ class CommandRegistrar:
) -> str:
"""Compute the on-disk command or skill name for an agent."""
if agent_config["extension"] != "/SKILL.md":
format_name = agent_config.get("format_name")
if format_name:
return format_name(cmd_name)
return cmd_name
short_name = cmd_name
@@ -437,6 +467,13 @@ class CommandRegistrar:
if not normalized.is_relative_to(base_normalized):
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
@staticmethod
def _is_safe_command_name(name: str) -> bool:
"""Reject names that could escape the commands directory via path traversal."""
if os.path.sep in name or "/" in name or "\\" in name:
return False
return os.path.normpath(name) == name
def register_commands(
self,
agent_name: str,
@@ -482,9 +519,11 @@ class CommandRegistrar:
commands_dir.mkdir(parents=True, exist_ok=True)
registered = []
is_cline_ext = agent_name == "cline" and source_id != "core"
for cmd_info in commands:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
cmd_file = cmd_info["file"]
source_file = source_dir / cmd_file
@@ -516,6 +555,10 @@ class CommandRegistrar:
format_name = agent_config.get("format_name")
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name
if is_cline_ext:
frontmatter = self._hyphenate_frontmatter_refs(frontmatter)
body = self._hyphenate_body_refs(body)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
@@ -585,7 +628,7 @@ class CommandRegistrar:
registered.append(cmd_name)
for alias in cmd_info.get("aliases", []):
for alias in aliases:
alias_output_name = self._compute_output_name(
agent_name, alias, agent_config
)
@@ -909,22 +952,32 @@ class CommandRegistrar:
output_name = self._compute_output_name(
agent_name, cmd_name, agent_config
)
names_to_clean = [output_name]
if output_name != cmd_name and self._is_safe_command_name(cmd_name):
names_to_clean.append(cmd_name)
for target_dir in dirs_to_clean:
cmd_file = (
target_dir / f"{output_name}{agent_config['extension']}"
)
if cmd_file.exists() or cmd_file.is_symlink():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
# SKILL.md). Remove the parent dir when it becomes
# empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass
for name in names_to_clean:
cmd_file = (
target_dir / f"{name}{agent_config['extension']}"
)
try:
self._ensure_inside(cmd_file, target_dir)
except ValueError:
continue
if cmd_file.exists() or cmd_file.is_symlink():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
# SKILL.md). Remove the parent dir when it becomes
# empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass
if agent_name == "copilot":
prompt_file = (

View File

@@ -726,6 +726,7 @@ def register(app: typer.Typer) -> None:
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
devin_skill_mode = selected_ai == "devin"
cline_skill_mode = selected_ai == "cline"
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
if codex_skill_mode and not ai_skills:
@@ -749,7 +750,7 @@ def register(app: typer.Typer) -> None:
return f"/speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or cline_skill_mode:
return f"/speckit-{name}"
return f"/speckit.{name}"

View File

@@ -761,7 +761,28 @@ class ExtensionManager:
if not ignore_file.exists():
return None
lines: List[str] = ignore_file.read_text().splitlines()
# Pin UTF-8 explicitly: ``Path.read_text`` defaults to the system
# locale codec on Windows (cp1252 / gb2312 / cp932), which silently
# corrupts multibyte patterns when the file is shared across
# machines with different locales. The next line already
# normalises backslashes "so Windows-authored files work" — the
# codebase already expects Windows authors to write this file.
#
# A file that is not valid UTF-8 is a user-authoring mistake, so
# surface it as ``ValidationError`` with a pointer to the offending
# byte — the same pattern ``ExtensionManifest._load_yaml`` uses
# for ``extension.yml`` (see ``UnicodeDecodeError`` handler in
# this module). Without the wrap, the raw ``UnicodeDecodeError``
# would abort installation with a Python traceback instead of a
# clear message naming the file.
try:
raw = ignore_file.read_text(encoding="utf-8")
except UnicodeDecodeError as e:
raise ValidationError(
f".extensionignore is not valid UTF-8: {ignore_file} "
f"({e.reason} at byte {e.start})"
)
lines: List[str] = raw.splitlines()
# Normalise backslashes in patterns so Windows-authored files work
normalised: List[str] = []
@@ -2413,6 +2434,7 @@ class HookExecutor:
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
kimi_skill_mode = selected_ai == "kimi"
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
cline_mode = selected_ai == "cline"
skill_name = self._skill_name_from_command(command_id)
if codex_skill_mode and skill_name:
@@ -2423,6 +2445,10 @@ class HookExecutor:
return f"/skill:{skill_name}"
if cursor_skill_mode and skill_name:
return f"/{skill_name}"
if cline_mode:
from .integrations.cline import format_cline_command_name
return f"/{format_cline_command_name(command_id)}"
return f"/{command_id}"

View File

@@ -52,6 +52,7 @@ def _register_builtins() -> None:
from .auggie import AuggieIntegration
from .bob import BobIntegration
from .claude import ClaudeIntegration
from .cline import ClineIntegration
from .codebuddy import CodebuddyIntegration
from .codex import CodexIntegration
from .copilot import CopilotIntegration
@@ -85,6 +86,7 @@ def _register_builtins() -> None:
_register(AuggieIntegration())
_register(BobIntegration())
_register(ClaudeIntegration())
_register(ClineIntegration())
_register(CodebuddyIntegration())
_register(CodexIntegration())
_register(CopilotIntegration())

View File

@@ -0,0 +1,162 @@
"""Cline IDE integration."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from ..base import MarkdownIntegration
from ..manifest import IntegrationManifest
# Note injected into hook sections so Cline maps dot-notation command
# names (from extensions.yml) to the hyphenated slash commands it uses.
_HOOK_COMMAND_NOTE = (
"- When constructing slash commands from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
)
def format_cline_command_name(cmd_name: str) -> str:
"""Convert command name to Cline-compatible hyphenated format.
Cline handles slash-commands optimally when they use hyphens instead of dots.
This function converts dot-notation command names to hyphenated format.
The function is idempotent: already-formatted names are returned unchanged.
Examples:
>>> format_cline_command_name("plan")
'speckit-plan'
>>> format_cline_command_name("speckit.plan")
'speckit-plan'
>>> format_cline_command_name("speckit.git.commit")
'speckit-git-commit'
Args:
cmd_name: Command name in dot notation (speckit.foo.bar),
hyphenated format (speckit-foo-bar), or plain name (foo)
Returns:
Hyphenated command name with 'speckit-' prefix
"""
cmd_name = cmd_name.replace(".", "-")
if not cmd_name.startswith("speckit-"):
cmd_name = f"speckit-{cmd_name}"
return cmd_name
class ClineIntegration(MarkdownIntegration):
"""Integration for Cline IDE."""
key = "cline"
config = {
"name": "Cline",
"folder": ".clinerules/",
"commands_subdir": "workflows",
"install_url": "https://github.com/cline/cline",
"requires_cli": False,
}
registrar_config = {
"dir": ".clinerules/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"inject_name": True,
"format_name": format_cline_command_name,
"invoke_separator": "-",
}
context_file = ".clinerules/specify-rules.md"
invoke_separator = "-"
multi_install_safe = True
def command_filename(self, template_name: str) -> str:
"""Cline uses hyphenated filenames (e.g. speckit-git-commit.md)."""
return format_cline_command_name(template_name) + ".md"
def process_template(self, *args, **kwargs):
"""Ensure shared templates render Cline command references with hyphens."""
kwargs.setdefault("invoke_separator", self.invoke_separator)
return super().process_template(*args, **kwargs)
@staticmethod
def _inject_hook_command_note(content: str) -> str:
"""Insert a dot-to-hyphen note before each hook output instruction.
Targets the line ``- For each executable hook, output the following``
and inserts the note on the line before it, matching its indentation.
Skips if the note is already present.
"""
if "replace dots" in content:
return content
def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
eol = m.group(3)
return (
indent
+ _HOOK_COMMAND_NOTE.rstrip("\n")
+ eol
+ indent
+ instruction
+ eol
)
return re.sub(
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)
@staticmethod
def _rewrite_handoff_references(content: str) -> str:
"""Replace dot-notation agent references in handoffs with hyphens."""
return re.sub(
r"(?m)^(\s*agent:\s*)(speckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*)",
lambda m: f"{m.group(1)}{format_cline_command_name(m.group(2))}",
content,
)
def post_process_content(self, content: str) -> str:
"""Apply Cline-specific transformations to command content."""
updated = self._inject_hook_command_note(content)
updated = self._rewrite_handoff_references(updated)
return updated
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Cline commands and apply post-processing transformations."""
created = super().setup(project_root, manifest, parsed_options, **opts)
# Post-process generated command files
dest_dir = self.commands_dest(project_root).resolve()
for path in created:
# Only touch .md files under the commands directory
try:
path.resolve().relative_to(dest_dir)
except ValueError:
continue
if path.suffix != ".md":
continue
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")
updated = self.post_process_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
return created

View File

@@ -91,7 +91,8 @@ Given that feature description, do this:
**Create the directory and spec file**:
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
- Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
- Resolve the active `spec-template` through the Spec Kit preset/template resolution stack (equivalent to `specify preset resolve spec-template`)
- Copy the resolved `spec-template` file to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
- Persist the resolved path to `.specify/feature.json`:
```json
@@ -107,7 +108,7 @@ Given that feature description, do this:
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
- The spec directory and file are always created by this command, never by the hook
4. Load `templates/spec-template.md` to understand required sections.
4. Load the resolved active `spec-template` file to understand required sections.
5. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.

View File

@@ -330,6 +330,7 @@ class TestInitIntegrationFlag:
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
"""Console warning is displayed when files are skipped."""
from specify_cli import _install_shared_infra
from tests.conftest import strip_ansi
project = tmp_path / "warn-test"
project.mkdir()

View File

@@ -0,0 +1,223 @@
"""Tests for ClineIntegration."""
import os
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.cline import format_cline_command_name
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestClineCommandNameFormatter:
"""Test the Cline command name formatter."""
def test_simple_name_without_prefix(self):
"""Test formatting a simple name without 'speckit.' prefix."""
assert format_cline_command_name("plan") == "speckit-plan"
assert format_cline_command_name("tasks") == "speckit-tasks"
assert format_cline_command_name("specify") == "speckit-specify"
def test_name_with_speckit_prefix(self):
"""Test formatting a name that already has 'speckit.' prefix."""
assert format_cline_command_name("speckit.plan") == "speckit-plan"
assert format_cline_command_name("speckit.tasks") == "speckit-tasks"
def test_extension_command_name(self):
"""Test formatting extension command names with dots."""
assert (
format_cline_command_name("speckit.my-extension.example")
== "speckit-my-extension-example"
)
assert (
format_cline_command_name("my-extension.example")
== "speckit-my-extension-example"
)
def test_idempotent_already_hyphenated(self):
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
assert format_cline_command_name("speckit-plan") == "speckit-plan"
assert (
format_cline_command_name("speckit-my-extension-example")
== "speckit-my-extension-example"
)
class TestClineIntegration(MarkdownIntegrationTests):
KEY = "cline"
FOLDER = ".clinerules/"
COMMANDS_SUBDIR = "workflows"
REGISTRAR_DIR = ".clinerules/workflows"
CONTEXT_FILE = ".clinerules/specify-rules.md"
@pytest.mark.parametrize(
"cmd_name, expected_filename",
[
("plan", "speckit-plan.md"),
("speckit.plan", "speckit-plan.md"),
("speckit.git.commit", "speckit-git-commit.md"),
("speckit", "speckit-speckit.md"),
("speckitfoo", "speckit-speckitfoo.md"),
],
)
def test_cline_command_filename(self, cmd_name, expected_filename):
"""Verify Cline uses hyphenated filenames."""
cline = get_integration("cline")
assert cline.command_filename(cmd_name) == expected_filename
def test_cline_invoke_separator(self):
"""Verify Cline uses hyphen as invoke separator."""
cline = get_integration("cline")
assert cline.invoke_separator == "-"
assert cline.registrar_config["invoke_separator"] == "-"
def test_cline_name_injection_and_formatting(self):
"""Verify Cline has inject_name and format_name configured."""
cline = get_integration("cline")
assert cline.registrar_config["inject_name"] is True
assert cline.registrar_config["format_name"] == format_cline_command_name
def test_cline_handoff_rewrite(self):
"""Verify Cline rewrites agent: speckit.foo to agent: speckit-foo."""
cline = get_integration("cline")
content = "---\nagent: speckit.plan\n---\n"
rewritten = cline._rewrite_handoff_references(content)
assert rewritten == "---\nagent: speckit-plan\n---\n"
def test_cline_hook_instruction_injection(self):
"""Verify Cline injects the dot-to-hyphen note for hooks."""
cline = get_integration("cline")
content = "- For each executable hook, output the following:\n"
injected = cline._inject_hook_command_note(content)
assert "replace dots (`.`) with hyphens (`-`)" in injected
assert "- For each executable hook, output the following:" in injected
# -- Overrides for MarkdownIntegrationTests ---------------------------
def test_setup_creates_files(self, tmp_path):
from specify_cli.integrations.manifest import IntegrationManifest
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [
f
for f in created
if "scripts" not in f.parts
and f.suffix == ".md"
and f.name != i.context_file
]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit-")
assert f.name.endswith(".md")
specify_file = next(
(f for f in cmd_files if f.name == "speckit-specify.md"), None
)
assert specify_file is not None
specify_contents = specify_file.read_text(encoding="utf-8")
assert "/speckit-plan" in specify_contents
assert "/speckit.plan" not in specify_contents
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir()
commands = sorted(cmd_dir.glob("speckit-*"))
assert len(commands) > 0
def _expected_files(self, script_variant: str) -> list[str]:
"""Override to expect hyphenated speckit- prefix."""
i = get_integration(self.KEY)
cmd_dir = i.registrar_config["dir"]
files = []
# Command files
for stem in (
self.COMMANDS_SUBDIR_STEMS
if hasattr(self, "COMMANDS_SUBDIR_STEMS")
else self.COMMAND_STEMS
):
files.append(f"{cmd_dir}/speckit-{stem.replace('.', '-')}.md")
# Framework files
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in [
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in [
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")
for name in [
"checklist-template.md",
"constitution-template.md",
"plan-template.md",
"spec-template.md",
"tasks-template.md",
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")
files.append(".specify/workflows/workflow-registry.json")
# Bundled agent-context extension
files.append(".specify/extensions.yml")
files.append(".specify/extensions/.registry")
files.append(".specify/extensions/agent-context/README.md")
files.append(".specify/extensions/agent-context/agent-context-config.yml")
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
files.append(".specify/extensions/agent-context/extension.yml")
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
# Agent context file (if set)
if i.context_file:
files.append(i.context_file)
return sorted(files)

View File

@@ -147,6 +147,21 @@ class TestCopilotIntegration:
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
assert "\nscripts:\n" not in content
def test_specify_agent_resolves_active_spec_template(self, tmp_path):
"""Generated specify agent must not hardcode the core spec template."""
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
assert "specify preset resolve spec-template" in content
assert "resolved active `spec-template`" in content
assert "Copy `.specify/templates/spec-template.md`" not in content
assert "Load `.specify/templates/spec-template.md`" not in content
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference copilot's context file."""
from specify_cli.integrations.copilot import CopilotIntegration

View File

@@ -330,7 +330,7 @@ class TestForgeCommandRegistrar:
assert "speckit.my-extension.example" in registered
# Check the generated file has hyphenated name in frontmatter
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md"
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md"
assert forge_cmd.exists()
content = forge_cmd.read_text(encoding="utf-8")
@@ -378,7 +378,7 @@ class TestForgeCommandRegistrar:
)
# Check the alias file has hyphenated name in frontmatter
alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md"
alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md"
assert alias_file.exists()
content = alias_file.read_text(encoding="utf-8")
@@ -467,7 +467,7 @@ class TestForgeCommandRegistrar:
assert "speckit.git.feature" in registered
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.git.feature.md"
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")

View File

@@ -185,7 +185,8 @@ class TestIntegrationInstall:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
normalized = " ".join(result.output.split())
output = strip_ansi(result.output)
normalized = " ".join(output.split())
assert "already installed" in normalized
assert "specify integration use codex" in normalized
assert "specify integration upgrade codex" in normalized

View File

@@ -1315,6 +1315,42 @@ $ARGUMENTS
assert not (skills_dir / "speckit-specify" / "SKILL.md").exists()
assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists()
def test_unregister_commands_handles_legacy_dot_notated_files(self, project_dir):
"""Unregister should clean up both legacy dot-notated and new hyphenated files."""
# 1. Mock an agent that uses hyphenated/formatted names (e.g. Cline)
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
# We'll use "cline" since it has format_name
assert "cline" in registrar.AGENT_CONFIGS
cline_config = registrar.AGENT_CONFIGS["cline"]
cline_dir = project_dir / cline_config["dir"]
cline_dir.mkdir(parents=True, exist_ok=True)
# 2. Create both legacy and new files
# Command name: speckit.git.commit
# Formatted name: speckit-git-commit
cmd_name = "speckit.git.commit"
formatted_name = "speckit-git-commit"
legacy_file = cline_dir / f"{cmd_name}.md"
formatted_file = cline_dir / f"{formatted_name}.md"
legacy_file.write_text("legacy body")
formatted_file.write_text("formatted body")
assert legacy_file.exists()
assert formatted_file.exists()
# 3. Call unregister
registrar.unregister_commands({"cline": [cmd_name]}, project_dir)
# 4. Verify both are gone
assert not legacy_file.exists(), "Legacy dot-notated file should be removed"
assert (
not formatted_file.exists()
), "Formatted hyphenated file should be removed"
def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir):
"""A Codex project under .agents/skills should not implicitly activate Amp."""
skills_dir = project_dir / ".agents" / "skills"
@@ -3322,9 +3358,13 @@ class TestExtensionIgnore:
else:
p.write_text(content)
# Write .extensionignore
# Write .extensionignore. Pinned to UTF-8 so non-ASCII patterns
# in tests (see ``test_extensionignore_utf8_patterns``) survive
# the round-trip on Windows runners with non-UTF-8 default locales.
if ignore_content is not None:
(ext_dir / ".extensionignore").write_text(ignore_content)
(ext_dir / ".extensionignore").write_text(
ignore_content, encoding="utf-8"
)
return ext_dir
@@ -3554,6 +3594,73 @@ class TestExtensionIgnore:
assert (dest / "docs" / "guide.md").exists()
assert not (dest / "docs" / "internal" / "draft.md").exists()
def test_extensionignore_utf8_patterns(self, temp_dir, valid_manifest_data):
"""Non-ASCII patterns in .extensionignore work on every locale.
``Path.read_text`` defaults to the system locale codec on Windows
(cp1252 / gb2312 / cp932). Without an explicit ``encoding="utf-8"``,
a pattern like ``ドキュメント/`` written by a UTF-8 host becomes
mojibake on a cp1252 host and silently fails to match — leaking
files the author intended to exclude. The existing
``test_extensionignore_windows_backslash_patterns`` already shows
the codebase treats this as a Windows-author-friendly file; UTF-8
is part of that same contract.
"""
ext_dir = self._make_extension(
temp_dir,
valid_manifest_data,
extra_files={
"ドキュメント/private.md": "secret",
"ドキュメント/public.md": "public",
"docs/guide.md": "# Guide",
"café/résumé.txt": "draft",
},
ignore_content="ドキュメント/\ncafé/\n",
)
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
dest = proj_dir / ".specify" / "extensions" / "test-ext"
# Multibyte patterns excluded.
assert not (dest / "ドキュメント").exists()
assert not (dest / "café").exists()
# ASCII path with no matching pattern is unaffected.
assert (dest / "docs" / "guide.md").exists()
def test_extensionignore_invalid_utf8_raises_validation_error(
self, temp_dir, valid_manifest_data
):
"""A non-UTF-8 ``.extensionignore`` surfaces as ``ValidationError``.
Pinning ``encoding="utf-8"`` on the reader means an
``.extensionignore`` written in some other codec (cp1252, etc.)
now triggers ``UnicodeDecodeError`` instead of silently
mojibake-ing patterns. Wrap that exception as ``ValidationError``
with a pointer to the offending byte — the same pattern
``ExtensionManifest._load_yaml`` uses for ``extension.yml`` —
so installation aborts with a user-friendly message instead of a
raw Python traceback.
"""
ext_dir = self._make_extension(temp_dir, valid_manifest_data)
# Write an .extensionignore whose bytes are not valid UTF-8.
# 0xE9 is 'é' in cp1252 but an invalid lead byte in UTF-8.
(ext_dir / ".extensionignore").write_bytes(b"caf\xe9/\n")
proj_dir = temp_dir / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
manager = ExtensionManager(proj_dir)
with pytest.raises(
ValidationError, match=r"\.extensionignore is not valid UTF-8"
):
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data):
"""'*' should NOT match across directory boundaries (gitignore semantics)."""
ext_dir = self._make_extension(
@@ -4616,6 +4723,43 @@ class TestHookInvocationRendering:
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "$speckit-tasks"
def test_cline_hooks_render_hyphenated_invocation(self, project_dir):
"""Cline projects should render /speckit-* invocations."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "cline"}))
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "speckit.tasks",
"optional": False,
}
)
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "/speckit-tasks"
def test_cline_hooks_render_extension_command(self, project_dir):
"""Cline projects should render /speckit-my-ext-cmd for extension hooks."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "cline"}))
hook_executor = HookExecutor(project_dir)
# Test with a non-speckit. command
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "my-extension.do-something",
"optional": False,
}
)
assert execution["command"] == "my-extension.do-something"
assert execution["invocation"] == "/speckit-my-extension-do-something"
def test_non_skill_command_keeps_slash_invocation(self, project_dir):
"""Custom hook commands should keep slash invocation style."""
init_options = project_dir / ".specify" / "init-options.json"
@@ -4751,3 +4895,157 @@ class TestExtensionRemoveCLI:
)
assert "2 commands" in result.output
class TestClineExtensionHyphenation:
"""Test that Cline integration uses hyphenated commands and frontmatter references."""
def _setup_mock_extension(self, tmp_path, ai_name):
import yaml
import json
# 1. Setup mock project
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(json.dumps({"ai": ai_name}), encoding="utf-8")
if ai_name == "cline":
commands_dest_dir = project_dir / ".clinerules" / "workflows"
else:
commands_dest_dir = project_dir / ".agents" / "commands"
commands_dest_dir.mkdir(parents=True, exist_ok=True)
# 2. Setup mock extension directory
ext_dir = tmp_path / "mock-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "mock-ext",
"name": "Mock Extension",
"version": "1.0.0",
"description": f"Mock extension for {ai_name} tests",
"author": "Tester",
"repository": "https://github.com/test/mock-ext",
"license": "MIT",
},
"requires": {
"speckit_version": ">=0.1.0",
},
"provides": {
"commands": [
{
"name": "speckit.mock-ext.hello",
"file": "commands/hello.md",
"description": "Test hello command",
"aliases": ["speckit.mock-ext.greet"]
}
]
}
}
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
# Command file with dotted speckit references in frontmatter and body
cmd_content = """---
description: "Test hello command"
agent: speckit.tasks
handoffs:
- agent: speckit.iterate.start
message: "Hand off to start"
---
# Test Hello Command
Please refer to speckit.mock-ext.greet for instructions.
$ARGUMENTS
"""
(commands_dir / "hello.md").write_text(cmd_content, encoding="utf-8")
return project_dir, ext_dir, commands_dest_dir
def test_cline_extension_hyphenation(self, tmp_path):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.agents import CommandRegistrar
project_dir, ext_dir, cline_workflows_dir = self._setup_mock_extension(tmp_path, "cline")
# 3. Run specify extension add
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False
)
# Verify CLI printed hyphenated commands
# Note: We assert that the primary command 'speckit-mock-ext-hello' is printed,
# but we do not assert that the alias 'speckit-mock-ext-greet' is printed in the console
# because manifest.commands only lists primary commands.
assert "speckit-mock-ext-hello" in result.output
assert "speckit.mock-ext.hello" not in result.output
# Verify on-disk command names are hyphenated
hello_file = cline_workflows_dir / "speckit-mock-ext-hello.md"
greet_file = cline_workflows_dir / "speckit-mock-ext-greet.md"
assert hello_file.exists()
assert greet_file.exists()
# Verify frontmatter in the generated files is recursively hyphenated
hello_text = hello_file.read_text(encoding="utf-8")
hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text)
assert hello_fm["agent"] == "speckit-tasks"
assert hello_fm["handoffs"][0]["agent"] == "speckit-iterate-start"
# Verify body references are hyphenated for Cline
assert "speckit-mock-ext-greet" in hello_body
assert "speckit.mock-ext.greet" not in hello_body
def test_non_cline_extension_no_hyphenation(self, tmp_path):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
from specify_cli.agents import CommandRegistrar
project_dir, ext_dir, claude_commands_dir = self._setup_mock_extension(tmp_path, "claude")
# 3. Run specify extension add
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False
)
# Verify CLI printed dotted commands
# Note: We assert that the primary command 'speckit.mock-ext.hello' is printed,
# but we do not assert that the alias 'speckit.mock-ext.greet' is printed in the console
# because manifest.commands only lists primary commands.
assert "speckit.mock-ext.hello" in result.output
assert "speckit-mock-ext-hello" not in result.output
# Verify on-disk command names are dotted
hello_file = claude_commands_dir / "speckit.mock-ext.hello.md"
greet_file = claude_commands_dir / "speckit.mock-ext.greet.md"
assert hello_file.exists()
assert greet_file.exists()
# Verify frontmatter references are still dotted
hello_text = hello_file.read_text(encoding="utf-8")
hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text)
assert hello_fm["agent"] == "speckit.tasks"
assert hello_fm["handoffs"][0]["agent"] == "speckit.iterate.start"
# Verify body references are still dotted for non-Cline
assert "speckit.mock-ext.greet" in hello_body
assert "speckit-mock-ext-greet" not in hello_body

View File

@@ -2269,6 +2269,85 @@ class TestInitOptions:
assert load_init_options(project_dir) == {}
@pytest.mark.parametrize(
"value",
["名前-プロジェクト", "café-résumé", "Ωmega-Δelta", "🚀-launch"],
)
def test_save_load_round_trip_preserves_non_ascii(self, project_dir, value):
"""Non-ASCII values round-trip via explicit UTF-8 encoding.
``Path.write_text`` / ``Path.read_text`` default to the system
locale codec on Windows (cp1252 / gb2312 / cp932). Without
``encoding="utf-8"`` pinned on both ends, a project name like
``café`` written on a UTF-8 host becomes garbled or unreadable on
a cp1252 host (and vice versa). Pin UTF-8 explicitly so init
options round-trip across machines and CI.
Note: this test only meaningfully exercises the encoding pin
because ``save_init_options`` now writes JSON with
``ensure_ascii=False`` — otherwise ``json.dumps`` would output
ASCII-only ``\\uXXXX`` escapes and the encoding pin would be a
no-op for any value here. ``test_save_writes_real_utf8_bytes``
below asserts that contract directly.
"""
from specify_cli import save_init_options, load_init_options
save_init_options(project_dir, {"ai": "claude", "project_name": value})
loaded = load_init_options(project_dir)
assert loaded["project_name"] == value
def test_save_writes_real_utf8_bytes(self, project_dir):
"""The on-disk file contains real UTF-8 bytes, not ``\\uXXXX`` escapes.
Pinning ``encoding="utf-8"`` on ``write_text`` only makes a
difference when the serialiser actually emits non-ASCII
characters. With ``ensure_ascii=False`` on ``json.dumps`` the
non-ASCII bytes hit the file, so the encoding pin is the thing
that decides between cp1252 garbage and clean UTF-8 on Windows.
This test pins that behaviour: the on-disk bytes are valid UTF-8
and contain the multi-byte encoding of ``café``, not its
``\\u00e9`` escape form. Reviewers can verify that removing
``ensure_ascii=False`` or ``encoding="utf-8"`` from the writer
breaks this test, which is what Copilot's review pointed out the
original round-trip test failed to do.
"""
from specify_cli import save_init_options
save_init_options(project_dir, {"project_name": "café"})
opts_file = project_dir / ".specify" / "init-options.json"
raw = opts_file.read_bytes()
# 'café' in UTF-8 ends with bytes 0xC3 0xA9 ('é'). The cp1252
# encoding of 'é' is the single byte 0xE9. The JSON-escape form
# would be the 6-byte literal '\\u00e9'. We assert the UTF-8 form
# is present so the test pins the actual contract.
assert b"caf\xc3\xa9" in raw, (
"Expected UTF-8 bytes for 'café' in the on-disk file, "
f"got: {raw!r}"
)
# And the whole file decodes cleanly as UTF-8.
raw.decode("utf-8")
def test_load_returns_empty_on_locale_corrupted_file(self, project_dir):
"""A file written in a non-UTF-8 codec falls back to {}, not crash.
Simulates a file produced by an old client (or by a peer machine
with a different default locale) that contains bytes invalid as
UTF-8. ``load_init_options`` should fall back to ``{}`` per the
existing contract — never propagate a raw ``UnicodeDecodeError``
to the CLI surface.
"""
from specify_cli import load_init_options
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
# 0xE9 is 'é' in cp1252 but an invalid lead byte in UTF-8.
opts_file.write_bytes(b'{"project_name": "caf\xe9"}')
assert load_init_options(project_dir) == {}
class TestPresetSkills:
"""Tests for preset skill registration and unregistration.

View File

@@ -520,6 +520,7 @@ class TestCommandStep:
assert result.output["integration"] == "gemini"
def test_step_override_model(self):
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext
@@ -531,10 +532,12 @@ class TestCommandStep:
"model": "opus-4",
"input": {},
}
result = step.execute(config, ctx)
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
result = step.execute(config, ctx)
assert result.output["model"] == "opus-4"
def test_options_merge(self):
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext
@@ -546,7 +549,8 @@ class TestCommandStep:
"options": {"thinking-budget": 32768},
"input": {},
}
result = step.execute(config, ctx)
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
result = step.execute(config, ctx)
assert result.output["options"]["max-tokens"] == 8000
assert result.output["options"]["thinking-budget"] == 32768