mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77bf078c9d | ||
|
|
b042d2a843 | ||
|
|
f846d6526c | ||
|
|
37e0e71b4e | ||
|
|
44ef11aa18 | ||
|
|
034fbfcbb4 | ||
|
|
8e76ff3d5c | ||
|
|
b6b74d4ccf | ||
|
|
0ef53eb91f | ||
|
|
0c975bbef7 | ||
|
|
59ffa918df |
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -85,6 +85,7 @@ body:
|
||||
- Kiro CLI
|
||||
- Lingma
|
||||
- Mistral Vibe
|
||||
- Oh My Pi
|
||||
- opencode
|
||||
- Pi Coding Agent
|
||||
- Qoder CLI
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -79,6 +79,7 @@ body:
|
||||
- Kiro CLI
|
||||
- Lingma
|
||||
- Mistral Vibe
|
||||
- Oh My Pi
|
||||
- opencode
|
||||
- Pi Coding Agent
|
||||
- Qoder CLI
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,6 +2,21 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.11.7] - 2026-06-24
|
||||
|
||||
### Changed
|
||||
|
||||
- feat(extensions): verify catalog archive sha256 before install (#3080)
|
||||
- fix(workflows): validate requires keys and reject phantom permissions gate (#3079)
|
||||
- fix(scripts): use case-sensitive match for acronym retention in PS branch names (#3130)
|
||||
- feat(integrations): add omp support (#3107)
|
||||
- fix: render valid TOML when a command body contains backslashes (#3135)
|
||||
- harden: reject shell=True in run_command (#3132)
|
||||
- docs: add monorepo guide (#3084)
|
||||
- fix(scripts): send check-prerequisites.ps1 errors to stderr (#3123)
|
||||
- fix: write Codex dev skills as files (#2988)
|
||||
- chore: release 0.11.6, begin 0.11.7.dev0 development (#3121)
|
||||
|
||||
## [0.11.6] - 2026-06-23
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -403,7 +403,7 @@ specify init . --force --integration copilot
|
||||
specify init --here --force --integration copilot
|
||||
```
|
||||
|
||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --integration copilot --ignore-agent-tools
|
||||
|
||||
111
docs/guides/monorepo.md
Normal file
111
docs/guides/monorepo.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Using Spec Kit in a Monorepo
|
||||
|
||||
A Spec Kit project is **directory-scoped**: the project is whichever directory
|
||||
contains `.specify/`. A monorepo can hold several independent Spec Kit projects
|
||||
under one repository root, each with its own `.specify/`, `specs/`, constitution,
|
||||
and feature numbering.
|
||||
|
||||
Root resolution already prefers the **nearest** `.specify/` over the Git
|
||||
toplevel, so commands run from inside a member project resolve to that project,
|
||||
not the repo root.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
my-monorepo/
|
||||
├── .git/ # one Git repository at the root
|
||||
├── apps/
|
||||
│ ├── web/
|
||||
│ │ └── .specify/ # Spec Kit project "web"
|
||||
│ │ └── memory/constitution.md
|
||||
│ └── api/
|
||||
│ └── .specify/ # Spec Kit project "api"
|
||||
│ └── memory/constitution.md
|
||||
└── packages/
|
||||
└── ui/
|
||||
└── .specify/ # Spec Kit project "ui"
|
||||
```
|
||||
|
||||
Initialize each member project independently:
|
||||
|
||||
```bash
|
||||
specify init apps/web --integration claude
|
||||
specify init apps/api --integration claude
|
||||
```
|
||||
|
||||
Each project keeps its own `specs/` directory and numbers features
|
||||
independently (`apps/web/specs/001-…`, `apps/api/specs/001-…`).
|
||||
|
||||
## Working inside a member project
|
||||
|
||||
The default workflow is unchanged: change into the project directory and run the
|
||||
slash commands. Root resolution finds the nearest `.specify/`.
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
# then run /speckit.specify, /speckit.plan, … in your agent
|
||||
```
|
||||
|
||||
## Targeting a member project from the repo root
|
||||
|
||||
For non-interactive or CI runs where you do not want to `cd`, set
|
||||
**`SPECIFY_INIT_DIR`** to the member project root (the directory *containing*
|
||||
`.specify/`). Relative paths resolve against the current directory.
|
||||
|
||||
```bash
|
||||
# operate on apps/web from the monorepo root (no cd required)
|
||||
export SPECIFY_INIT_DIR=apps/web
|
||||
```
|
||||
|
||||
The path must exist and contain `.specify/`. If it does not, the command
|
||||
**errors and does not fall back** to the current directory or the Git toplevel.
|
||||
This is deliberate: a typo never writes specs into the wrong project. A
|
||||
nonexistent path is reported as you typed it; a path that exists but is not a
|
||||
Spec Kit project is reported as its resolved absolute path:
|
||||
|
||||
```text
|
||||
# SPECIFY_INIT_DIR=apps/wbe (typo: no such directory)
|
||||
ERROR: SPECIFY_INIT_DIR does not point to an existing directory: apps/wbe
|
||||
|
||||
# SPECIFY_INIT_DIR=apps (exists, but has no .specify/ of its own)
|
||||
ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): /home/you/my-monorepo/apps
|
||||
```
|
||||
|
||||
`SPECIFY_INIT_DIR` selects the **project**; `SPECIFY_FEATURE_DIRECTORY` selects
|
||||
the **feature** within it. They compose: set both to pick a project and a
|
||||
feature non-interactively. See the
|
||||
[`SPECIFY_INIT_DIR` reference](../reference/core.md#environment-variables) for
|
||||
the full contract and the two-axes model.
|
||||
|
||||
## How `SPECIFY_INIT_DIR` reaches your agent
|
||||
|
||||
`SPECIFY_INIT_DIR` is read by the shell scripts that the slash commands invoke
|
||||
(`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell). It takes effect only
|
||||
when it is present in the environment of the shell that runs those scripts.
|
||||
|
||||
- **Scripted / CI runs:** export it in the same shell that drives the commands;
|
||||
it is reliable there.
|
||||
- **Interactive agents:** whether an exported variable reaches the shell tool an
|
||||
agent uses is agent-specific. Export `SPECIFY_INIT_DIR` *before* launching the
|
||||
agent, and verify once (e.g. run `/speckit.specify` and confirm the new feature
|
||||
landed under the intended project's `specs/`).
|
||||
|
||||
## Git in a monorepo
|
||||
|
||||
> [!NOTE]
|
||||
> Spec Kit project files are scoped to the **resolved project root**, but Git
|
||||
> operations still run in the containing Git work tree. In a monorepo with a
|
||||
> single Git repository at the root and projects in subdirectories, feature
|
||||
> branch creation creates or switches branches in the shared root repository.
|
||||
> Spec directories still live under the selected member project, while the Git
|
||||
> branch namespace is shared by the whole monorepo. Manage branches and commits
|
||||
> at the repository root, or initialize Git per member project if you want
|
||||
> isolated per-project branch namespaces.
|
||||
|
||||
## Constitutions
|
||||
|
||||
Each member project has its own `.specify/memory/constitution.md` and
|
||||
`/speckit.constitution` edits the local project's file. Spec Kit does not provide
|
||||
a built-in base/inheritance mechanism; if you want one constitution to reference
|
||||
shared rules elsewhere in the monorepo, you need to maintain that wiring yourself.
|
||||
Otherwise, duplicate or sync shared engineering rules per project.
|
||||
@@ -3,7 +3,7 @@
|
||||
## Prerequisites
|
||||
|
||||
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi Coding Agent](https://pi.dev), or [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent)
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
|
||||
- [Python 3.11+](https://www.python.org/downloads/)
|
||||
- [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
|
||||
@@ -51,6 +51,7 @@ specify init <project_name> --integration gemini
|
||||
specify init <project_name> --integration copilot
|
||||
specify init <project_name> --integration codebuddy
|
||||
specify init <project_name> --integration pi
|
||||
specify init <project_name> --integration omp
|
||||
```
|
||||
|
||||
### Specify Script Type (Shell vs PowerShell)
|
||||
|
||||
@@ -29,6 +29,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
|
||||
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
|
||||
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
|
||||
| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` |
|
||||
| [opencode](https://opencode.ai/) | `opencode` | |
|
||||
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
|
||||
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
|
||||
|
||||
@@ -270,6 +270,8 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
|
||||
| `fan-out` | Dispatch a step for each item in a list |
|
||||
| `fan-in` | Aggregate results from a fan-out step |
|
||||
|
||||
> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands.
|
||||
|
||||
## Expressions
|
||||
|
||||
Steps can reference inputs and previous step outputs using `{{ expression }}` syntax:
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
href: local-development.md
|
||||
- name: Evolving Specs
|
||||
href: guides/evolving-specs.md
|
||||
- name: Monorepos
|
||||
href: guides/monorepo.md
|
||||
|
||||
# Community
|
||||
- name: Community
|
||||
|
||||
@@ -308,6 +308,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
|
||||
ls -la .gemini/commands/ # Gemini
|
||||
ls -la .cursor/skills/ # Cursor
|
||||
ls -la .pi/prompts/ # Pi Coding Agent
|
||||
ls -la .omp/commands/ # Oh My Pi
|
||||
```
|
||||
|
||||
3. **Check agent-specific setup:**
|
||||
@@ -427,7 +428,7 @@ The `specify` CLI tool is used for:
|
||||
- **Upgrades:** `specify init --here --force` to update templates and commands
|
||||
- **Diagnostics:** `specify check` to verify tool installation
|
||||
|
||||
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
|
||||
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, `.omp/commands/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
|
||||
|
||||
**If your agent isn't recognizing slash commands:**
|
||||
|
||||
@@ -442,6 +443,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
|
||||
|
||||
# For Pi
|
||||
ls -la .pi/prompts/
|
||||
|
||||
# For Oh My Pi
|
||||
ls -la .omp/commands/
|
||||
```
|
||||
|
||||
2. **Restart your IDE/editor completely** (not just reload window)
|
||||
|
||||
@@ -320,6 +320,7 @@ A: Extensions should be free and open-source. Commercial support/services are al
|
||||
"author": "string (required)",
|
||||
"version": "string (required, semver)",
|
||||
"download_url": "string (required, valid URL)",
|
||||
"sha256": "string (optional, SHA-256 hex digest of the archive at download_url; verified before install)",
|
||||
"repository": "string (required, valid URL)",
|
||||
"homepage": "string (optional, valid URL)",
|
||||
"documentation": "string (optional, valid URL)",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-22T00:00:00Z",
|
||||
"updated_at": "2026-06-23T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||
"integrations": {
|
||||
"claude": {
|
||||
@@ -255,6 +255,15 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli"]
|
||||
},
|
||||
"omp": {
|
||||
"id": "omp",
|
||||
"name": "Oh My Pi",
|
||||
"version": "1.0.0",
|
||||
"description": "Oh My Pi (omp) terminal coding agent prompt-based integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli"]
|
||||
},
|
||||
"iflow": {
|
||||
"id": "iflow",
|
||||
"name": "iFlow CLI",
|
||||
|
||||
@@ -185,6 +185,7 @@ Edit `presets/catalog.community.json` and add your preset.
|
||||
"author": "Your Name",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip",
|
||||
"sha256": "OPTIONAL: SHA-256 hex digest of the archive above; verified before install",
|
||||
"repository": "https://github.com/your-org/spec-kit-preset-your-preset",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.11.6"
|
||||
version = "0.11.7"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -74,3 +74,13 @@ precision = 2
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Lock in subprocess security posture: any reintroduction of shell=True
|
||||
# (or os.system / popen2) must be acknowledged with an explicit `# noqa`
|
||||
# pointing at the rule, making the deviation visible in review.
|
||||
extend-select = [
|
||||
"S602", # subprocess-popen-with-shell-equals-true
|
||||
"S604", # call-with-shell-equals-true
|
||||
"S605", # start-process-with-a-shell
|
||||
]
|
||||
|
||||
|
||||
@@ -83,24 +83,24 @@ if ($PathsOnly) {
|
||||
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
|
||||
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
|
||||
Write-Output "Run $specifyCommand first to create the feature structure."
|
||||
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
||||
Write-Output "Run $planCommand first to create the implementation plan."
|
||||
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for tasks.md if required
|
||||
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
|
||||
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
|
||||
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
|
||||
$tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT
|
||||
Write-Output "Run $tasksCommand first to create the task list."
|
||||
[Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -111,8 +111,11 @@ function Get-BranchName {
|
||||
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
|
||||
if ($word.Length -ge 3) {
|
||||
$meaningfulWords += $word
|
||||
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
||||
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
|
||||
# Keep short words only if they appear as uppercase in original (likely
|
||||
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
|
||||
# bash script's case-sensitive grep; -match would be case-insensitive and
|
||||
# would keep every short word.
|
||||
$meaningfulWords += $word
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,14 +65,31 @@ def dump_frontmatter(data: dict[str, Any]) -> str:
|
||||
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
|
||||
|
||||
|
||||
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
|
||||
"""Run a shell command and optionally capture output."""
|
||||
def run_command(
|
||||
cmd: list[str],
|
||||
check_return: bool = True,
|
||||
capture: bool = False,
|
||||
shell: bool = False,
|
||||
) -> str | None:
|
||||
"""Run a command without invoking a shell and optionally capture output.
|
||||
|
||||
The ``shell`` parameter is kept in the signature so existing keyword
|
||||
callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
|
||||
but only the default ``shell=False`` is honoured. ``shell=True`` is
|
||||
rejected with ``ValueError`` rather than silently ignored, so the
|
||||
unsupported mode fails loudly instead of running with a different meaning.
|
||||
"""
|
||||
if shell:
|
||||
raise ValueError(
|
||||
"run_command() does not support shell=True; pass argv as a list"
|
||||
)
|
||||
|
||||
try:
|
||||
if capture:
|
||||
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
|
||||
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
subprocess.run(cmd, check=check_return, shell=shell)
|
||||
subprocess.run(cmd, check=check_return)
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
if check_return:
|
||||
|
||||
@@ -37,6 +37,8 @@ def _build_agent_configs() -> dict[str, Any]:
|
||||
# when register_commands() resolves __SPECKIT_COMMAND_*__ tokens.
|
||||
if "invoke_separator" not in config:
|
||||
config["invoke_separator"] = integration.invoke_separator
|
||||
if integration.dev_no_symlink:
|
||||
config["dev_no_symlink"] = True
|
||||
configs[key] = config
|
||||
return configs
|
||||
|
||||
@@ -234,9 +236,14 @@ class CommandRegistrar:
|
||||
toml_lines.append(f"# Source: {source_id}")
|
||||
toml_lines.append("")
|
||||
|
||||
# Keep TOML output valid even when body contains triple-quote delimiters.
|
||||
# Prefer multiline forms, then fall back to escaped basic string.
|
||||
if '"""' not in body:
|
||||
# Keep TOML output valid even when body contains triple-quote delimiters
|
||||
# or backslashes. Prefer multiline forms, then fall back to escaped basic
|
||||
# string. A multiline *basic* string ("""...""") processes backslash escape
|
||||
# sequences, so a body containing a backslash (e.g. a Windows path
|
||||
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
|
||||
# produce unparseable TOML — route those to the *literal* form ('''...'''),
|
||||
# which does not process escapes, or to the escaped basic string.
|
||||
if '"""' not in body and "\\" not in body:
|
||||
toml_lines.append('prompt = """')
|
||||
toml_lines.append(body)
|
||||
toml_lines.append('"""')
|
||||
@@ -714,6 +721,7 @@ class CommandRegistrar:
|
||||
output_name,
|
||||
agent_config["extension"],
|
||||
link_outputs,
|
||||
agent_config,
|
||||
)
|
||||
|
||||
if agent_name == "copilot":
|
||||
@@ -788,6 +796,7 @@ class CommandRegistrar:
|
||||
alias_output_name,
|
||||
agent_config["extension"],
|
||||
link_outputs,
|
||||
agent_config,
|
||||
)
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, alias)
|
||||
@@ -804,9 +813,12 @@ class CommandRegistrar:
|
||||
output_name: str,
|
||||
extension: str,
|
||||
link_outputs: bool,
|
||||
agent_config: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Write a rendered agent artifact, optionally as a dev-mode symlink."""
|
||||
if not link_outputs:
|
||||
if not link_outputs or (agent_config or {}).get("dev_no_symlink"):
|
||||
if dest_file.is_symlink():
|
||||
dest_file.unlink()
|
||||
dest_file.write_text(content, encoding="utf-8")
|
||||
return
|
||||
|
||||
@@ -927,6 +939,16 @@ class CommandRegistrar:
|
||||
self._active_skills_agent(project_root)
|
||||
if create_missing_active_skills_dir else None
|
||||
)
|
||||
active_skills_dir: Optional[Path] = None
|
||||
if active_skills_agent:
|
||||
active_skills_config = self.AGENT_CONFIGS.get(active_skills_agent)
|
||||
if (
|
||||
active_skills_config
|
||||
and active_skills_config.get("extension") == "/SKILL.md"
|
||||
):
|
||||
active_skills_dir = self._resolve_agent_dir(
|
||||
active_skills_agent, active_skills_config, project_root,
|
||||
)
|
||||
active_created_skills_dir: Optional[Path] = None
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
active_skills_output = (
|
||||
@@ -958,6 +980,14 @@ class CommandRegistrar:
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
shares_active_skills_dir = (
|
||||
active_skills_dir is not None
|
||||
and agent_name != active_skills_agent
|
||||
and agent_config.get("extension") == "/SKILL.md"
|
||||
and self._same_lexical_path(agent_dir, active_skills_dir)
|
||||
)
|
||||
if shares_active_skills_dir:
|
||||
continue
|
||||
|
||||
agent_dir_existed = agent_dir.is_dir()
|
||||
register_missing_active_skills_agent = (
|
||||
|
||||
@@ -31,6 +31,7 @@ from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
|
||||
from .._utils import dump_frontmatter, relative_extension_path_violation
|
||||
from ..catalogs import CatalogEntry as BaseCatalogEntry
|
||||
from ..catalogs import CatalogStackBase
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset(
|
||||
{
|
||||
@@ -997,6 +998,7 @@ class ExtensionManager:
|
||||
if not isinstance(selected_ai, str) or not selected_ai:
|
||||
return []
|
||||
registrar = CommandRegistrar()
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||
integration = get_integration(selected_ai)
|
||||
|
||||
for cmd_info in manifest.commands:
|
||||
@@ -1030,15 +1032,16 @@ class ExtensionManager:
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
cache_root = extension_dir / ".specify-dev" / "extension-skills"
|
||||
cache_file = cache_root / skill_name / "SKILL.md"
|
||||
use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink")
|
||||
CommandRegistrar._ensure_inside(cache_file, cache_root)
|
||||
if skill_file.exists() or skill_file.is_symlink():
|
||||
is_expected_dev_symlink = self._is_expected_dev_symlink(
|
||||
skill_file, cache_file
|
||||
)
|
||||
# Do not overwrite user-customized skills, but allow dev-mode
|
||||
# symlinks that point back to this extension's generated cache
|
||||
# to be refreshed on a subsequent dev install.
|
||||
if not (
|
||||
link_outputs
|
||||
and self._is_expected_dev_symlink(skill_file, cache_file)
|
||||
):
|
||||
if not is_expected_dev_symlink:
|
||||
continue
|
||||
|
||||
# Create skill directory; track whether we created it so we can clean
|
||||
@@ -1093,7 +1096,7 @@ class ExtensionManager:
|
||||
):
|
||||
skill_content = integration.post_process_skill_content(skill_content)
|
||||
|
||||
if link_outputs:
|
||||
if use_dev_symlink:
|
||||
try:
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(skill_content, encoding="utf-8")
|
||||
@@ -1106,6 +1109,8 @@ class ExtensionManager:
|
||||
skill_file.unlink()
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
else:
|
||||
if skill_file.is_symlink():
|
||||
skill_file.unlink()
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
written.append(skill_name)
|
||||
|
||||
@@ -2617,6 +2622,10 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
) as response:
|
||||
zip_data = response.read()
|
||||
|
||||
verify_archive_sha256(
|
||||
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
|
||||
)
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ def _register_builtins() -> None:
|
||||
from .kimi import KimiIntegration
|
||||
from .kiro_cli import KiroCliIntegration
|
||||
from .lingma import LingmaIntegration
|
||||
from .omp import OmpIntegration
|
||||
from .opencode import OpencodeIntegration
|
||||
from .pi import PiIntegration
|
||||
from .qodercli import QodercliIntegration
|
||||
@@ -108,6 +109,7 @@ def _register_builtins() -> None:
|
||||
_register(KimiIntegration())
|
||||
_register(KiroCliIntegration())
|
||||
_register(LingmaIntegration())
|
||||
_register(OmpIntegration())
|
||||
_register(OpencodeIntegration())
|
||||
_register(PiIntegration())
|
||||
_register(QodercliIntegration())
|
||||
|
||||
@@ -119,6 +119,9 @@ class IntegrationBase(ABC):
|
||||
invoke_separator: str = "."
|
||||
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
|
||||
|
||||
dev_no_symlink: bool = False
|
||||
"""Whether dev-mode registration should write files instead of symlinks."""
|
||||
|
||||
multi_install_safe: bool = False
|
||||
"""Whether this integration is declared safe to install alongside others.
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ class CodexIntegration(SkillsIntegration):
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
dev_no_symlink = True
|
||||
multi_install_safe = True
|
||||
|
||||
def build_exec_args(
|
||||
|
||||
45
src/specify_cli/integrations/omp/__init__.py
Normal file
45
src/specify_cli/integrations/omp/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Oh My Pi (omp) coding agent integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
|
||||
|
||||
class OmpIntegration(MarkdownIntegration):
|
||||
key = "omp"
|
||||
config = {
|
||||
"name": "Oh My Pi",
|
||||
"folder": ".omp/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": "https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".omp/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
# Diverges from MarkdownIntegration.build_exec_args because OMP's
|
||||
# CLI parser treats `-p`/`--print` as a boolean (one-shot mode) and
|
||||
# consumes the prompt as a positional argument — see args.ts in
|
||||
# can1357/oh-my-pi. JSON output is selected via `--mode json`.
|
||||
if not self.config or not self.config.get("requires_cli"):
|
||||
return None
|
||||
args = [self._resolve_executable(), "--print"]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
args.extend(["--mode", "json"])
|
||||
args.append(prompt)
|
||||
return args
|
||||
@@ -31,6 +31,7 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priorit
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from ..integrations.base import IntegrationBase
|
||||
from .._utils import dump_frontmatter
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
|
||||
|
||||
def _substitute_core_template(
|
||||
@@ -2505,6 +2506,10 @@ class PresetCatalog:
|
||||
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
|
||||
zip_data = response.read()
|
||||
|
||||
verify_archive_sha256(
|
||||
zip_data, pack_info.get("sha256"), pack_id, PresetError
|
||||
)
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
@@ -11,6 +14,74 @@ from typing import Any
|
||||
from .integrations.base import IntegrationBase
|
||||
from .integrations.manifest import IntegrationManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal
|
||||
# characters. Callers lowercase the declared value before matching (see
|
||||
# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and
|
||||
# normalized rather than rejected.
|
||||
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def verify_archive_sha256(
|
||||
data: bytes,
|
||||
expected: str | None,
|
||||
name: str,
|
||||
error_cls: type[Exception],
|
||||
) -> None:
|
||||
"""Verify downloaded archive bytes against a catalog-declared SHA-256.
|
||||
|
||||
Catalog entries may pin the expected digest of their release archive in a
|
||||
``sha256`` field (optionally prefixed with ``"sha256:"``). When present, the
|
||||
downloaded bytes must match before they are written to disk and installed,
|
||||
so a corrupted or tampered archive is rejected even though the transport was
|
||||
HTTPS. Entries without a declared digest are accepted unchanged, keeping the
|
||||
check backwards compatible.
|
||||
|
||||
Args:
|
||||
data: The raw downloaded archive bytes.
|
||||
expected: The catalog-declared SHA-256 hex digest, or ``None``.
|
||||
name: The extension/preset id, used in the error message.
|
||||
error_cls: Exception type to raise on mismatch (e.g. ``ExtensionError``).
|
||||
|
||||
Raises:
|
||||
error_cls: If ``expected`` is provided and is not a well-formed
|
||||
SHA-256 hex digest, or does not match ``data``.
|
||||
"""
|
||||
# Skip only when no digest is declared at all (``None``). A declared but
|
||||
# empty/blank value (e.g. ``sha256: ""``) is an authoring error, not an
|
||||
# opt-out: let it fall through to the format check below so it is rejected
|
||||
# rather than silently disabling verification.
|
||||
if expected is None:
|
||||
logger.debug(
|
||||
"No sha256 declared for %r; archive integrity was not verified.",
|
||||
name,
|
||||
)
|
||||
return
|
||||
# Strip *only* a literal ``sha256:`` algorithm prefix (case-insensitive).
|
||||
# Any other prefix is part of the value and must not be silently dropped,
|
||||
# otherwise a malformed or wrong-algorithm digest (e.g. ``md5:...``) would
|
||||
# be quietly accepted as if it were a valid SHA-256.
|
||||
raw = str(expected).strip()
|
||||
if raw[:7].lower() == "sha256:":
|
||||
raw = raw[7:].strip()
|
||||
expected_hex = raw.lower()
|
||||
if not _SHA256_HEX_RE.match(expected_hex):
|
||||
raise error_cls(
|
||||
f"Invalid sha256 declared for {name!r}: expected 64 hexadecimal "
|
||||
f"characters (optionally prefixed with 'sha256:'), got "
|
||||
f"{expected!r}."
|
||||
)
|
||||
actual_hex = hashlib.sha256(data).hexdigest()
|
||||
# Constant-time comparison: both sides are fixed-length hex digests, so use
|
||||
# ``hmac.compare_digest`` to avoid leaking information through timing.
|
||||
if not hmac.compare_digest(actual_hex, expected_hex):
|
||||
raise error_cls(
|
||||
f"Integrity check failed for {name!r}: the catalog declares "
|
||||
f"sha256 {expected_hex}, but the downloaded archive is "
|
||||
f"{actual_hex}. The archive may be corrupted or tampered with."
|
||||
)
|
||||
|
||||
|
||||
class SymlinkedSharedPathError(ValueError):
|
||||
"""Raised when a shared infrastructure path or ancestor is a symlink.
|
||||
|
||||
@@ -52,9 +52,18 @@ class WorkflowDefinition:
|
||||
if not isinstance(self.default_options, dict):
|
||||
self.default_options = {}
|
||||
|
||||
# Requirements (declared but not yet enforced at runtime;
|
||||
# enforcement is a planned enhancement)
|
||||
self.requires: dict[str, Any] = data.get("requires", {})
|
||||
# Advisory pre-conditions (spec-kit version / integrations a workflow
|
||||
# expects). Validated by ``validate_workflow`` (recognized keys only;
|
||||
# see ``_RECOGNIZED_REQUIRES_KEYS``) but NOT enforced at run time — they
|
||||
# are not a security boundary. In particular there is no
|
||||
# ``requires.permissions`` capability gate: shell steps always run with
|
||||
# the user's privileges.
|
||||
#
|
||||
# Holds the raw parsed value, so before ``validate_workflow`` runs it may
|
||||
# be a non-mapping (``None`` for a bare ``requires:``, a list for
|
||||
# ``requires: []``, etc.); typed ``Any`` rather than ``dict[str, Any]``
|
||||
# to avoid implying it is always a mapping at this point.
|
||||
self.requires: Any = data.get("requires", {})
|
||||
|
||||
# Inputs
|
||||
self.inputs: dict[str, Any] = data.get("inputs", {})
|
||||
@@ -87,6 +96,15 @@ class WorkflowDefinition:
|
||||
# ID format: lowercase alphanumeric with hyphens
|
||||
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")
|
||||
|
||||
# Keys accepted under a workflow's ``requires`` block: the advisory
|
||||
# pre-conditions documented for workflows (``speckit_version`` and
|
||||
# ``integrations``). This is the *workflow* schema only — the bundle manifest's
|
||||
# ``requires`` (see ``bundler/models/manifest.py``) is a separate schema that
|
||||
# also carries ``tools``/``mcp``; those are not workflow ``requires`` keys.
|
||||
# Any other key — notably ``permissions`` — is rejected by ``validate_workflow``
|
||||
# so it is never mistaken for an enforced runtime control.
|
||||
_RECOGNIZED_REQUIRES_KEYS = frozenset({"speckit_version", "integrations"})
|
||||
|
||||
# Valid step types (matching STEP_REGISTRY keys)
|
||||
def _get_valid_step_types() -> set[str]:
|
||||
"""Return valid step types from the registry, with a built-in fallback."""
|
||||
@@ -177,6 +195,36 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"Input {input_name!r} has invalid default: {exc}"
|
||||
)
|
||||
|
||||
# -- Requires ---------------------------------------------------------
|
||||
# ``requires`` declares advisory pre-conditions (the spec-kit version and
|
||||
# integrations a workflow expects). Only a fixed set of keys is recognized;
|
||||
# reject anything else so authoring typos surface here instead of being
|
||||
# silently ignored at runtime. In particular ``requires.permissions`` is
|
||||
# rejected explicitly: it reads like a runtime capability gate, but no such
|
||||
# gate exists — a ``shell`` step always runs with the user's privileges, so
|
||||
# declaring it would give a false sense of sandboxing.
|
||||
#
|
||||
# Mirror ``inputs`` validation: an omitted block defaults to ``{}`` and is
|
||||
# valid, but any present-but-non-mapping value — ``requires:`` (YAML null),
|
||||
# ``requires: []`` or ``requires: ''`` — is an authoring error and must
|
||||
# surface here rather than be silently ignored at runtime.
|
||||
if not isinstance(definition.requires, dict):
|
||||
errors.append("'requires' must be a mapping (or omitted).")
|
||||
else:
|
||||
for key in definition.requires:
|
||||
if key == "permissions":
|
||||
errors.append(
|
||||
"'requires.permissions' is not a recognized or "
|
||||
"enforced capability gate — shell steps always run "
|
||||
"with the user's privileges. Remove it and gate "
|
||||
"sensitive steps with a 'gate' step instead."
|
||||
)
|
||||
elif key not in _RECOGNIZED_REQUIRES_KEYS:
|
||||
errors.append(
|
||||
f"Unknown 'requires' key {key!r}. Recognized keys: "
|
||||
f"{', '.join(sorted(_RECOGNIZED_REQUIRES_KEYS))}."
|
||||
)
|
||||
|
||||
# -- Steps ------------------------------------------------------------
|
||||
if not isinstance(definition.steps, list):
|
||||
errors.append("'steps' must be a list.")
|
||||
|
||||
@@ -31,7 +31,7 @@ class ShellStep(StepBase):
|
||||
# control commands; catalog-installed workflows should be reviewed
|
||||
# before use (see PUBLISHING.md for security guidance).
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above)
|
||||
run_cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
|
||||
31
tests/integrations/test_integration_omp.py
Normal file
31
tests/integrations/test_integration_omp.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Tests for OmpIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestOmpIntegration(MarkdownIntegrationTests):
|
||||
KEY = "omp"
|
||||
FOLDER = ".omp/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".omp/commands"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
def test_build_exec_args_uses_omp_json_mode(self):
|
||||
i = get_integration(self.KEY)
|
||||
|
||||
args = i.build_exec_args(
|
||||
"/speckit.specify Build auth",
|
||||
model="gpt-5",
|
||||
)
|
||||
|
||||
assert args == [
|
||||
"omp",
|
||||
"--print",
|
||||
"--model",
|
||||
"gpt-5",
|
||||
"--mode",
|
||||
"json",
|
||||
"/speckit.specify Build auth",
|
||||
]
|
||||
@@ -34,6 +34,7 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"kiro-cli",
|
||||
"lingma",
|
||||
"vibe",
|
||||
"omp",
|
||||
"opencode",
|
||||
"pi",
|
||||
"qodercli",
|
||||
@@ -360,6 +361,12 @@ class TestAgentConfigConsistency:
|
||||
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
|
||||
)
|
||||
|
||||
def test_codex_dev_no_symlink_policy_in_agent_config(self):
|
||||
"""Codex dev installs must expose the no-symlink policy as metadata."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert cfg["codex"].get("dev_no_symlink") is True
|
||||
|
||||
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).
|
||||
|
||||
@@ -143,7 +143,11 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@requires_bash
|
||||
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without --paths-only, feature directory validation must still fail on main."""
|
||||
"""Without --paths-only, feature directory validation must still fail on main.
|
||||
|
||||
The error must go to stderr and stdout must stay clean, so a caller that
|
||||
parses stdout as JSON is not handed the error string instead (#3122).
|
||||
"""
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
@@ -155,6 +159,8 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in result.stderr
|
||||
assert "Feature directory not found" not in result.stdout
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
# ── PowerShell tests ──────────────────────────────────────────────────────
|
||||
@@ -213,7 +219,11 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without -PathsOnly, feature directory validation must still fail on main."""
|
||||
"""Without -PathsOnly, feature directory validation must still fail on main.
|
||||
|
||||
The error must land on stderr only, leaving stdout clean for -Json
|
||||
callers that parse it as JSON (#3122).
|
||||
"""
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
@@ -225,5 +235,51 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
combined = result.stdout + result.stderr
|
||||
assert "Feature directory not found" in combined
|
||||
assert "Feature directory not found" in result.stderr
|
||||
assert "Feature directory not found" not in result.stdout
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_missing_plan_error_goes_to_stderr(prereq_repo: Path) -> None:
|
||||
"""A missing plan.md must report on stderr, not stdout (#3122)."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "plan.md not found" in result.stderr
|
||||
assert "plan.md not found" not in result.stdout
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_missing_tasks_error_goes_to_stderr(prereq_repo: Path) -> None:
|
||||
"""With -RequireTasks, a missing tasks.md must report on stderr only (#3122)."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-RequireTasks"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "tasks.md not found" in result.stderr
|
||||
assert "tasks.md not found" not in result.stdout
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
@@ -573,6 +573,84 @@ class TestExtensionSkillRegistration:
|
||||
assert "speckit-test-ext-hello" in written
|
||||
assert "Run this updated hello." in skill_file.read_text(encoding="utf-8")
|
||||
|
||||
def test_codex_dev_skill_registration_replaces_existing_dev_symlink(
|
||||
self, project_dir, extension_dir, temp_dir
|
||||
):
|
||||
"""Codex dev skill registration should migrate prior dev symlinks to files."""
|
||||
if not _can_create_symlink(temp_dir):
|
||||
pytest.skip("Current platform/user cannot create symlinks")
|
||||
|
||||
_create_init_options(project_dir, ai="codex", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="codex")
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
skill_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file = (
|
||||
extension_dir
|
||||
/ ".specify-dev"
|
||||
/ "extension-skills"
|
||||
/ "speckit-test-ext-hello"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
cache_file.parent.mkdir(parents=True)
|
||||
cache_file.write_text("old linked content", encoding="utf-8")
|
||||
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
|
||||
|
||||
written = manager._register_extension_skills(
|
||||
manifest,
|
||||
extension_dir,
|
||||
link_outputs=True,
|
||||
)
|
||||
|
||||
assert "speckit-test-ext-hello" in written
|
||||
assert skill_file.exists()
|
||||
assert not skill_file.is_symlink()
|
||||
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
|
||||
assert cache_file.read_text(encoding="utf-8") == "old linked content"
|
||||
|
||||
def test_codex_dev_skill_registration_preserves_unrelated_symlink(
|
||||
self, project_dir, extension_dir, temp_dir
|
||||
):
|
||||
"""Codex dev registration should not overwrite user-owned symlinks."""
|
||||
if not _can_create_symlink(temp_dir):
|
||||
pytest.skip("Current platform/user cannot create symlinks")
|
||||
|
||||
_create_init_options(project_dir, ai="codex", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="codex")
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
skill_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
unrelated_cache_file = (
|
||||
temp_dir
|
||||
/ "other-extension"
|
||||
/ ".specify-dev"
|
||||
/ "extension-skills"
|
||||
/ "speckit-test-ext-hello"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
unrelated_cache_file.parent.mkdir(parents=True)
|
||||
unrelated_cache_file.write_text("user-owned linked content", encoding="utf-8")
|
||||
os.symlink(
|
||||
os.path.relpath(unrelated_cache_file, skill_file.parent), skill_file
|
||||
)
|
||||
|
||||
written = manager._register_extension_skills(
|
||||
manifest,
|
||||
extension_dir,
|
||||
link_outputs=True,
|
||||
)
|
||||
|
||||
assert "speckit-test-ext-hello" not in written
|
||||
assert skill_file.is_symlink()
|
||||
assert skill_file.resolve(strict=True) == unrelated_cache_file.resolve()
|
||||
assert unrelated_cache_file.read_text(encoding="utf-8") == (
|
||||
"user-owned linked content"
|
||||
)
|
||||
|
||||
def test_dev_skill_registration_falls_back_to_copy_when_symlink_fails(
|
||||
self, skills_project, extension_dir, monkeypatch
|
||||
):
|
||||
|
||||
@@ -1669,6 +1669,47 @@ $ARGUMENTS
|
||||
|
||||
assert parsed["description"] == "first line\nsecond line\n"
|
||||
|
||||
def test_render_toml_command_preserves_backslashes_in_body(self):
|
||||
"""A backslash in the body (e.g. a Windows path) must not break TOML.
|
||||
|
||||
A multiline basic string ("\"\"\"") processes backslash escapes, so
|
||||
``C:\\Users`` (``\\U``) would render as invalid TOML; the body must
|
||||
round-trip with backslashes intact.
|
||||
"""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": "x"},
|
||||
r"Run C:\Users\dev\tool.exe then report.",
|
||||
"extension:test-ext",
|
||||
)
|
||||
parsed = tomllib.loads(output) # must not raise
|
||||
assert parsed["prompt"].strip() == r"Run C:\Users\dev\tool.exe then report."
|
||||
|
||||
def test_render_toml_command_handles_trailing_backslash(self):
|
||||
"""A body ending in a backslash must round-trip without corruption."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": "x"},
|
||||
"path ends with sep\\",
|
||||
"extension:test-ext",
|
||||
)
|
||||
parsed = tomllib.loads(output)
|
||||
assert parsed["prompt"].strip() == "path ends with sep\\"
|
||||
|
||||
def test_render_toml_command_backslash_with_both_triple_quotes_escapes(self):
|
||||
"""Body with a backslash and both triple-quote styles → escaped basic string."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
body = "a \\ b\nc \"\"\" d\ne ''' f"
|
||||
output = registrar.render_toml_command({"description": "x"}, body, "extension:test-ext")
|
||||
parsed = tomllib.loads(output)
|
||||
assert parsed["prompt"] == body
|
||||
|
||||
def test_register_commands_for_claude(self, extension_dir, project_dir):
|
||||
"""Test registering commands for Claude agent."""
|
||||
# Create .claude directory
|
||||
@@ -2248,6 +2289,50 @@ Run {SCRIPT}
|
||||
assert target.is_file()
|
||||
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
|
||||
|
||||
def test_dev_register_commands_replaces_codex_dev_symlink(
|
||||
self, extension_dir, project_dir, temp_dir
|
||||
):
|
||||
"""Codex dev registration should replace prior symlinks with real files."""
|
||||
if not can_create_symlink(temp_dir):
|
||||
pytest.skip("Current platform/user cannot create symlinks")
|
||||
|
||||
skill_file = (
|
||||
project_dir
|
||||
/ ".agents"
|
||||
/ "skills"
|
||||
/ "speckit-test-ext-hello"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
skill_file.parent.mkdir(parents=True)
|
||||
cache_file = (
|
||||
extension_dir
|
||||
/ ".specify-dev"
|
||||
/ "agent-commands"
|
||||
/ "codex"
|
||||
/ "speckit-test-ext-hello"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
cache_file.parent.mkdir(parents=True)
|
||||
cache_file.write_text("old linked content", encoding="utf-8")
|
||||
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
|
||||
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent(
|
||||
"codex",
|
||||
manifest,
|
||||
extension_dir,
|
||||
project_dir,
|
||||
link_outputs=True,
|
||||
)
|
||||
|
||||
assert skill_file.exists()
|
||||
assert not skill_file.is_symlink()
|
||||
assert "name: speckit-test-ext-hello" in skill_file.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert cache_file.read_text(encoding="utf-8") == "old linked content"
|
||||
|
||||
def test_dev_register_commands_falls_back_to_copy_when_symlink_fails(
|
||||
self, extension_dir, project_dir, monkeypatch
|
||||
):
|
||||
@@ -3716,6 +3801,89 @@ class TestExtensionCatalog:
|
||||
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[1].get_header("Accept") == "application/octet-stream"
|
||||
|
||||
def _make_zip_bytes(self):
|
||||
"""Build a minimal valid extension ZIP in memory for download tests."""
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
|
||||
return buf.getvalue()
|
||||
|
||||
def _mock_response(self, data):
|
||||
"""Build a context-manager mock HTTP response returning ``data``."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = data
|
||||
# Configure the context-manager protocol explicitly so `with resp`
|
||||
# yields `resp` itself, independent of how the protocol is invoked.
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
return resp
|
||||
|
||||
def test_download_extension_accepts_matching_sha256(self, temp_dir):
|
||||
"""A catalog ``sha256`` that matches the archive is accepted."""
|
||||
import hashlib
|
||||
from unittest.mock import patch
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
zip_bytes = self._make_zip_bytes()
|
||||
ext_info = {
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://example.com/test-ext.zip",
|
||||
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
|
||||
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert zip_path.read_bytes() == zip_bytes
|
||||
|
||||
def test_download_extension_rejects_sha256_mismatch(self, temp_dir):
|
||||
"""A catalog ``sha256`` that does not match the downloaded archive
|
||||
aborts the install — a tampered or swapped archive is rejected.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
zip_bytes = self._make_zip_bytes()
|
||||
ext_info = {
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://example.com/test-ext.zip",
|
||||
"sha256": "0" * 64, # deliberately wrong
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
|
||||
with pytest.raises(ExtensionError, match="[Ii]ntegrity"):
|
||||
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
def test_download_extension_without_sha256_still_succeeds(self, temp_dir):
|
||||
"""Entries without ``sha256`` keep working (backwards compatible)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
zip_bytes = self._make_zip_bytes()
|
||||
ext_info = {
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://example.com/test-ext.zip",
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
|
||||
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert zip_path.read_bytes() == zip_bytes
|
||||
|
||||
def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch):
|
||||
"""download_extension can use a GitHub REST release asset URL directly."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -4874,6 +5042,93 @@ class TestExtensionAddCLI:
|
||||
else:
|
||||
assert not agent_file.is_symlink()
|
||||
|
||||
def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir):
|
||||
"""Codex dev skills should be written as files so Codex can load them."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.write_text(
|
||||
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", str(extension_dir), "--dev"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
skill_file = (
|
||||
project_dir
|
||||
/ ".agents"
|
||||
/ "skills"
|
||||
/ "speckit-test-ext-hello"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
assert skill_file.exists()
|
||||
assert not skill_file.is_symlink()
|
||||
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "name: speckit-test-ext-hello" in content
|
||||
assert "metadata:" in content
|
||||
assert "source: test-ext:commands/hello.md" in content
|
||||
|
||||
def test_add_dev_replaces_existing_codex_skill_symlink(
|
||||
self, extension_dir, project_dir, temp_dir
|
||||
):
|
||||
"""Codex dev installs should migrate expected dev symlinks to files."""
|
||||
if not can_create_symlink(temp_dir):
|
||||
pytest.skip("Current platform/user cannot create symlinks")
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.write_text(
|
||||
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
|
||||
)
|
||||
|
||||
skill_file = (
|
||||
project_dir
|
||||
/ ".agents"
|
||||
/ "skills"
|
||||
/ "speckit-test-ext-hello"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
skill_file.parent.mkdir(parents=True)
|
||||
cache_file = (
|
||||
extension_dir
|
||||
/ ".specify-dev"
|
||||
/ "extension-skills"
|
||||
/ "speckit-test-ext-hello"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
cache_file.parent.mkdir(parents=True)
|
||||
cache_file.write_text("old linked content", encoding="utf-8")
|
||||
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", str(extension_dir), "--dev"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert skill_file.exists()
|
||||
assert not skill_file.is_symlink()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "name: speckit-test-ext-hello" in content
|
||||
assert "source: test-ext:commands/hello.md" in content
|
||||
assert cache_file.read_text(encoding="utf-8") == "old linked content"
|
||||
|
||||
def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable(
|
||||
self, extension_dir, project_dir, monkeypatch
|
||||
):
|
||||
|
||||
@@ -2019,6 +2019,90 @@ class TestPresetCatalog:
|
||||
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[1].get_header("Accept") == "application/octet-stream"
|
||||
|
||||
def _pack_zip_and_response(self):
|
||||
"""Build a minimal preset ZIP and a context-manager mock response."""
|
||||
from unittest.mock import MagicMock
|
||||
import io
|
||||
|
||||
zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buf, "w") as zf:
|
||||
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = zip_bytes
|
||||
# Configure the context-manager protocol explicitly so `with resp`
|
||||
# yields `resp` itself, independent of how the protocol is invoked.
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
return zip_bytes, resp
|
||||
|
||||
def test_download_pack_accepts_matching_sha256(self, project_dir):
|
||||
"""A catalog ``sha256`` that matches the preset archive is accepted."""
|
||||
import hashlib
|
||||
from unittest.mock import patch
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
zip_bytes, resp = self._pack_zip_and_response()
|
||||
pack_info = {
|
||||
"id": "test-pack",
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://example.com/test-pack.zip",
|
||||
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
|
||||
"_install_allowed": True,
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
||||
patch.object(catalog, "_open_url", return_value=resp):
|
||||
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
assert zip_path.read_bytes() == zip_bytes
|
||||
|
||||
def test_download_pack_rejects_sha256_mismatch(self, project_dir):
|
||||
"""A catalog ``sha256`` that does not match the archive aborts install."""
|
||||
from unittest.mock import patch
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
_zip_bytes, resp = self._pack_zip_and_response()
|
||||
pack_info = {
|
||||
"id": "test-pack",
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://example.com/test-pack.zip",
|
||||
"sha256": "0" * 64, # deliberately wrong
|
||||
"_install_allowed": True,
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
||||
patch.object(catalog, "_open_url", return_value=resp):
|
||||
with pytest.raises(PresetError, match="[Ii]ntegrity"):
|
||||
catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
def test_download_pack_without_sha256_skips_verification(self, project_dir):
|
||||
"""A catalog entry with no ``sha256`` keeps working: verification is
|
||||
opt-in, so the backwards-compatible path (``pack_info.get("sha256")``
|
||||
is ``None``) must download without aborting — mirrors the extensions
|
||||
coverage so the helper never silently becomes mandatory for presets.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
zip_bytes, resp = self._pack_zip_and_response()
|
||||
pack_info = {
|
||||
"id": "test-pack",
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://example.com/test-pack.zip",
|
||||
"_install_allowed": True,
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
||||
patch.object(catalog, "_open_url", return_value=resp):
|
||||
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
assert zip_path.read_bytes() == zip_bytes
|
||||
|
||||
def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch):
|
||||
"""download_pack can use a GitHub REST release asset URL directly."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
101
tests/test_shared_infra_integrity.py
Normal file
101
tests/test_shared_infra_integrity.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Unit tests for the shared archive-integrity helper.
|
||||
|
||||
These exercise ``verify_archive_sha256`` directly (independently of the
|
||||
extension/preset download paths that call it) so the digest-matching,
|
||||
mismatch, normalisation and "no digest declared" behaviours are pinned in
|
||||
one place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.shared_infra import verify_archive_sha256
|
||||
|
||||
|
||||
class _BoomError(Exception):
|
||||
"""Sentinel error type used to assert the helper raises ``error_cls``."""
|
||||
|
||||
|
||||
def test_matching_digest_passes():
|
||||
"""A digest that matches the data returns without raising."""
|
||||
data = b"hello-archive"
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
verify_archive_sha256(data, digest, "thing", _BoomError)
|
||||
|
||||
|
||||
def test_mismatch_raises_error_cls():
|
||||
"""A non-matching digest raises the caller-supplied error type."""
|
||||
with pytest.raises(_BoomError, match="[Ii]ntegrity"):
|
||||
verify_archive_sha256(b"data", "0" * 64, "thing", _BoomError)
|
||||
|
||||
|
||||
def test_sha256_prefix_is_accepted():
|
||||
"""A ``sha256:`` prefix on the expected digest is tolerated."""
|
||||
data = b"prefixed"
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
verify_archive_sha256(data, f"sha256:{digest}", "thing", _BoomError)
|
||||
|
||||
|
||||
def test_comparison_is_case_insensitive():
|
||||
"""An upper-cased expected digest still matches the lower-case actual."""
|
||||
data = b"casing"
|
||||
digest = hashlib.sha256(data).hexdigest().upper()
|
||||
verify_archive_sha256(data, digest, "thing", _BoomError)
|
||||
|
||||
|
||||
def test_malformed_digest_is_rejected():
|
||||
"""A declared digest that is not 64 hex chars is rejected up front.
|
||||
|
||||
A too-short, too-long, or non-hex value is an authoring/catalog error and
|
||||
must surface clearly instead of being treated as a digest that simply does
|
||||
not match the archive.
|
||||
"""
|
||||
for bad in ("deadbeef", "z" * 64, "0" * 63, "0" * 65):
|
||||
with pytest.raises(_BoomError, match="[Ii]nvalid sha256"):
|
||||
verify_archive_sha256(b"data", bad, "thing", _BoomError)
|
||||
|
||||
|
||||
def test_non_sha256_prefix_is_not_silently_stripped():
|
||||
"""Only a literal ``sha256:`` prefix is stripped.
|
||||
|
||||
A different algorithm prefix (e.g. ``md5:``) must not be silently dropped
|
||||
and accepted as if the remaining characters were a valid SHA-256 digest;
|
||||
the value is rejected as malformed.
|
||||
"""
|
||||
data = b"prefixed"
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
with pytest.raises(_BoomError, match="[Ii]nvalid sha256"):
|
||||
verify_archive_sha256(data, f"md5:{digest}", "thing", _BoomError)
|
||||
|
||||
|
||||
def test_absent_digest_skips_and_logs_debug(caplog):
|
||||
"""When no digest is declared the helper returns and logs at DEBUG.
|
||||
|
||||
Installs stay backwards compatible (no error, no user-facing warning),
|
||||
but the unverified download leaves an audit trail for operators who opt
|
||||
into debug logging.
|
||||
"""
|
||||
with caplog.at_level(logging.DEBUG, logger="specify_cli.shared_infra"):
|
||||
verify_archive_sha256(b"data", None, "thing", _BoomError)
|
||||
assert any(
|
||||
"not verified" in r.getMessage() and "thing" in r.getMessage()
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_blank_declared_digest_is_rejected():
|
||||
"""A present-but-empty ``sha256`` is an authoring error, not an opt-out.
|
||||
|
||||
Catalog entries reach the helper via ``...get("sha256")``; a blank value
|
||||
(``""``, whitespace, or a bare ``sha256:`` prefix) means the digest was
|
||||
declared but left empty. It must surface as a malformed digest rather than
|
||||
silently disabling the integrity check, which a bare ``if not expected``
|
||||
guard would have done.
|
||||
"""
|
||||
for blank in ("", " ", "sha256:"):
|
||||
with pytest.raises(_BoomError, match="[Ii]nvalid sha256"):
|
||||
verify_archive_sha256(b"data", blank, "thing", _BoomError)
|
||||
@@ -869,6 +869,52 @@ class TestPowerShellDryRun:
|
||||
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
|
||||
|
||||
|
||||
# ── Short-Word / Acronym Branch-Name Tests ──────────────────────────────────
|
||||
|
||||
|
||||
def _branch_from_output(stdout: str) -> str | None:
|
||||
for line in stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
SHORT_WORD_CASES = [
|
||||
# description, expected branch — "go" (lowercase short word) is dropped,
|
||||
# "AI" (uppercase short word / acronym) is kept, "now" (>=3 chars) is kept.
|
||||
("go AI now", "001-ai-now"),
|
||||
# A short word that is lowercase everywhere is dropped entirely.
|
||||
("go to the pub", "001-pub"),
|
||||
]
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestShortWordRetentionBash:
|
||||
"""A short word is kept only when it appears in uppercase (an acronym)."""
|
||||
|
||||
@pytest.mark.parametrize("description,expected", SHORT_WORD_CASES)
|
||||
def test_short_word_retention(self, git_repo: Path, description: str, expected: str):
|
||||
result = run_script(git_repo, "--dry-run", description)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _branch_from_output(result.stdout) == expected
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
|
||||
class TestShortWordRetentionPowerShell:
|
||||
"""PowerShell must match bash: a short word is kept only when uppercase.
|
||||
|
||||
Regression guard for the `-match` (case-insensitive) vs `-cmatch`
|
||||
(case-sensitive) divergence — with `-match`, every short non-stop word
|
||||
leaked into the branch name even when it was lowercase.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("description,expected", SHORT_WORD_CASES)
|
||||
def test_short_word_retention(self, ps_git_repo: Path, description: str, expected: str):
|
||||
result = run_ps_script(ps_git_repo, "-DryRun", description)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _branch_from_output(result.stdout) == expected
|
||||
|
||||
|
||||
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
15
tests/test_utils.py
Normal file
15
tests/test_utils.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Tests for specify_cli._utils.run_command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli import run_command
|
||||
|
||||
|
||||
def test_run_command_rejects_shell_execution_compatibly():
|
||||
assert inspect.signature(run_command).parameters["shell"].default is False
|
||||
with pytest.raises(ValueError, match="does not support shell=True"):
|
||||
run_command(["echo", "blocked"], shell=True) # noqa: S604
|
||||
@@ -2115,6 +2115,148 @@ steps:
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid type" in e.lower() for e in errors)
|
||||
|
||||
def test_requires_with_recognized_keys_is_valid(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
requires:
|
||||
speckit_version: ">=0.7.2"
|
||||
integrations:
|
||||
any: ["claude", "gemini"]
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert errors == []
|
||||
|
||||
def test_requires_must_be_mapping(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
requires: "claude"
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("'requires' must be a mapping" in e for e in errors)
|
||||
|
||||
def test_requires_unknown_key_is_rejected(self):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
requires:
|
||||
speckit_version: ">=0.7.2"
|
||||
typo_key: true
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("typo_key" in e and "requires" in e for e in errors)
|
||||
|
||||
def test_requires_permissions_is_rejected_as_not_enforced(self):
|
||||
"""A `requires.permissions` block looks like a runtime capability gate
|
||||
but no such gate exists — shell steps always run with the user's
|
||||
privileges. Reject it explicitly so authors are not misled into
|
||||
believing the declaration sandboxes execution.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
requires:
|
||||
permissions:
|
||||
shell: true
|
||||
steps:
|
||||
- id: run
|
||||
type: shell
|
||||
run: "echo hi"
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
# Assert on specific markers from the intended message (the offending
|
||||
# key and the `gate` remediation) so the test fails if the validation
|
||||
# path or wording drifts, rather than passing on any error that merely
|
||||
# happens to contain "permissions" and "not".
|
||||
assert any("requires.permissions" in e and "gate" in e for e in errors)
|
||||
|
||||
def test_requires_empty_sequence_is_rejected_as_non_mapping(self):
|
||||
"""A non-mapping ``requires`` (e.g. an empty list) is an authoring
|
||||
error. Mirroring ``inputs``, validation checks ``isinstance(..., dict)``
|
||||
so ``requires: []`` surfaces instead of silently passing.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
requires: []
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("'requires' must be a mapping" in e for e in errors)
|
||||
|
||||
def test_requires_yaml_null_is_rejected_as_non_mapping(self):
|
||||
"""A bare ``requires:`` parses as YAML null. Like ``inputs``, a present
|
||||
block must be a mapping, so YAML null is rejected as an authoring error
|
||||
rather than being silently treated as an omitted block. (A truly
|
||||
omitted ``requires`` defaults to ``{}`` and stays valid.)
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
requires:
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("'requires' must be a mapping" in e for e in errors)
|
||||
|
||||
def test_requires_omitted_is_valid(self):
|
||||
"""A workflow with no ``requires`` block at all defaults to ``{}`` and
|
||||
must validate cleanly — only a present-but-non-mapping value is an
|
||||
error (guards against over-correcting YAML-null rejection into also
|
||||
flagging the omitted case).
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
workflow:
|
||||
id: "test"
|
||||
name: "Test"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert not any("requires" in e for e in errors)
|
||||
|
||||
|
||||
# ===== Workflow Engine Tests =====
|
||||
|
||||
|
||||
@@ -268,10 +268,22 @@ When releasing a new version:
|
||||
|
||||
### Shell Steps
|
||||
|
||||
- **Shell runs with the user's privileges** — a `shell` step executes a local command directly; there is no capability sandbox. `requires` is an advisory pre-condition block (recognised keys: `speckit_version`, `integrations`), **not** a runtime permission gate — there is no `requires.permissions`. Gate sensitive commands explicitly with a `gate` step.
|
||||
- **Avoid destructive commands** — don't delete files or directories without explicit confirmation via a gate
|
||||
- **Quote variables** — use proper quoting in shell commands to handle spaces
|
||||
- **Check exit codes** — shell step failures stop the workflow; make sure commands are robust
|
||||
|
||||
#### Security: shell steps execute arbitrary code
|
||||
|
||||
Workflow `shell` steps execute their `run` field through `/bin/sh` (POSIX) or the platform shell. There is no sandbox between the step and the user's machine: a malicious or buggy `run` block can read environment variables, modify files outside the project, exfiltrate data, or escalate privileges.
|
||||
|
||||
Catalog-listed workflows are reviewed at submission time (see [Verification Process](#verification-process)), but you should still treat every install as code-execution from an untrusted source until you have read the `workflow.yml`:
|
||||
|
||||
- **Before installing a workflow**, fetch the raw YAML and audit every `shell` step's `run` field directly. `specify workflow info <name>` only shows metadata (name, version, inputs, step IDs/types) — not the shell content that would actually execute.
|
||||
- **Prefer explicit commands over interpolation** in `run` blocks: `{{ inputs.something }}` substitutions should be quoted and constrained via `enum` so a malicious input can't inject shell syntax.
|
||||
- **Limit privilege**: shell steps inherit the user's environment. Workflows that need elevated access (sudo, secrets, GitHub tokens) should call them out explicitly in the README so reviewers can spot the requirement.
|
||||
- **Authors**: if your workflow has shell steps that look risky out of context (deletions, network calls, credential reads), document the rationale in your README. Maintainers will reject submissions whose shell steps can't be justified at review time.
|
||||
|
||||
### Integration Flexibility
|
||||
|
||||
- **Set `integration` at workflow level** — use the `workflow.integration` field as the default
|
||||
|
||||
Reference in New Issue
Block a user