mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 07:04:44 +08:00
Compare commits
41 Commits
fix/3272-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882e1e90d0 | ||
|
|
a307894709 | ||
|
|
10d4bca64c | ||
|
|
f1a8d8f95b | ||
|
|
d5ba062eab | ||
|
|
12faf7b5b5 | ||
|
|
220e6fcc4e | ||
|
|
fb796c2a39 | ||
|
|
0151d239b5 | ||
|
|
f764270d06 | ||
|
|
7839acce86 | ||
|
|
1935cf7e48 | ||
|
|
d4e7d2b888 | ||
|
|
abaed10d00 | ||
|
|
2b5e175440 | ||
|
|
0e40438903 | ||
|
|
1930f89d17 | ||
|
|
4bb5166445 | ||
|
|
73f77c200f | ||
|
|
b8d27e472f | ||
|
|
587b1859fa | ||
|
|
52480ee50f | ||
|
|
d3e7b06fa7 | ||
|
|
de6a04eaad | ||
|
|
5217206fdf | ||
|
|
c978faac57 | ||
|
|
b5f1194168 | ||
|
|
44c112c807 | ||
|
|
3b4e7f3cb6 | ||
|
|
f494a8e33e | ||
|
|
92b7cf7658 | ||
|
|
bba473c223 | ||
|
|
288bd679f3 | ||
|
|
9bd3512025 | ||
|
|
bbe86310ca | ||
|
|
3b30e40aaa | ||
|
|
6288dea6ae | ||
|
|
5b682b2cb3 | ||
|
|
490566847c | ||
|
|
f59fd81608 | ||
|
|
1849543611 |
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: '8.x'
|
||||
|
||||
|
||||
60
CHANGELOG.md
60
CHANGELOG.md
@@ -2,6 +2,66 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.7] - 2026-07-07
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(bundler): bundle update uninstalls components dropped by new version (#3353)
|
||||
- fix(workflows): route run/resume errors to stderr under --json (#3352)
|
||||
- fix(workflows): fan-in validate() rejects non-mapping output (#3349)
|
||||
- fix(workflows): shell step validate() rejects non-string run (#3348)
|
||||
- fix(integrations): agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (#3347)
|
||||
- Add Orchestration Task Context Management extension to community catalog (#3372)
|
||||
- Update DocGuard — CDD Enforcement extension to v0.30.0 (#3371)
|
||||
- Update Ripple extension to v1.1.0 (#3370)
|
||||
- feat(integrations): generalize post-processing to all format types (#3311)
|
||||
- chore: release 0.12.6, begin 0.12.7.dev0 development (#3393)
|
||||
|
||||
## [0.12.6] - 2026-07-07
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(bundler): validate catalog URLs in `catalog add` (HTTPS-only, require host) (#3367)
|
||||
- Update Ralph Loop extension to v1.2.1 (#3365)
|
||||
- fix extension-local script path rewriting (#3364)
|
||||
- Add Charter extension to community catalog (#3363)
|
||||
- feat(scripts): add Python check-prerequisites PoC (#3302)
|
||||
- test: reduce registry manifest test repetition (#3146)
|
||||
- fix(integrations): hermes honors SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS (#3346)
|
||||
- fix(extensions): coerce non-mapping YAML config roots to {} in ConfigManager (#3345)
|
||||
- fix(yaml): pin goose recipe prompt block-scalar indentation (#3343)
|
||||
- chore: release 0.12.5, begin 0.12.6.dev0 development (#3381)
|
||||
|
||||
## [0.12.5] - 2026-07-06
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(workflows): match gate reject option case-insensitively (#3335)
|
||||
- fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333)
|
||||
- fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331)
|
||||
- fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323)
|
||||
- fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307)
|
||||
- Support namespaced git feature branch templates (#3293)
|
||||
- chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315)
|
||||
- fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265)
|
||||
- docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291)
|
||||
- chore: release 0.12.4, begin 0.12.5.dev0 development (#3305)
|
||||
|
||||
## [0.12.4] - 2026-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
- feat(cli): add `py` script type & Python interpreter resolution (#3278) (#3285)
|
||||
- fix: resolve GitHub release asset API URL for private repo bundle downloads (#3136)
|
||||
- [extension] Add Analytics extension to community catalog (#3296)
|
||||
- fix: interpolate multi-expression templates instead of returning None (#3208) (#3228)
|
||||
- feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver (#3186)
|
||||
- fix(extensions): resolve core-command dirs via _assets helpers (#3274) (#3287)
|
||||
- fix: fall back to feature dir basename for empty CURRENT_BRANCH (#3026) (#3229)
|
||||
- feat(bug-fix): add label-driven bug-fix agentic workflow (#3258)
|
||||
- feat(workflows): add label-driven bug-test workflow (#3239) (#3257)
|
||||
- chore: release 0.12.3, begin 0.12.4.dev0 development (#3295)
|
||||
|
||||
## [0.12.3] - 2026-07-01
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -28,6 +28,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
|
||||
| Agent Governance | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
| Analytics | Measure what your AI builds, and how much time it saves you | `visibility` | Read+Write | [spec-kit-analytics](https://github.com/Fyloss/spec-kit-analytics) |
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
@@ -41,6 +42,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
|
||||
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
|
||||
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
|
||||
| Charter | Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent. | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) |
|
||||
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
|
||||
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
@@ -49,7 +51,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
@@ -84,6 +86,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
|
||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
@@ -104,7 +107,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||
| Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
||||
|
||||
@@ -77,6 +77,18 @@ feature non-interactively. See the
|
||||
[`SPECIFY_INIT_DIR` reference](../reference/core.md#environment-variables) for
|
||||
the full contract and the two-axes model.
|
||||
|
||||
The `specify` CLI's project-scoped subcommands honor the same variable, so they
|
||||
target a member project from the root without `cd` too:
|
||||
|
||||
```bash
|
||||
export SPECIFY_INIT_DIR=apps/web
|
||||
specify workflow list # lists apps/web's workflows
|
||||
specify integration status # reports apps/web's integration
|
||||
```
|
||||
|
||||
The validation rules are the same: the path must exist and contain `.specify/`,
|
||||
with no fallback to the current directory.
|
||||
|
||||
## How `SPECIFY_INIT_DIR` reaches your agent
|
||||
|
||||
`SPECIFY_INIT_DIR` is read by the shell scripts that the slash commands invoke
|
||||
|
||||
@@ -50,12 +50,14 @@ specify init my-project --integration copilot --preset compliance
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------ |
|
||||
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. When unset, the project is detected by searching upward from the current directory as before. |
|
||||
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). |
|
||||
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
|
||||
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
|
||||
|
||||
> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature.
|
||||
|
||||
> **Symlinked project roots.** `SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.
|
||||
|
||||
## Check Installed Tools
|
||||
|
||||
```bash
|
||||
|
||||
@@ -24,7 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
||||
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
|
||||
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` |
|
||||
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
|
||||
| [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` | |
|
||||
@@ -218,7 +218,7 @@ Some integrations accept additional options via `--integration-options`:
|
||||
| Integration | Option | Description |
|
||||
| ----------- | ------------------- | -------------------------------------------------------------- |
|
||||
| `generic` | `--commands-dir` | Required. Directory for command files |
|
||||
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated directory names); when the `agent-context` extension is enabled, also migrates `KIMI.md` to `AGENTS.md` |
|
||||
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-30T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -145,6 +145,40 @@
|
||||
"created_at": "2026-05-04T00:00:00Z",
|
||||
"updated_at": "2026-05-04T00:00:00Z"
|
||||
},
|
||||
"analytics": {
|
||||
"name": "Analytics",
|
||||
"id": "analytics",
|
||||
"description": "Measure what your AI builds, and how much time it saves you",
|
||||
"author": "Fyloss",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/Fyloss/spec-kit-analytics/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/Fyloss/spec-kit-analytics",
|
||||
"homepage": "https://github.com/Fyloss/spec-kit-analytics",
|
||||
"documentation": "https://github.com/Fyloss/spec-kit-analytics/tree/main/doc",
|
||||
"changelog": "https://github.com/Fyloss/spec-kit-analytics/releases",
|
||||
"license": "MIT",
|
||||
"category": "visibility",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.10.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 16
|
||||
},
|
||||
"tags": [
|
||||
"analytics",
|
||||
"productivity",
|
||||
"metrics",
|
||||
"benchmarking",
|
||||
"tracking"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-01T00:00:00Z",
|
||||
"updated_at": "2026-07-01T00:00:00Z"
|
||||
},
|
||||
"api-evolve": {
|
||||
"name": "API Evolve",
|
||||
"id": "api-evolve",
|
||||
@@ -636,6 +670,40 @@
|
||||
"created_at": "2026-04-11T18:00:00Z",
|
||||
"updated_at": "2026-04-11T18:00:00Z"
|
||||
},
|
||||
"charter": {
|
||||
"name": "Charter",
|
||||
"id": "charter",
|
||||
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
|
||||
"author": "Fyloss",
|
||||
"version": "0.3.1",
|
||||
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
|
||||
"repository": "https://github.com/Fyloss/spec-kit-charter",
|
||||
"homepage": "https://github.com/Fyloss/spec-kit-charter",
|
||||
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
|
||||
"changelog": "https://github.com/Fyloss/spec-kit-charter/blob/master/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.11.9"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"constitution",
|
||||
"governance",
|
||||
"modular",
|
||||
"fragments",
|
||||
"registry"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-06T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"ci-guard": {
|
||||
"name": "CI Guard",
|
||||
"id": "ci-guard",
|
||||
@@ -1038,10 +1106,10 @@
|
||||
"docguard": {
|
||||
"name": "DocGuard — CDD Enforcement",
|
||||
"id": "docguard",
|
||||
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
|
||||
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
|
||||
"author": "raccioly",
|
||||
"version": "0.28.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.0.zip",
|
||||
"version": "0.30.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
|
||||
"repository": "https://github.com/raccioly/docguard",
|
||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||
@@ -1077,7 +1145,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-06-23T00:00:00Z"
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
@@ -2328,6 +2396,39 @@
|
||||
"created_at": "2026-04-03T00:00:00Z",
|
||||
"updated_at": "2026-04-03T00:00:00Z"
|
||||
},
|
||||
"orchestration-task-context-management": {
|
||||
"name": "Orchestration Task Context Management",
|
||||
"id": "orchestration-task-context-management",
|
||||
"description": "Adds subagent work-unit orchestration to generated Spec Kit task files",
|
||||
"author": "Igor Benicio de Mesquita",
|
||||
"version": "0.0.0",
|
||||
"download_url": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/archive/refs/tags/v0.0.0.zip",
|
||||
"repository": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
|
||||
"homepage": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
|
||||
"documentation": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/README.md",
|
||||
"changelog": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.7.2"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"agent",
|
||||
"orchestration",
|
||||
"tasks",
|
||||
"context"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-06T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"orchestrator": {
|
||||
"name": "Spec Orchestrator",
|
||||
"id": "orchestrator",
|
||||
@@ -2645,8 +2746,8 @@
|
||||
"id": "ralph",
|
||||
"description": "Autonomous implementation loop using AI agent CLI",
|
||||
"author": "Rubiss",
|
||||
"version": "1.1.1",
|
||||
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.1.1.zip",
|
||||
"version": "1.2.1",
|
||||
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.2.1.zip",
|
||||
"repository": "https://github.com/Rubiss-Projects/spec-kit-ralph",
|
||||
"homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph",
|
||||
"documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md",
|
||||
@@ -2655,7 +2756,7 @@
|
||||
"category": "code",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"speckit_version": ">=0.8.5",
|
||||
"tools": [
|
||||
{
|
||||
"name": "copilot",
|
||||
@@ -2665,6 +2766,10 @@
|
||||
"name": "codex",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "claude",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
@@ -2680,13 +2785,14 @@
|
||||
"automation",
|
||||
"loop",
|
||||
"copilot",
|
||||
"codex"
|
||||
"codex",
|
||||
"claude"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-09T00:00:00Z",
|
||||
"updated_at": "2026-06-05T03:11:06Z"
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"reconcile": {
|
||||
"name": "Reconcile Extension",
|
||||
@@ -3013,10 +3119,10 @@
|
||||
"ripple": {
|
||||
"name": "Ripple",
|
||||
"id": "ripple",
|
||||
"description": "Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories with fix-induced side effect detection",
|
||||
"description": "Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories",
|
||||
"author": "chordpli",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/chordpli/spec-kit-ripple",
|
||||
"homepage": "https://github.com/chordpli/spec-kit-ripple",
|
||||
"documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md",
|
||||
@@ -3025,7 +3131,13 @@
|
||||
"category": "code",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
@@ -3042,7 +3154,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"roadmap": {
|
||||
"name": "Spec Roadmap",
|
||||
|
||||
@@ -7,7 +7,7 @@ Git repository initialization, feature branch creation, numbering (sequential/ti
|
||||
This extension provides Git operations as an optional, self-contained module. It manages:
|
||||
|
||||
- **Repository initialization** with configurable commit messages
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
|
||||
- **Branch validation** to ensure branches follow naming conventions
|
||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||
@@ -53,6 +53,16 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`:
|
||||
# Branch numbering strategy: "sequential" or "timestamp"
|
||||
branch_numbering: sequential
|
||||
|
||||
# Optional branch name template. Leave empty for the default "{number}-{slug}".
|
||||
# Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear
|
||||
# before {number}, and the final path segment must start with {number}-.
|
||||
# Example for monorepos: "{author}/{app}/{number}-{slug}"
|
||||
branch_template: ""
|
||||
|
||||
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
|
||||
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
|
||||
branch_prefix: ""
|
||||
|
||||
# Custom commit message for git init
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
@@ -65,6 +75,10 @@ auto_commit:
|
||||
message: "[Spec Kit] Add specification"
|
||||
```
|
||||
|
||||
`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must not put `{slug}` before `{number}`, and must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`.
|
||||
|
||||
For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `<branch_prefix>/{number}-{slug}`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||
- `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -35,6 +35,19 @@ Determine the branch numbering strategy by checking configuration in this order:
|
||||
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
|
||||
4. Default to `sequential` if none of the above exist
|
||||
|
||||
## Branch Name Template
|
||||
|
||||
Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, `{slug}` must not appear before `{number}`, its final path segment must start with `{number}-`, and the script expands these tokens:
|
||||
|
||||
- `{author}`: sanitized Git config author (`user.name`, falling back to the email local part)
|
||||
- `{app}`: sanitized Spec Kit init directory name
|
||||
- `{number}`: sequential number or timestamp
|
||||
- `{slug}`: generated short branch slug
|
||||
|
||||
For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering.
|
||||
|
||||
The script also accepts `branch_prefix` as a shorthand for simple namespaces; it expands to `<branch_prefix>/{number}-{slug}`.
|
||||
|
||||
## Execution
|
||||
|
||||
Generate a concise short name (2-4 words) for the branch:
|
||||
@@ -54,6 +67,7 @@ Run the appropriate script based on your platform:
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
||||
- Do not manually expand `branch_template`; the script reads the git extension config and applies it consistently
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
@@ -64,5 +78,5 @@ If Git is not installed or the current directory is not a Git repository:
|
||||
## Output
|
||||
|
||||
The script outputs JSON with:
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`)
|
||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||
|
||||
@@ -22,24 +22,24 @@ Get the current branch name:
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
The branch name must match one of these patterns:
|
||||
The branch name's final path segment must start with one of these feature markers:
|
||||
|
||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||
1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`)
|
||||
2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`)
|
||||
|
||||
## Execution
|
||||
|
||||
If on a feature branch (matches either pattern):
|
||||
- Output: `✓ On feature branch: <branch-name>`
|
||||
- Check if the corresponding spec directory exists under `specs/`:
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion, regardless of branch namespace prefixes
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes
|
||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||
|
||||
If NOT on a feature branch:
|
||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||
- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or <namespace>/001-feature-name`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||
branch_numbering: sequential
|
||||
|
||||
# Optional branch name template. Leave empty for the default "{number}-{slug}".
|
||||
# Supported tokens: {author}, {app}, {number}, {slug}
|
||||
# {slug} must not appear before {number}; final path segment must start with {number}-.
|
||||
# Example for monorepos: "{author}/{app}/{number}-{slug}"
|
||||
branch_template: ""
|
||||
|
||||
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
|
||||
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
|
||||
branch_prefix: ""
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ extension:
|
||||
id: git
|
||||
name: "Git Branching Workflow"
|
||||
version: "1.0.0"
|
||||
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
|
||||
description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection"
|
||||
author: spec-kit-core
|
||||
repository: https://github.com/github/spec-kit
|
||||
license: MIT
|
||||
@@ -19,7 +19,7 @@ provides:
|
||||
commands:
|
||||
- name: speckit.git.feature
|
||||
file: commands/speckit.git.feature.md
|
||||
description: "Create a feature branch with sequential or timestamp numbering"
|
||||
description: "Create a feature branch with sequential or timestamp numbering and optional templates"
|
||||
- name: speckit.git.validate
|
||||
file: commands/speckit.git.validate.md
|
||||
description: "Validate current branch follows feature branch naming conventions"
|
||||
@@ -137,4 +137,6 @@ tags:
|
||||
config:
|
||||
defaults:
|
||||
branch_numbering: sequential
|
||||
branch_template: ""
|
||||
branch_prefix: ""
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||
branch_numbering: sequential
|
||||
|
||||
# Optional branch name template. Leave empty for the default "{number}-{slug}".
|
||||
# Supported tokens: {author}, {app}, {number}, {slug}
|
||||
# {slug} must not appear before {number}; final path segment must start with {number}-.
|
||||
# Example for monorepos: "{author}/{app}/{number}-{slug}"
|
||||
branch_template: ""
|
||||
|
||||
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
|
||||
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
|
||||
branch_prefix: ""
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ while [ $i -le $# ]; do
|
||||
echo "Environment variables:"
|
||||
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
|
||||
echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
@@ -127,16 +131,28 @@ get_highest_from_specs() {
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
local scope_prefix="${1:-}"
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
_extract_highest_number() {
|
||||
local scope_prefix="${1:-}"
|
||||
local highest=0
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||
if [ -n "$scope_prefix" ]; then
|
||||
case "$name" in
|
||||
"$scope_prefix"*) name="${name#"$scope_prefix"}" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
fi
|
||||
name="${name##*/}"
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
@@ -148,11 +164,12 @@ _extract_highest_number() {
|
||||
|
||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||
get_highest_from_remote_refs() {
|
||||
local scope_prefix="${1:-}"
|
||||
local highest=0
|
||||
|
||||
for remote in $(git remote 2>/dev/null); do
|
||||
local remote_highest
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix")
|
||||
if [ "$remote_highest" -gt "$highest" ]; then
|
||||
highest=$remote_highest
|
||||
fi
|
||||
@@ -165,16 +182,17 @@ get_highest_from_remote_refs() {
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
local skip_fetch="${2:-false}"
|
||||
local scope_prefix="${3:-}"
|
||||
|
||||
if [ "$skip_fetch" = true ]; then
|
||||
local highest_remote=$(get_highest_from_remote_refs)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
local highest_remote=$(get_highest_from_remote_refs "$scope_prefix")
|
||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||
highest_branch=$highest_remote
|
||||
fi
|
||||
else
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
||||
fi
|
||||
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
@@ -273,6 +291,152 @@ fi
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
|
||||
read_git_config_value() {
|
||||
local key="$1"
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \
|
||||
| head -n 1 \
|
||||
| sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \
|
||||
| sed -E 's/[[:space:]]+#.*$//' \
|
||||
| sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
|
||||
| sed -E 's/^"//; s/"$//' \
|
||||
| sed -E "s/^'//; s/'$//"
|
||||
}
|
||||
|
||||
branch_token() {
|
||||
local value="$1"
|
||||
local fallback="$2"
|
||||
local cleaned
|
||||
cleaned=$(clean_branch_name "$value")
|
||||
if [ -n "$cleaned" ]; then
|
||||
printf '%s\n' "$cleaned"
|
||||
else
|
||||
printf '%s\n' "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
get_author_token() {
|
||||
local author=""
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
author=$(git config user.name 2>/dev/null || true)
|
||||
if [ -z "$author" ]; then
|
||||
author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true)
|
||||
fi
|
||||
fi
|
||||
if [ -z "$author" ]; then
|
||||
author="${USER:-unknown}"
|
||||
fi
|
||||
branch_token "$author" "unknown"
|
||||
}
|
||||
|
||||
get_app_token() {
|
||||
branch_token "$(basename "$REPO_ROOT")" "app"
|
||||
}
|
||||
|
||||
resolve_branch_template() {
|
||||
local template
|
||||
local prefix
|
||||
template=$(read_git_config_value "branch_template")
|
||||
if [ -n "$template" ]; then
|
||||
printf '%s\n' "$template"
|
||||
return
|
||||
fi
|
||||
|
||||
prefix=$(read_git_config_value "branch_prefix")
|
||||
if [ -z "$prefix" ]; then
|
||||
printf '%s\n' ""
|
||||
return
|
||||
fi
|
||||
case "$prefix" in
|
||||
*/) printf '%s%s\n' "$prefix" "{number}-{slug}" ;;
|
||||
*) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
render_branch_template() {
|
||||
local template="$1"
|
||||
local feature_num="$2"
|
||||
local branch_suffix="$3"
|
||||
local rendered="$template"
|
||||
rendered=${rendered//\{author\}/$AUTHOR_TOKEN}
|
||||
rendered=${rendered//\{app\}/$APP_TOKEN}
|
||||
rendered=${rendered//\{number\}/$feature_num}
|
||||
rendered=${rendered//\{slug\}/$branch_suffix}
|
||||
printf '%s\n' "$rendered"
|
||||
}
|
||||
|
||||
validate_branch_template() {
|
||||
local template="$1"
|
||||
[ -n "$template" ] || return 0
|
||||
local feature_segment
|
||||
feature_segment="${template##*/}"
|
||||
case "$template" in
|
||||
*"{number}"*) ;;
|
||||
*)
|
||||
>&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$template" in
|
||||
*"{slug}"*"{number}"*)
|
||||
>&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$feature_segment" in
|
||||
"{number}-"*) ;;
|
||||
*)
|
||||
>&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_branch_name() {
|
||||
local feature_num="$1"
|
||||
local branch_suffix="$2"
|
||||
if [ -n "$BRANCH_TEMPLATE" ]; then
|
||||
render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix"
|
||||
else
|
||||
printf '%s-%s\n' "$feature_num" "$branch_suffix"
|
||||
fi
|
||||
}
|
||||
|
||||
branch_scope_prefix() {
|
||||
local template="$1"
|
||||
local prefix="$template"
|
||||
[ -n "$prefix" ] || return 0
|
||||
case "$prefix" in
|
||||
*"{number}"*) prefix="${prefix%%\{number\}*}" ;;
|
||||
*"{slug}"*) prefix="${prefix%%\{slug\}*}" ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
render_branch_template "$prefix" "" "$BRANCH_SUFFIX"
|
||||
}
|
||||
|
||||
extract_feature_num_from_branch() {
|
||||
local branch_name="$1"
|
||||
local feature_segment="${branch_name##*/}"
|
||||
local match
|
||||
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true)
|
||||
if [ -n "$match" ]; then
|
||||
printf '%s\n' "$match" | sed -E 's/-$//'
|
||||
return
|
||||
fi
|
||||
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true)
|
||||
if [ -n "$match" ]; then
|
||||
printf '%s\n' "$match" | sed -E 's/-$//'
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$branch_name"
|
||||
}
|
||||
|
||||
AUTHOR_TOKEN=$(get_author_token)
|
||||
APP_TOKEN=$(get_app_token)
|
||||
BRANCH_TEMPLATE=$(resolve_branch_template)
|
||||
validate_branch_template "$BRANCH_TEMPLATE"
|
||||
|
||||
# Function to generate branch name with stop word filtering
|
||||
generate_branch_name() {
|
||||
@@ -318,18 +482,8 @@ generate_branch_name() {
|
||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
||||
BRANCH_NAME="$GIT_BRANCH_NAME"
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
|
||||
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
else
|
||||
FEATURE_NUM="$BRANCH_NAME"
|
||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||
fi
|
||||
FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME")
|
||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||
else
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
@@ -347,16 +501,17 @@ else
|
||||
# Determine branch prefix
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
else
|
||||
BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX")
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
elif [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX")
|
||||
else
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
@@ -364,7 +519,7 @@ else
|
||||
fi
|
||||
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -376,18 +531,23 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH
|
||||
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
||||
exit 1
|
||||
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
TRUNCATED_SUFFIX="$BRANCH_SUFFIX"
|
||||
while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do
|
||||
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}"
|
||||
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX")
|
||||
done
|
||||
if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then
|
||||
>&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME")
|
||||
TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
|
||||
@@ -23,8 +23,9 @@ spec_kit_effective_branch_name() {
|
||||
}
|
||||
|
||||
# Validate that a branch name matches the expected feature branch pattern.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
||||
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats,
|
||||
# either at the start of the branch or after path-style namespace prefixes.
|
||||
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
|
||||
check_feature_branch() {
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
@@ -37,16 +38,17 @@ check_feature_branch() {
|
||||
|
||||
local branch
|
||||
branch=$(spec_kit_effective_branch_name "$raw")
|
||||
local feature_segment="${branch##*/}"
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
local is_sequential=false
|
||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
is_sequential=true
|
||||
fi
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ if ($Help) {
|
||||
Write-Host "Environment variables:"
|
||||
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
Write-Host ""
|
||||
Write-Host "Configuration:"
|
||||
Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
|
||||
Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -67,11 +71,23 @@ function Get-HighestNumberFromSpecs {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromNames {
|
||||
param([string[]]$Names)
|
||||
param(
|
||||
[string[]]$Names,
|
||||
[string]$ScopePrefix = ''
|
||||
)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
|
||||
continue
|
||||
}
|
||||
if ($ScopePrefix) {
|
||||
$name = $name.Substring($ScopePrefix.Length)
|
||||
}
|
||||
$name = ($name -split '/')[-1]
|
||||
$hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
|
||||
$hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
@@ -82,7 +98,7 @@ function Get-HighestNumberFromNames {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
param([string]$ScopePrefix = '')
|
||||
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
@@ -90,7 +106,7 @@ function Get-HighestNumberFromBranches {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
@@ -99,6 +115,8 @@ function Get-HighestNumberFromBranches {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
param([string]$ScopePrefix = '')
|
||||
|
||||
[long]$highest = 0
|
||||
try {
|
||||
$remotes = git remote 2>$null
|
||||
@@ -111,7 +129,7 @@ function Get-HighestNumberFromRemoteRefs {
|
||||
$refNames = $refs | ForEach-Object {
|
||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||
} | Where-Object { $_ }
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
@@ -125,18 +143,19 @@ function Get-HighestNumberFromRemoteRefs {
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch
|
||||
[switch]$SkipFetch,
|
||||
[string]$ScopePrefix = ''
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch { }
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
}
|
||||
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
@@ -232,6 +251,145 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
|
||||
function Read-GitConfigValue {
|
||||
param([string]$Key)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
|
||||
$escapedKey = [regex]::Escape($Key)
|
||||
foreach ($line in Get-Content -LiteralPath $configFile) {
|
||||
if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
|
||||
$val = ($matches[1] -replace '\s+#.*$', '').Trim()
|
||||
$val = $val -replace '^["'']', '' -replace '["'']$', ''
|
||||
return $val
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function ConvertTo-BranchToken {
|
||||
param(
|
||||
[string]$Value,
|
||||
[string]$Fallback
|
||||
)
|
||||
|
||||
$cleaned = ConvertTo-CleanBranchName -Name $Value
|
||||
if ($cleaned) { return $cleaned }
|
||||
return $Fallback
|
||||
}
|
||||
|
||||
function Get-GitAuthorToken {
|
||||
$author = ''
|
||||
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||||
try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
|
||||
if (-not $author) {
|
||||
try {
|
||||
$email = (git config user.email 2>$null | Out-String).Trim()
|
||||
if ($email) { $author = ($email -split '@')[0] }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
|
||||
return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
|
||||
}
|
||||
|
||||
function Get-AppToken {
|
||||
return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
|
||||
}
|
||||
|
||||
function Resolve-BranchTemplate {
|
||||
$template = Read-GitConfigValue -Key 'branch_template'
|
||||
if ($template) { return $template }
|
||||
|
||||
$prefix = Read-GitConfigValue -Key 'branch_prefix'
|
||||
if (-not $prefix) { return '' }
|
||||
if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
|
||||
return "$prefix/{number}-{slug}"
|
||||
}
|
||||
|
||||
function Expand-BranchTemplate {
|
||||
param(
|
||||
[string]$Template,
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
$rendered = $Template.Replace('{author}', $authorToken)
|
||||
$rendered = $rendered.Replace('{app}', $appToken)
|
||||
$rendered = $rendered.Replace('{number}', $FeatureNum)
|
||||
$rendered = $rendered.Replace('{slug}', $BranchSuffix)
|
||||
return $rendered
|
||||
}
|
||||
|
||||
function Assert-BranchTemplateValid {
|
||||
param([string]$Template)
|
||||
|
||||
if ($Template -and -not $Template.Contains('{number}')) {
|
||||
throw "branch_template must include the {number} token so generated branches remain valid feature branches."
|
||||
}
|
||||
if ($Template) {
|
||||
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
||||
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
||||
if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
|
||||
throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
|
||||
}
|
||||
$featureSegment = ($Template -split '/')[-1]
|
||||
if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
|
||||
throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-BranchName {
|
||||
param(
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
if ($branchTemplate) {
|
||||
return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
|
||||
}
|
||||
return "$FeatureNum-$BranchSuffix"
|
||||
}
|
||||
|
||||
function Get-BranchScopePrefix {
|
||||
param(
|
||||
[string]$Template,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
if (-not $Template) { return '' }
|
||||
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
||||
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
||||
$indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
|
||||
if (-not $indexes) { return '' }
|
||||
$prefix = $Template.Substring(0, $indexes[0])
|
||||
return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
|
||||
}
|
||||
|
||||
function Get-FeatureNumberFromBranchName {
|
||||
param([string]$BranchName)
|
||||
|
||||
$featureSegment = ($BranchName -split '/')[-1]
|
||||
if ($featureSegment -match '^(\d{8}-\d{6})-') {
|
||||
return $matches[1]
|
||||
}
|
||||
if ($featureSegment -match '^(\d+)-') {
|
||||
return $matches[1]
|
||||
}
|
||||
return $BranchName
|
||||
}
|
||||
|
||||
function Get-Utf8ByteCount {
|
||||
param([string]$Value)
|
||||
return [System.Text.Encoding]::UTF8.GetByteCount($Value)
|
||||
}
|
||||
|
||||
$authorToken = Get-GitAuthorToken
|
||||
$appToken = Get-AppToken
|
||||
$branchTemplate = Resolve-BranchTemplate
|
||||
Assert-BranchTemplateValid -Template $branchTemplate
|
||||
|
||||
function Get-BranchName {
|
||||
param([string]$Description)
|
||||
@@ -276,19 +434,11 @@ function Get-BranchName {
|
||||
if ($env:GIT_BRANCH_NAME) {
|
||||
$branchName = $env:GIT_BRANCH_NAME
|
||||
# Check 244-byte limit (UTF-8) for override names
|
||||
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
|
||||
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
|
||||
if ($branchNameUtf8ByteCount -gt 244) {
|
||||
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
||||
}
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
|
||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||
$featureNum = $matches[1]
|
||||
} elseif ($branchName -match '^(\d+)-') {
|
||||
$featureNum = $matches[1]
|
||||
} else {
|
||||
$featureNum = $branchName
|
||||
}
|
||||
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
|
||||
} else {
|
||||
if ($ShortName) {
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
@@ -303,39 +453,41 @@ if ($env:GIT_BRANCH_NAME) {
|
||||
|
||||
if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
} else {
|
||||
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
|
||||
} elseif ($DryRun) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
|
||||
} else {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
}
|
||||
}
|
||||
|
||||
$maxBranchLength = 244
|
||||
if ($branchName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $featureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
|
||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
|
||||
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
$truncatedSuffix = $branchSuffix
|
||||
while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
|
||||
$truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
|
||||
}
|
||||
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
||||
throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
|
||||
}
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
|
||||
@@ -37,14 +37,15 @@ function Test-FeatureBranch {
|
||||
|
||||
$raw = $Branch
|
||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||
$featureSegment = ($Branch -split '/')[-1]
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||
# Accept sequential prefix (3+ digits), at the start or after namespace
|
||||
# segments, but exclude malformed timestamps.
|
||||
$hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
|
||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name")
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.4.dev0"
|
||||
version = "0.12.8.dev0"
|
||||
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"
|
||||
|
||||
@@ -198,6 +198,15 @@ get_feature_paths() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
|
||||
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
|
||||
# directory basename so CURRENT_BRANCH is a usable identifier rather than
|
||||
# an empty, misleading value (issue #3026).
|
||||
if [[ -z "$current_branch" ]]; then
|
||||
local feature_dir_trimmed="${feature_dir%/}"
|
||||
current_branch="${feature_dir_trimmed##*/}"
|
||||
fi
|
||||
|
||||
# Use printf '%q' to safely quote values, preventing shell injection
|
||||
# via crafted branch names or paths containing special characters
|
||||
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||||
|
||||
@@ -192,6 +192,17 @@ function Get-FeaturePathsEnv {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
|
||||
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
|
||||
# directory basename so CURRENT_BRANCH is a usable identifier rather than
|
||||
# an empty, misleading value (issue #3026).
|
||||
if (-not $currentBranch) {
|
||||
# TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
|
||||
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework.
|
||||
$featureDirTrimmed = $featureDir.TrimEnd('/', '\')
|
||||
$currentBranch = Split-Path -Leaf $featureDirTrimmed
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $repoRoot
|
||||
CURRENT_BRANCH = $currentBranch
|
||||
|
||||
207
scripts/python/check_prerequisites.py
Normal file
207
scripts/python/check_prerequisites.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Consolidated prerequisite checking script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
--json Output in JSON format
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
./check_prerequisites.py --json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
./check_prerequisites.py --json --require-tasks --include-tasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
./check_prerequisites.py --paths-only
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Args:
|
||||
json_mode: bool = False
|
||||
require_tasks: bool = False
|
||||
include_tasks: bool = False
|
||||
paths_only: bool = False
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> Args:
|
||||
json_mode = False
|
||||
require_tasks = False
|
||||
include_tasks = False
|
||||
paths_only = False
|
||||
|
||||
for arg in argv:
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg == "--require-tasks":
|
||||
require_tasks = True
|
||||
elif arg == "--include-tasks":
|
||||
include_tasks = True
|
||||
elif arg == "--paths-only":
|
||||
paths_only = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(HELP_TEXT)
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
print(
|
||||
f"ERROR: Unknown option '{arg}'. Use --help for usage information.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
return Args(
|
||||
json_mode=json_mode,
|
||||
require_tasks=require_tasks,
|
||||
include_tasks=include_tasks,
|
||||
paths_only=paths_only,
|
||||
)
|
||||
|
||||
|
||||
def _dir_has_entries(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_dir() and any(path.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]:
|
||||
docs: list[str] = []
|
||||
if paths.research.is_file():
|
||||
docs.append("research.md")
|
||||
if paths.data_model.is_file():
|
||||
docs.append("data-model.md")
|
||||
if _dir_has_entries(paths.contracts_dir):
|
||||
docs.append("contracts/")
|
||||
if paths.quickstart.is_file():
|
||||
docs.append("quickstart.md")
|
||||
if include_tasks and paths.tasks.is_file():
|
||||
docs.append("tasks.md")
|
||||
return docs
|
||||
|
||||
|
||||
def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"REPO_ROOT": str(paths.repo_root),
|
||||
"BRANCH": paths.current_branch,
|
||||
"FEATURE_DIR": str(paths.feature_dir),
|
||||
"FEATURE_SPEC": str(paths.feature_spec),
|
||||
"IMPL_PLAN": str(paths.impl_plan),
|
||||
"TASKS": str(paths.tasks),
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
print(f"REPO_ROOT: {paths.repo_root}")
|
||||
print(f"BRANCH: {paths.current_branch}")
|
||||
print(f"FEATURE_DIR: {paths.feature_dir}")
|
||||
print(f"FEATURE_SPEC: {paths.feature_spec}")
|
||||
print(f"IMPL_PLAN: {paths.impl_plan}")
|
||||
print(f"TASKS: {paths.tasks}")
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
|
||||
print(f"FEATURE_DIR:{paths.feature_dir}")
|
||||
print("AVAILABLE_DOCS:")
|
||||
_check_file(paths.research, "research.md")
|
||||
_check_file(paths.data_model, "data-model.md")
|
||||
_check_dir(paths.contracts_dir, "contracts/")
|
||||
_check_file(paths.quickstart, "quickstart.md")
|
||||
if include_tasks:
|
||||
_check_file(paths.tasks, "tasks.md")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(
|
||||
no_persist=args.paths_only,
|
||||
script_file=Path(__file__),
|
||||
)
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
if args.paths_only:
|
||||
_print_paths_only(paths, args.json_mode)
|
||||
return 0
|
||||
|
||||
if not paths.feature_dir.is_dir():
|
||||
print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not paths.impl_plan.is_file():
|
||||
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.require_tasks and not paths.tasks.is_file():
|
||||
print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
docs = _available_docs(paths, args.include_tasks)
|
||||
if args.json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
|
||||
)
|
||||
else:
|
||||
_print_text_results(paths, args.include_tasks)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
210
scripts/python/common.py
Normal file
210
scripts/python/common.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Shared helpers for Spec Kit Python scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _trim_trailing_separators(value: Path) -> str:
|
||||
text = str(value)
|
||||
while len(text) > 1 and text.endswith((os.sep, "/")):
|
||||
text = text[:-1]
|
||||
return text
|
||||
|
||||
|
||||
def find_specify_root(start_dir: Path | None = None) -> Path | None:
|
||||
current = (start_dir or Path.cwd()).resolve()
|
||||
while True:
|
||||
if (current / ".specify").is_dir():
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
return None
|
||||
current = parent
|
||||
|
||||
|
||||
def resolve_specify_init_dir() -> Path:
|
||||
raw = os.environ.get("SPECIFY_INIT_DIR", "")
|
||||
candidate = Path(raw)
|
||||
if not candidate.is_absolute():
|
||||
candidate = Path.cwd() / candidate
|
||||
try:
|
||||
init_root = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
print(
|
||||
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if not init_root.is_dir():
|
||||
print(
|
||||
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if not (init_root / ".specify").is_dir():
|
||||
print(
|
||||
"ERROR: SPECIFY_INIT_DIR is not a Spec Kit project "
|
||||
f"(no .specify/ directory): {init_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
return init_root
|
||||
|
||||
|
||||
def get_repo_root(script_file: Path | None = None) -> Path:
|
||||
if os.environ.get("SPECIFY_INIT_DIR"):
|
||||
return resolve_specify_init_dir()
|
||||
|
||||
specify_root = find_specify_root()
|
||||
if specify_root is not None:
|
||||
return specify_root
|
||||
|
||||
if script_file is not None:
|
||||
script_root = find_specify_root(script_file.resolve().parent)
|
||||
if script_root is not None:
|
||||
return script_root
|
||||
|
||||
# Installed scripts live at .specify/scripts/python/<script>.py.
|
||||
return script_file.resolve().parents[3]
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
def get_current_branch() -> str:
|
||||
return os.environ.get("SPECIFY_FEATURE", "")
|
||||
|
||||
|
||||
def read_feature_json_feature_directory(repo_root: Path) -> str:
|
||||
feature_json = repo_root / ".specify" / "feature.json"
|
||||
if not feature_json.is_file():
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return ""
|
||||
value = data.get("feature_directory") if isinstance(data, dict) else None
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _json_dump(data: dict[str, str]) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
value = feature_dir_value
|
||||
try:
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
current = read_feature_json_feature_directory(repo_root)
|
||||
if current == value:
|
||||
return
|
||||
|
||||
specify_dir = repo_root / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "feature.json").write_text(
|
||||
_json_dump({"feature_directory": value}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeaturePaths:
|
||||
repo_root: Path
|
||||
current_branch: str
|
||||
feature_dir: Path
|
||||
feature_spec: Path
|
||||
impl_plan: Path
|
||||
tasks: Path
|
||||
research: Path
|
||||
data_model: Path
|
||||
quickstart: Path
|
||||
contracts_dir: Path
|
||||
|
||||
|
||||
def get_feature_paths(
|
||||
*, no_persist: bool = False, script_file: Path | None = None
|
||||
) -> FeaturePaths:
|
||||
repo_root = get_repo_root(script_file)
|
||||
current_branch = get_current_branch()
|
||||
|
||||
feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
|
||||
if feature_dir_raw:
|
||||
feature_dir = Path(feature_dir_raw)
|
||||
if not feature_dir.is_absolute():
|
||||
feature_dir = repo_root / feature_dir
|
||||
if not no_persist:
|
||||
persist_feature_json(repo_root, feature_dir_raw)
|
||||
elif (repo_root / ".specify" / "feature.json").is_file():
|
||||
stored = read_feature_json_feature_directory(repo_root)
|
||||
if not stored:
|
||||
print(
|
||||
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
||||
"or ensure .specify/feature.json contains feature_directory.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
feature_dir = Path(stored)
|
||||
if not feature_dir.is_absolute():
|
||||
feature_dir = repo_root / feature_dir
|
||||
else:
|
||||
print(
|
||||
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
||||
"or run the specify command to create .specify/feature.json.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not current_branch:
|
||||
current_branch = Path(_trim_trailing_separators(feature_dir)).name
|
||||
|
||||
return FeaturePaths(
|
||||
repo_root=repo_root,
|
||||
current_branch=current_branch,
|
||||
feature_dir=feature_dir,
|
||||
feature_spec=feature_dir / "spec.md",
|
||||
impl_plan=feature_dir / "plan.md",
|
||||
tasks=feature_dir / "tasks.md",
|
||||
research=feature_dir / "research.md",
|
||||
data_model=feature_dir / "data-model.md",
|
||||
quickstart=feature_dir / "quickstart.md",
|
||||
contracts_dir=feature_dir / "contracts",
|
||||
)
|
||||
|
||||
|
||||
def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
return "."
|
||||
try:
|
||||
state = json.loads(integration_json.read_text(encoding="utf-8"))
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
return entry["invoke_separator"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return "."
|
||||
|
||||
|
||||
def format_speckit_command(command_name: str, repo_root: Path) -> str:
|
||||
separator = get_invoke_separator(repo_root)
|
||||
name = command_name.lstrip("/")
|
||||
if name.startswith("speckit."):
|
||||
name = name[len("speckit.") :]
|
||||
elif name.startswith("speckit-"):
|
||||
name = name[len("speckit-") :]
|
||||
name = name.replace(".", separator)
|
||||
return f"/speckit{separator}{name}"
|
||||
@@ -46,6 +46,7 @@ from ._console import (
|
||||
BannerGroup,
|
||||
StepTracker,
|
||||
console,
|
||||
err_console,
|
||||
get_key as get_key,
|
||||
select_with_arrows as select_with_arrows,
|
||||
show_banner,
|
||||
@@ -507,20 +508,35 @@ _register_extension_cmds(app)
|
||||
from .integrations._commands import register as _register_integration_cmds # noqa: E402
|
||||
_register_integration_cmds(app)
|
||||
|
||||
# Re-exported from integrations/_helpers.py to preserve the public import surface.
|
||||
# Re-export selected helpers to preserve the public import surface.
|
||||
from .integrations._helpers import ( # noqa: E402
|
||||
_clear_init_options_for_integration as _clear_init_options_for_integration,
|
||||
_update_init_options_for_integration as _update_init_options_for_integration,
|
||||
)
|
||||
from ._project import _resolve_init_dir_override as _resolve_init_dir_override # noqa: E402
|
||||
|
||||
|
||||
def _require_specify_project() -> Path:
|
||||
"""Return the current project root if it is a spec-kit project, else exit."""
|
||||
"""Return the project root if it is a spec-kit project, else exit.
|
||||
|
||||
Honors the ``SPECIFY_INIT_DIR`` override (same validation rules as the shell
|
||||
scripts) so a member project can be targeted from a monorepo root without
|
||||
``cd``. This is the resolution chokepoint for *every* project-scoped
|
||||
subcommand — ``integration``, ``extension``, ``workflow``, ``preset``, and the
|
||||
rest that operate on an existing ``.specify/`` project — so the override
|
||||
applies to all of them uniformly. When the override is unset, the project is
|
||||
the current directory, as before.
|
||||
"""
|
||||
override = _resolve_init_dir_override()
|
||||
if override is not None:
|
||||
return override
|
||||
project_root = Path.cwd()
|
||||
if (project_root / ".specify").is_dir():
|
||||
return project_root
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
err_console.print("[red]Error:[/red] Not a Spec Kit project (no .specify/ directory)")
|
||||
err_console.print(
|
||||
"Run this command from a Spec Kit project root or set SPECIFY_INIT_DIR to one."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
|
||||
@@ -17,4 +17,8 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
|
||||
|
||||
DEFAULT_INIT_INTEGRATION = "copilot"
|
||||
|
||||
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
||||
SCRIPT_TYPE_CHOICES: dict[str, str] = {
|
||||
"sh": "POSIX Shell (bash/zsh)",
|
||||
"ps": "PowerShell",
|
||||
"py": "Python",
|
||||
}
|
||||
|
||||
@@ -127,7 +127,14 @@ def resolve_github_release_asset_api_url(
|
||||
if hostname == "github.com":
|
||||
api_base = "https://api.github.com"
|
||||
elif is_ghes:
|
||||
authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
|
||||
# ``parsed.port`` raises ValueError on a malformed port (e.g.
|
||||
# ``host:notaport``); the function's contract is to return None for
|
||||
# anything it can't resolve, not to raise.
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
authority = hostname if port is None else f"{hostname}:{port}"
|
||||
api_base = f"{parsed.scheme}://{authority}/api/v3"
|
||||
else:
|
||||
return None
|
||||
|
||||
53
src/specify_cli/_project.py
Normal file
53
src/specify_cli/_project.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Shared project-resolution helpers for the Specify CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from ._console import err_console
|
||||
|
||||
|
||||
def _resolve_init_dir_override() -> Path | None:
|
||||
"""Resolve the ``SPECIFY_INIT_DIR`` project override for the Python CLI.
|
||||
|
||||
Applies the same validation rules as the shell resolver
|
||||
(``resolve_specify_init_dir`` in ``scripts/bash/common.sh``): the value names
|
||||
the project root — the directory *containing* ``.specify/`` — and is strict.
|
||||
Relative paths resolve against the current directory; the path must exist and
|
||||
contain ``.specify/``, otherwise this hard-errors with no fallback to cwd
|
||||
(which would silently operate on the wrong project's files). The error
|
||||
messages mirror the shell resolver's wording (rendered here as a Rich
|
||||
``Error:`` line, plain ``ERROR:`` in the shell) so the two surfaces read
|
||||
consistently.
|
||||
|
||||
Returns the validated absolute project root, or ``None`` when the variable is
|
||||
unset/empty, in which case callers keep their existing cwd-based behavior.
|
||||
|
||||
Note: this canonicalizes symlinks via :meth:`Path.resolve` (physical path),
|
||||
whereas the shell ``cd -- "$X" && pwd`` keeps the logical path. The two agree
|
||||
for non-symlinked paths; a symlinked ``SPECIFY_INIT_DIR`` can resolve to
|
||||
different strings across the surfaces. The canonical form is the safer choice
|
||||
here (a stable project identity), so this is a deliberate, documented variance,
|
||||
not a parity guarantee on the resolved string.
|
||||
"""
|
||||
raw = os.environ.get("SPECIFY_INIT_DIR", "")
|
||||
if not raw:
|
||||
return None
|
||||
# Relative values resolve against cwd; an absolute value stands alone (Path's
|
||||
# `/` drops the left operand when the right is absolute). resolve() also
|
||||
# collapses a trailing slash and canonicalizes symlinks.
|
||||
init_root = (Path.cwd() / raw).resolve()
|
||||
if not init_root.is_dir():
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] SPECIFY_INIT_DIR does not point to an existing directory: {raw}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if not (init_root / ".specify").is_dir():
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): {init_root}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
return init_root
|
||||
@@ -148,7 +148,9 @@ class CommandRegistrar:
|
||||
)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
||||
def _adjust_script_paths(
|
||||
self, frontmatter: dict, extension_id: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Normalize script paths in frontmatter to generated project locations.
|
||||
|
||||
Rewrites known repo-relative and top-level script paths under the
|
||||
@@ -158,6 +160,7 @@ class CommandRegistrar:
|
||||
|
||||
Args:
|
||||
frontmatter: Frontmatter dictionary
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
|
||||
Returns:
|
||||
Modified frontmatter with normalized project paths
|
||||
@@ -168,11 +171,15 @@ class CommandRegistrar:
|
||||
if isinstance(scripts, dict):
|
||||
for key, script_path in scripts.items():
|
||||
if isinstance(script_path, str):
|
||||
scripts[key] = self.rewrite_project_relative_paths(script_path)
|
||||
scripts[key] = self.rewrite_project_relative_paths(
|
||||
script_path, extension_id=extension_id
|
||||
)
|
||||
return frontmatter
|
||||
|
||||
@staticmethod
|
||||
def rewrite_project_relative_paths(text: str) -> str:
|
||||
def rewrite_project_relative_paths(
|
||||
text: str, extension_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""Rewrite repo-relative paths to their generated project locations."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
@@ -184,10 +191,18 @@ class CommandRegistrar:
|
||||
):
|
||||
text = text.replace(old, new)
|
||||
|
||||
# Only rewrite top-level style references so extension-local paths like
|
||||
# ".specify/extensions/<ext>/scripts/..." remain intact.
|
||||
# Only rewrite top-level style references so existing generated paths
|
||||
# like ".specify/extensions/<ext>/scripts/..." remain intact. When
|
||||
# rendering extension commands, top-level "scripts/" is extension-local.
|
||||
scripts_replacement = (
|
||||
f".specify/extensions/{extension_id}/scripts/"
|
||||
if extension_id
|
||||
else ".specify/scripts/"
|
||||
)
|
||||
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
|
||||
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
|
||||
text = re.sub(
|
||||
r'(^|[\s`"\'(])(?:\.?/)?scripts/', rf"\1{scripts_replacement}", text
|
||||
)
|
||||
text = re.sub(
|
||||
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
|
||||
)
|
||||
@@ -312,6 +327,7 @@ class CommandRegistrar:
|
||||
source_id: str,
|
||||
source_file: str,
|
||||
project_root: Path,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Render a command override as a SKILL.md file.
|
||||
|
||||
@@ -331,7 +347,7 @@ class CommandRegistrar:
|
||||
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root
|
||||
agent_name, frontmatter, body, project_root, extension_id=extension_id
|
||||
)
|
||||
|
||||
description = frontmatter.get(
|
||||
@@ -393,7 +409,11 @@ class CommandRegistrar:
|
||||
|
||||
@staticmethod
|
||||
def resolve_skill_placeholders(
|
||||
agent_name: str, frontmatter: dict, body: str, project_root: Path
|
||||
agent_name: str,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
project_root: Path,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Resolve script placeholders for skills-backed agents."""
|
||||
if not isinstance(frontmatter, dict):
|
||||
@@ -433,7 +453,9 @@ class CommandRegistrar:
|
||||
|
||||
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
|
||||
|
||||
return CommandRegistrar.rewrite_project_relative_paths(body)
|
||||
return CommandRegistrar.rewrite_project_relative_paths(
|
||||
body, extension_id=extension_id
|
||||
)
|
||||
|
||||
def _convert_argument_placeholder(
|
||||
self, content: str, from_placeholder: str, to_placeholder: str
|
||||
@@ -528,6 +550,7 @@ class CommandRegistrar:
|
||||
context_note: str = None,
|
||||
_resolved_dir: Path = None,
|
||||
link_outputs: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""Register commands for a specific agent.
|
||||
|
||||
@@ -545,6 +568,7 @@ class CommandRegistrar:
|
||||
link_outputs: If True, write rendered output to a source-local
|
||||
dev cache and symlink the agent command file to it. Falls back
|
||||
to a normal file write when symlinks are unavailable.
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
@@ -614,7 +638,9 @@ class CommandRegistrar:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
frontmatter.pop("strategy", None)
|
||||
|
||||
frontmatter = self._adjust_script_paths(frontmatter)
|
||||
frontmatter = self._adjust_script_paths(
|
||||
frontmatter, extension_id=extension_id
|
||||
)
|
||||
|
||||
for key in agent_config.get("strip_frontmatter_keys", []):
|
||||
frontmatter.pop(key, None)
|
||||
@@ -653,10 +679,11 @@ class CommandRegistrar:
|
||||
source_id,
|
||||
cmd_file,
|
||||
project_root,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root
|
||||
agent_name, frontmatter, body, project_root, extension_id=extension_id
|
||||
)
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
@@ -666,7 +693,7 @@ class CommandRegistrar:
|
||||
)
|
||||
elif agent_config["format"] == "toml":
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root
|
||||
agent_name, frontmatter, body, project_root, extension_id=extension_id
|
||||
)
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
@@ -679,6 +706,17 @@ class CommandRegistrar:
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
# -- Post-process for non-skills agents -----------------------
|
||||
_integration = None
|
||||
if agent_config["extension"] != "/SKILL.md":
|
||||
from specify_cli.integrations import ( # noqa: PLC0415
|
||||
get_integration,
|
||||
)
|
||||
|
||||
_integration = get_integration(agent_name)
|
||||
if _integration is not None:
|
||||
output = _integration.post_process_command_content(output)
|
||||
|
||||
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
self._ensure_inside(dest_file, commands_dir)
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -721,6 +759,7 @@ class CommandRegistrar:
|
||||
source_id,
|
||||
cmd_file,
|
||||
project_root,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
alias_output = self.render_markdown_command(
|
||||
@@ -738,6 +777,9 @@ class CommandRegistrar:
|
||||
raise ValueError(
|
||||
f"Unsupported format: {agent_config['format']}"
|
||||
)
|
||||
|
||||
if agent_config["extension"] != "/SKILL.md" and _integration is not None:
|
||||
alias_output = _integration.post_process_command_content(alias_output)
|
||||
else:
|
||||
# For other agents, reuse the primary output
|
||||
alias_output = output
|
||||
@@ -750,6 +792,7 @@ class CommandRegistrar:
|
||||
source_id,
|
||||
cmd_file,
|
||||
project_root,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
|
||||
alias_file = (
|
||||
@@ -881,6 +924,7 @@ class CommandRegistrar:
|
||||
context_note: str = None,
|
||||
link_outputs: bool = False,
|
||||
create_missing_active_skills_dir: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all detected agents in the project.
|
||||
|
||||
@@ -897,6 +941,7 @@ class CommandRegistrar:
|
||||
Recovery requires active skills mode (or Kimi's existing native
|
||||
skills directory) and is skipped when safe resolution or
|
||||
creation fails.
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
@@ -999,6 +1044,7 @@ class CommandRegistrar:
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
link_outputs=link_outputs,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
@@ -1023,6 +1069,7 @@ class CommandRegistrar:
|
||||
project_root: Path,
|
||||
context_note: Optional[str] = None,
|
||||
link_outputs: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all non-skill agents in the project.
|
||||
|
||||
@@ -1038,6 +1085,7 @@ class CommandRegistrar:
|
||||
context_note: Custom context comment for markdown output
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
command files when supported by the OS.
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
@@ -1066,6 +1114,7 @@ class CommandRegistrar:
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
link_outputs=link_outputs,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
|
||||
@@ -73,6 +73,13 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
self._redirect_validator = redirect_validator
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
try:
|
||||
new_parsed = urlparse(newurl)
|
||||
except ValueError as exc:
|
||||
# Malformed redirect target (e.g. unterminated IPv6 bracket).
|
||||
# Surface as URLError so callers' download error handling applies.
|
||||
raise urllib.error.URLError(f"malformed redirect URL: {exc}") from exc
|
||||
|
||||
if self._redirect_validator is not None:
|
||||
self._redirect_validator(req.full_url, newurl)
|
||||
|
||||
@@ -83,7 +90,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_req is not None:
|
||||
old_scheme = urlparse(req.full_url).scheme
|
||||
new_parsed = urlparse(newurl)
|
||||
hostname = (new_parsed.hostname or "").lower()
|
||||
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
|
||||
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
|
||||
|
||||
@@ -95,7 +95,11 @@ def _is_local_path(url: str) -> bool:
|
||||
"""True when *url* denotes a local filesystem path rather than a URL."""
|
||||
if _WINDOWS_DRIVE_RE.match(url):
|
||||
return True
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
try:
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
except ValueError:
|
||||
# Malformed URLs (e.g. an unclosed IPv6 bracket) are not local paths.
|
||||
return False
|
||||
return scheme not in _REMOTE_SCHEMES
|
||||
|
||||
|
||||
@@ -137,7 +141,10 @@ def add_source(
|
||||
url = url.strip()
|
||||
if not url:
|
||||
raise BundlerError("A catalog url is required.")
|
||||
parsed = urlparse(url)
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError as exc:
|
||||
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
|
||||
if not (parsed.scheme or parsed.path):
|
||||
raise BundlerError(f"Invalid catalog url: '{url}'.")
|
||||
# Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are
|
||||
@@ -148,6 +155,20 @@ def add_source(
|
||||
f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. "
|
||||
"Use http(s)://, file://, builtin://, or a local path."
|
||||
)
|
||||
if parsed.scheme.lower() in {"http", "https"}:
|
||||
# Mirror specify_cli.catalogs._validate_catalog_url (#3209/#3210):
|
||||
# HTTPS only (HTTP just for localhost), and check hostname, not
|
||||
# netloc — netloc is truthy for host-less URLs like "https://:8080"
|
||||
# or "https://user@". Validating here keeps junk out of
|
||||
# bundle-catalogs.yml instead of failing later at fetch time.
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme.lower() != "https" and not is_localhost:
|
||||
raise BundlerError(
|
||||
f"Catalog url must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
|
||||
|
||||
url = _canonicalize_url(url)
|
||||
install_policy = InstallPolicy.parse(policy)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..._project import _resolve_init_dir_override
|
||||
from .. import BundlerError
|
||||
from .yamlio import ensure_within, load_json
|
||||
|
||||
@@ -15,7 +16,26 @@ def find_project_root(start: Path | None = None) -> Path | None:
|
||||
A symlinked ``.specify`` is not accepted as a project root: following it
|
||||
could read/write outside the intended tree, and other CLI surfaces refuse
|
||||
it for the same reason.
|
||||
|
||||
When *start* is ``None`` the ``SPECIFY_INIT_DIR`` override is honored first
|
||||
(see :func:`specify_cli._project._resolve_init_dir_override`). With an
|
||||
explicit override this may **raise** rather than return: a set-but-invalid
|
||||
value raises ``typer.Exit`` and a symlinked ``.specify`` raises
|
||||
``BundlerError``. That is deliberate — returning ``None`` would let
|
||||
``bundle init``/``install`` silently fall back to the current directory.
|
||||
"""
|
||||
if start is None:
|
||||
override = _resolve_init_dir_override()
|
||||
if override is not None:
|
||||
# An explicit override is strict: do not return None here, because
|
||||
# bundle install treats None as "init the current directory".
|
||||
if (override / ".specify").is_symlink():
|
||||
raise BundlerError(
|
||||
"SPECIFY_INIT_DIR is not a safe Spec Kit project "
|
||||
f"(symlinked .specify/ directory is not allowed): {override}"
|
||||
)
|
||||
return override
|
||||
|
||||
current = Path(start or Path.cwd()).resolve()
|
||||
for candidate in (current, *current.parents):
|
||||
marker = candidate / ".specify"
|
||||
@@ -25,7 +45,13 @@ def find_project_root(start: Path | None = None) -> Path | None:
|
||||
|
||||
|
||||
def require_project_root(start: Path | None = None) -> Path:
|
||||
"""Return the Spec Kit project root or raise an actionable error."""
|
||||
"""Return the Spec Kit project root or raise an actionable error.
|
||||
|
||||
Inherits :func:`find_project_root`'s override behavior: when *start* is
|
||||
``None``, a set-but-invalid ``SPECIFY_INIT_DIR`` raises ``typer.Exit`` and a
|
||||
symlinked ``.specify`` raises ``BundlerError`` before this returns. A missing
|
||||
project (no override) raises ``BundlerError``.
|
||||
"""
|
||||
root = find_project_root(start)
|
||||
if root is None:
|
||||
raise BundlerError(
|
||||
|
||||
@@ -75,7 +75,11 @@ def _validate_remote_url(source_id: str, url: str) -> None:
|
||||
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
# Check hostname, not netloc: netloc is truthy for host-less URLs like
|
||||
# "https://:8080" or "https://user@...", so requiring netloc would let
|
||||
# those through even though they carry no host. hostname is None in those
|
||||
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
|
||||
if not parsed.hostname:
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
|
||||
)
|
||||
|
||||
@@ -88,17 +88,25 @@ class CatalogStack:
|
||||
Results are sorted by bundle id for deterministic output.
|
||||
"""
|
||||
needle = query.strip().lower()
|
||||
seen: dict[str, ResolvedBundle] = {}
|
||||
# Resolve each id to its highest-precedence entry FIRST, then filter by
|
||||
# the query. Claiming an id only when it matches would let a lower-
|
||||
# precedence entry with the same id surface when the highest-precedence
|
||||
# one doesn't match the query — but that shadowed entry is not what
|
||||
# `resolve()`/install would use, so search would advertise a bundle
|
||||
# (name, version, author) the user can never actually get.
|
||||
resolved: dict[str, ResolvedBundle] = {}
|
||||
for source in self._sources:
|
||||
for bundle_id, entry in self._entries_for(source).items():
|
||||
if bundle_id in seen:
|
||||
if bundle_id in resolved:
|
||||
continue
|
||||
if needle and not _matches(entry, needle):
|
||||
continue
|
||||
seen[bundle_id] = ResolvedBundle(
|
||||
resolved[bundle_id] = ResolvedBundle(
|
||||
entry=entry.with_provenance(source), source=source
|
||||
)
|
||||
return [seen[k] for k in sorted(seen)]
|
||||
return [
|
||||
resolved[k]
|
||||
for k in sorted(resolved)
|
||||
if not needle or _matches(resolved[k].entry, needle)
|
||||
]
|
||||
|
||||
|
||||
def _matches(entry: CatalogEntry, needle: str) -> bool:
|
||||
|
||||
@@ -130,6 +130,28 @@ def install_bundle(
|
||||
done.append(component)
|
||||
result.installed.append(component)
|
||||
contributed.append(component)
|
||||
|
||||
# On update (refresh), uninstall components this bundle used to own
|
||||
# that the new version no longer ships. Otherwise they are dropped
|
||||
# from the record below (contributed only holds plan.components) yet
|
||||
# left on disk — permanently orphaned, since no bundle record can
|
||||
# ever remove them. A stale component still owned by another bundle
|
||||
# is kept installed and simply de-attributed here (it stays in that
|
||||
# bundle's record). Mirrors remove_bundle's refcount logic.
|
||||
if refresh and existing is not None:
|
||||
planned = {(c.kind, c.id) for c in plan.components}
|
||||
still_needed = components_still_needed(
|
||||
records, exclude_bundle_id=plan.bundle_id
|
||||
)
|
||||
for component in existing.contributed_components:
|
||||
key = (component.kind, component.id)
|
||||
if key in planned:
|
||||
continue
|
||||
if key in still_needed:
|
||||
continue
|
||||
if installer.is_installed(project_root, component):
|
||||
installer.remove(project_root, component)
|
||||
result.uninstalled.append(component)
|
||||
except BundlerError:
|
||||
_rollback(project_root, installer, done)
|
||||
raise
|
||||
|
||||
@@ -631,6 +631,14 @@ def catalog_remove(
|
||||
console.print(f"[green]✓[/green] Removed catalog source '{removed}'.")
|
||||
|
||||
|
||||
# ZIP magic-byte signatures used to detect .zip payloads from REST API asset
|
||||
# URLs, which carry no file extension. The three signatures cover all valid
|
||||
# ZIP variants (PK\x03\x04 = local file header, PK\x05\x06 = empty archive,
|
||||
# PK\x07\x08 = spanning marker) without the false-positive risk of checking
|
||||
# only the 2-byte "PK" prefix.
|
||||
_ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
|
||||
|
||||
|
||||
# ===== internal helpers =====
|
||||
|
||||
|
||||
@@ -794,41 +802,110 @@ def _download_remote_manifest(entry_id: str, url: str):
|
||||
"""Fetch a remote bundle artifact over HTTPS and extract its manifest."""
|
||||
import io
|
||||
import tempfile
|
||||
from pathlib import PurePosixPath
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
from ...authentication.http import open_url
|
||||
import yaml as _yaml
|
||||
|
||||
from ...authentication.http import github_provider_hosts, open_url
|
||||
from ..._github_http import resolve_github_release_asset_api_url
|
||||
from ...bundler.models.manifest import BundleManifest
|
||||
|
||||
def _validate_redirect(old_url: str, new_url: str) -> None:
|
||||
_require_https(f"bundle '{entry_id}'", new_url)
|
||||
|
||||
_require_https(f"bundle '{entry_id}'", url)
|
||||
|
||||
# For private/SSO-protected GitHub repos, browser release download URLs
|
||||
# (https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>)
|
||||
# redirect to an HTML/SSO page instead of delivering the asset. Resolve
|
||||
# such URLs to the GitHub REST API asset URL so the authenticated client
|
||||
# can download the actual file.
|
||||
extra_headers = None
|
||||
effective_url = url
|
||||
resolved = resolve_github_release_asset_api_url(
|
||||
url, open_url, timeout=30, github_hosts=github_provider_hosts()
|
||||
)
|
||||
if resolved:
|
||||
effective_url = resolved
|
||||
_require_https(f"bundle '{entry_id}'", effective_url)
|
||||
extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
# Human-readable description of where the bytes came from, reused across
|
||||
# all post-download error messages so failures point at the catalog URL
|
||||
# (and resolved API URL, if any) instead of an opaque temp path.
|
||||
if effective_url != url:
|
||||
_source_desc = f"{url} (resolved to {effective_url})"
|
||||
else:
|
||||
_source_desc = url
|
||||
|
||||
try:
|
||||
with open_url(url, timeout=30, redirect_validator=_validate_redirect) as resp:
|
||||
with open_url(
|
||||
effective_url,
|
||||
timeout=30,
|
||||
redirect_validator=_validate_redirect,
|
||||
extra_headers=extra_headers,
|
||||
) as resp:
|
||||
_require_https(f"bundle '{entry_id}'", resp.geturl())
|
||||
raw = resp.read()
|
||||
except BundlerError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise BundlerError(f"Failed to download bundle '{entry_id}' from {url}: {exc}") from exc
|
||||
# Report the original catalog URL so users know which entry to fix,
|
||||
# and include the resolved URL when it differs for easier debugging.
|
||||
raise BundlerError(
|
||||
f"Failed to download bundle '{entry_id}' from {_source_desc}: {exc}"
|
||||
) from exc
|
||||
|
||||
# A .zip artifact is written to a temp file and parsed via the local-source
|
||||
# path (which extracts bundle.yml); any other payload is treated as YAML.
|
||||
if url.lower().endswith(".zip"):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
artifact = Path(tmp) / "bundle.zip"
|
||||
artifact.write_bytes(raw)
|
||||
manifest = _local_manifest_source(str(artifact))
|
||||
if manifest is None:
|
||||
raise BundlerError(
|
||||
f"Downloaded artifact for bundle '{entry_id}' is not a valid bundle."
|
||||
)
|
||||
return manifest
|
||||
# Detection uses the path component of the original catalog URL (via
|
||||
# PurePosixPath so query strings and fragments are ignored, and URL paths
|
||||
# are always treated as POSIX regardless of host OS), falling back to the
|
||||
# module-level _ZIP_SIGNATURES magic-byte check for direct REST API asset
|
||||
# URLs which carry no file extension.
|
||||
_url_ext = PurePosixPath(_urlparse(url).path).suffix.lower()
|
||||
try:
|
||||
if _url_ext == ".zip" or raw[:4] in _ZIP_SIGNATURES:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
artifact = Path(tmp) / "bundle.zip"
|
||||
artifact.write_bytes(raw)
|
||||
# Wrap ZIP parsing so any failure (BadZipFile, missing
|
||||
# bundle.yml, etc.) references the source URL rather than the
|
||||
# opaque temporary path, consistent with the download-error
|
||||
# handling above.
|
||||
try:
|
||||
manifest = _local_manifest_source(str(artifact))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise BundlerError(
|
||||
f"Downloaded artifact for bundle '{entry_id}' from "
|
||||
f"{_source_desc} is not a valid bundle: {exc}"
|
||||
) from exc
|
||||
# _local_manifest_source returns None only when the file does
|
||||
# not exist; since we just wrote *artifact* that cannot happen
|
||||
# here. The explicit guard ensures callers never receive None
|
||||
# and silently degrade instead of raising a clear error.
|
||||
if manifest is None:
|
||||
raise BundlerError(
|
||||
f"Downloaded artifact for bundle '{entry_id}' from "
|
||||
f"{_source_desc} is not a valid bundle."
|
||||
)
|
||||
return manifest
|
||||
|
||||
import yaml as _yaml
|
||||
|
||||
from ...bundler.models.manifest import BundleManifest
|
||||
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
return BundleManifest.from_dict(data)
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
return BundleManifest.from_dict(data)
|
||||
except BundlerError:
|
||||
raise
|
||||
except _yaml.YAMLError as exc:
|
||||
raise BundlerError(
|
||||
f"Downloaded content for bundle '{entry_id}' from {_source_desc} "
|
||||
f"is not valid YAML: {exc}"
|
||||
) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise BundlerError(
|
||||
f"Failed to parse downloaded bundle '{entry_id}' from "
|
||||
f"{_source_desc}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
|
||||
@@ -26,6 +26,7 @@ import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
from .._assets import _locate_core_pack, _repo_root
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
|
||||
from .._utils import dump_frontmatter, relative_extension_path_violation, version_satisfies
|
||||
@@ -62,14 +63,28 @@ def _load_core_command_names() -> frozenset[str]:
|
||||
Prefer the wheel-time ``core_pack`` bundle when present, and fall back to
|
||||
the source checkout when running from the repository. If neither is
|
||||
available, use the baked-in fallback set so validation still works.
|
||||
|
||||
Path resolution is delegated to the canonical ``_assets`` resolvers
|
||||
(``_locate_core_pack`` / ``_repo_root``) — the same ones the presets and
|
||||
bundle loaders use — rather than bespoke ``Path(__file__)`` arithmetic.
|
||||
Hand-counted ``.parent`` chains silently broke discovery once already: the
|
||||
#3014 move of this module from ``specify_cli/extensions.py`` to
|
||||
``specify_cli/extensions/__init__.py`` pushed the file one directory deeper
|
||||
without updating the counts, so both candidates resolved to non-existent
|
||||
paths and every call fell through to the fallback (#3274). The shared
|
||||
resolvers are anchored to the package root, so discovery survives future
|
||||
module moves.
|
||||
"""
|
||||
core_pack = _locate_core_pack()
|
||||
candidate_dirs = [
|
||||
Path(__file__).parent / "core_pack" / "commands",
|
||||
Path(__file__).resolve().parent.parent.parent / "templates" / "commands",
|
||||
# Wheel install: force-include maps templates/commands → core_pack/commands.
|
||||
core_pack / "commands" if core_pack is not None else None,
|
||||
# Source checkout / editable install: repo-root templates/commands.
|
||||
_repo_root() / "templates" / "commands",
|
||||
]
|
||||
|
||||
for commands_dir in candidate_dirs:
|
||||
if not commands_dir.is_dir():
|
||||
if commands_dir is None or not commands_dir.is_dir():
|
||||
continue
|
||||
|
||||
command_names = {
|
||||
@@ -1060,9 +1075,11 @@ class ExtensionManager:
|
||||
pass # best-effort cleanup
|
||||
continue
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
frontmatter = registrar._adjust_script_paths(frontmatter)
|
||||
frontmatter = registrar._adjust_script_paths(
|
||||
frontmatter, extension_id=manifest.id
|
||||
)
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
|
||||
)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
@@ -1943,6 +1960,7 @@ class CommandRegistrar:
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
extension_id=manifest.id,
|
||||
)
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
@@ -1963,6 +1981,7 @@ class CommandRegistrar:
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
create_missing_active_skills_dir=create_missing_active_skills_dir,
|
||||
extension_id=manifest.id,
|
||||
)
|
||||
|
||||
def unregister_commands(
|
||||
@@ -2673,7 +2692,12 @@ class ConfigManager:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
|
||||
data = yaml.safe_load(file_path.read_text(encoding="utf-8"))
|
||||
# Coerce a non-mapping root (list/scalar, or None for an empty
|
||||
# file) to {} so callers that iterate/merge the result — e.g.
|
||||
# _merge_configs' .items() — never crash. Mirrors the same
|
||||
# non-dict-root guard in get_project_config().
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (yaml.YAMLError, OSError, UnicodeError):
|
||||
return {}
|
||||
|
||||
|
||||
@@ -426,7 +426,11 @@ def extension_add(
|
||||
if from_url and not dev:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(from_url)
|
||||
try:
|
||||
parsed = urlparse(from_url)
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
|
||||
@@ -32,6 +32,8 @@ def integration_scaffold(
|
||||
"""Create a minimal built-in integration package and test skeleton."""
|
||||
from ..integration_scaffold import scaffold_integration
|
||||
|
||||
# scaffold targets the Spec Kit *source* repo layout (_is_spec_kit_repo_root),
|
||||
# not a .specify/ member project, so SPECIFY_INIT_DIR does not apply here.
|
||||
project_root = Path.cwd()
|
||||
try:
|
||||
result = scaffold_integration(project_root, key, integration_type.value)
|
||||
|
||||
@@ -89,7 +89,11 @@ class AgyIntegration(SkillsIntegration):
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
# agy does not support --model or JSON output; both params are ignored
|
||||
return [self._resolve_executable(), "--print", prompt]
|
||||
args = [self._resolve_executable(), "--print", prompt]
|
||||
# Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags),
|
||||
# appended after the positional prompt like the devin integration.
|
||||
self._apply_extra_args_env_var(args)
|
||||
return args
|
||||
|
||||
def setup(
|
||||
self,
|
||||
|
||||
@@ -17,6 +17,7 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -122,6 +123,19 @@ class IntegrationBase(ABC):
|
||||
integration that sets this flag.
|
||||
"""
|
||||
|
||||
def post_process_command_content(self, content: str) -> str:
|
||||
"""Transform command content after format rendering.
|
||||
|
||||
Called by ``register_commands()`` for non-skills format types
|
||||
(Markdown, TOML, YAML) after the command has been rendered into
|
||||
its target format and before writing to disk. Skills-format
|
||||
agents use ``post_process_skill_content()`` instead.
|
||||
|
||||
Subclasses may override to inject agent-specific content.
|
||||
The default implementation returns *content* unchanged.
|
||||
"""
|
||||
return content
|
||||
|
||||
# -- Public API -------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
@@ -495,8 +509,8 @@ class IntegrationBase(ABC):
|
||||
|
||||
Copies files from this integration's ``scripts/`` directory to
|
||||
``.specify/integrations/<key>/scripts/`` in the project. Shell
|
||||
scripts are made executable. All copied files are recorded in
|
||||
*manifest*.
|
||||
(``.sh``) and Python (``.py``) scripts are made executable. All
|
||||
copied files are recorded in *manifest*.
|
||||
|
||||
Returns the list of files created.
|
||||
"""
|
||||
@@ -513,7 +527,7 @@ class IntegrationBase(ABC):
|
||||
continue
|
||||
dst_script = scripts_dest / src_script.name
|
||||
shutil.copy2(src_script, dst_script)
|
||||
if dst_script.suffix == ".sh":
|
||||
if dst_script.suffix in (".sh", ".py"):
|
||||
dst_script.chmod(dst_script.stat().st_mode | 0o111)
|
||||
self.record_file_in_manifest(dst_script, project_root, manifest)
|
||||
created.append(dst_script)
|
||||
@@ -538,6 +552,47 @@ class IntegrationBase(ABC):
|
||||
content,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve_python_interpreter(project_root: Path | None = None) -> str:
|
||||
"""Resolve a portable Python interpreter command for ``{SCRIPT}``.
|
||||
|
||||
Used to build the invocation string for the ``py`` script type so
|
||||
that ``.py`` workflow scripts run consistently across platforms
|
||||
(notably Windows, where ``.py`` files are not directly executable).
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. A project virtual environment (``.venv``) interpreter, if one
|
||||
exists under *project_root* (POSIX ``bin/python`` or Windows
|
||||
``Scripts/python.exe``). The returned path is **relative to the
|
||||
project root** (e.g. ``.venv/bin/python``) so generated
|
||||
``{SCRIPT}`` invocations stay portable and runnable from the
|
||||
repo root regardless of where the project lives.
|
||||
2. ``python3`` on ``PATH``.
|
||||
3. ``python`` on ``PATH``.
|
||||
|
||||
Falls back to the running interpreter (``sys.executable``) when
|
||||
``PATH`` resolution fails so the generated command is guaranteed
|
||||
to work in the current environment, and finally to ``"python3"``
|
||||
if even that is unavailable.
|
||||
"""
|
||||
if project_root is not None:
|
||||
# (existence check path, repo-root-relative invocation string)
|
||||
venv_candidates = (
|
||||
(project_root / ".venv" / "bin" / "python", ".venv/bin/python"),
|
||||
(
|
||||
project_root / ".venv" / "Scripts" / "python.exe",
|
||||
".venv/Scripts/python.exe",
|
||||
),
|
||||
)
|
||||
for candidate, relative in venv_candidates:
|
||||
if candidate.exists():
|
||||
return relative
|
||||
for name in ("python3", "python"):
|
||||
if shutil.which(name):
|
||||
return name
|
||||
return sys.executable or "python3"
|
||||
|
||||
@staticmethod
|
||||
def process_template(
|
||||
content: str,
|
||||
@@ -545,6 +600,7 @@ class IntegrationBase(ABC):
|
||||
script_type: str,
|
||||
arg_placeholder: str = "$ARGUMENTS",
|
||||
invoke_separator: str = ".",
|
||||
project_root: Path | None = None,
|
||||
) -> str:
|
||||
"""Process a raw command template into agent-ready content.
|
||||
|
||||
@@ -578,6 +634,17 @@ class IntegrationBase(ABC):
|
||||
|
||||
# 2. Replace {SCRIPT}
|
||||
if script_command:
|
||||
# For the Python script type, prefix the resolved interpreter so
|
||||
# the command is portable (``.py`` files are not directly
|
||||
# executable on Windows).
|
||||
if script_type == "py":
|
||||
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
||||
# Quote the interpreter if it contains whitespace (e.g. an
|
||||
# absolute ``sys.executable`` path under Windows
|
||||
# ``Program Files``) so it isn't split into multiple args.
|
||||
if any(ch.isspace() for ch in interpreter):
|
||||
interpreter = f'"{interpreter}"'
|
||||
script_command = f"{interpreter} {script_command}"
|
||||
content = content.replace("{SCRIPT}", script_command)
|
||||
|
||||
# 3. Strip scripts: section from frontmatter
|
||||
@@ -784,6 +851,7 @@ class MarkdownIntegration(IntegrationBase):
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
project_root=project_root,
|
||||
)
|
||||
dst_name = self.command_filename(src_file.stem)
|
||||
dst_file = self.write_file_and_record(
|
||||
@@ -986,6 +1054,7 @@ class TomlIntegration(IntegrationBase):
|
||||
description = self._extract_description(raw)
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
project_root=project_root,
|
||||
)
|
||||
_, body = self._split_frontmatter(processed)
|
||||
toml_content = self._render_toml(description, body)
|
||||
@@ -1122,12 +1191,18 @@ class YamlIntegration(IntegrationBase):
|
||||
default_flow_style=False,
|
||||
).strip()
|
||||
|
||||
# Indent the body for YAML block scalar
|
||||
# Indent the body for YAML block scalar. Use an explicit indentation
|
||||
# indicator ("|2") rather than a bare "|": YAML infers a plain block
|
||||
# scalar's indentation from its first non-empty line, so a body whose
|
||||
# first line is itself indented (e.g. a markdown code block or a nested
|
||||
# list item) would make the parser expect that deeper indent for the
|
||||
# whole block and reject the later, less-indented lines. Pinning the
|
||||
# indent to 2 keeps the recipe parseable whatever the body looks like.
|
||||
indented = "\n".join(f" {line}" for line in body.split("\n"))
|
||||
|
||||
lines = [
|
||||
header_yaml,
|
||||
"prompt: |",
|
||||
"prompt: |2",
|
||||
indented,
|
||||
"",
|
||||
f"# Source: {source_id}",
|
||||
@@ -1186,6 +1261,7 @@ class YamlIntegration(IntegrationBase):
|
||||
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
project_root=project_root,
|
||||
)
|
||||
_, body = self._split_frontmatter(processed)
|
||||
yaml_content = self._render_yaml(
|
||||
@@ -1381,6 +1457,7 @@ class SkillsIntegration(IntegrationBase):
|
||||
# Process body through the standard template pipeline
|
||||
processed_body = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
project_root=project_root,
|
||||
invoke_separator=self.invoke_separator,
|
||||
)
|
||||
# Strip the processed frontmatter — we rebuild it for skills.
|
||||
|
||||
@@ -370,6 +370,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
project_root=project_root,
|
||||
)
|
||||
dst_name = self.command_filename(src_file.stem)
|
||||
dst_file = self.write_file_and_record(
|
||||
|
||||
@@ -75,7 +75,15 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
either drops tool calls or exits non-zero on the first approval
|
||||
prompt.
|
||||
"""
|
||||
args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt]
|
||||
args = [
|
||||
self._resolve_executable(),
|
||||
"-p",
|
||||
"--trust",
|
||||
"--approve-mcps",
|
||||
"--force",
|
||||
prompt,
|
||||
]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
|
||||
@@ -134,6 +134,7 @@ class ForgeIntegration(MarkdownIntegration):
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
invoke_separator=self.invoke_separator,
|
||||
project_root=project_root,
|
||||
)
|
||||
|
||||
# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are
|
||||
|
||||
@@ -123,6 +123,7 @@ class GenericIntegration(MarkdownIntegration):
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
project_root=project_root,
|
||||
)
|
||||
dst_name = self.command_filename(src_file.stem)
|
||||
dst_file = self.write_file_and_record(
|
||||
|
||||
@@ -140,6 +140,7 @@ class HermesIntegration(SkillsIntegration):
|
||||
script_type,
|
||||
arg_placeholder,
|
||||
invoke_separator=self.invoke_separator,
|
||||
project_root=project_root,
|
||||
)
|
||||
# Strip the processed frontmatter — we rebuild it for skills.
|
||||
if processed_body.startswith("---"):
|
||||
@@ -252,6 +253,11 @@ class HermesIntegration(SkillsIntegration):
|
||||
"""
|
||||
args = [self._resolve_executable(), "chat", "-Q"]
|
||||
|
||||
# Operator-supplied SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS go here —
|
||||
# after the base command but before Spec Kit's canonical -m/--json/-s/-q
|
||||
# flags — so they can't displace or clobber them (mirrors opencode).
|
||||
self._apply_extra_args_env_var(args)
|
||||
|
||||
if model:
|
||||
args.extend(["-m", model])
|
||||
if output_json:
|
||||
|
||||
@@ -309,7 +309,14 @@ class IntegrationManifest:
|
||||
if abs_path.is_symlink() or not abs_path.is_file():
|
||||
modified.append(rel)
|
||||
continue
|
||||
if _sha256(abs_path) != expected_hash:
|
||||
try:
|
||||
changed = _sha256(abs_path) != expected_hash
|
||||
except OSError:
|
||||
# Unreadable regular file (e.g. permission denied): treat as
|
||||
# modified, consistent with the symlink / non-regular-file
|
||||
# handling above, rather than letting the OSError escape.
|
||||
changed = True
|
||||
if changed:
|
||||
modified.append(rel)
|
||||
return modified
|
||||
|
||||
@@ -358,9 +365,17 @@ class IntegrationManifest:
|
||||
skipped.append(path)
|
||||
continue
|
||||
else:
|
||||
if not force and _sha256(path) != expected_hash:
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not force:
|
||||
try:
|
||||
matches = _sha256(path) == expected_hash
|
||||
except OSError:
|
||||
# Unreadable: can't verify it's ours, so preserve it
|
||||
# (mirrors the path.unlink() OSError guard below).
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not matches:
|
||||
skipped.append(path)
|
||||
continue
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
|
||||
@@ -104,7 +104,13 @@ def preset_add(
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
_parsed = _urlparse(from_url)
|
||||
try:
|
||||
_parsed = _urlparse(from_url)
|
||||
except ValueError:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
def _is_allowed_download_url(parsed_url):
|
||||
host = parsed_url.hostname
|
||||
@@ -135,7 +141,9 @@ def preset_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
||||
from rich.markup import escape as _esc
|
||||
|
||||
console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
|
||||
import urllib.error
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
@@ -19,7 +19,8 @@ import typer
|
||||
import yaml
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console
|
||||
from .._console import console, err_console
|
||||
from .._project import _resolve_init_dir_override
|
||||
|
||||
workflow_app = typer.Typer(
|
||||
name="workflow",
|
||||
@@ -49,7 +50,17 @@ workflow_step_catalog_app = typer.Typer(
|
||||
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
|
||||
|
||||
|
||||
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
|
||||
def _error_console(json_output: bool):
|
||||
"""Console for error text: stderr under ``--json`` so the JSON stdout
|
||||
stream stays parseable, the normal console otherwise. Mirrors the
|
||||
stderr-only error routing already used by ``specify bundle``.
|
||||
"""
|
||||
return err_console if json_output else console
|
||||
|
||||
|
||||
def _parse_input_values(
|
||||
input_values: list[str] | None, *, json_output: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Parse repeated ``key=value`` CLI inputs into a dict.
|
||||
|
||||
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
|
||||
@@ -58,7 +69,9 @@ def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
|
||||
inputs: dict[str, Any] = {}
|
||||
for kv in input_values or []:
|
||||
if "=" not in kv:
|
||||
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
|
||||
_error_console(json_output).print(
|
||||
f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
key, _, value = kv.partition("=")
|
||||
inputs[key.strip()] = value.strip()
|
||||
@@ -74,10 +87,10 @@ def _reject_unsafe_dir(path: Path, label: str) -> None:
|
||||
creates the directory — only an existing-but-wrong target is rejected.
|
||||
"""
|
||||
if path.is_symlink():
|
||||
console.print(f"[red]Error:[/red] Refusing to use symlinked {label} path")
|
||||
err_console.print(f"[red]Error:[/red] Refusing to use symlinked {label} path")
|
||||
raise typer.Exit(1)
|
||||
if path.exists() and not path.is_dir():
|
||||
console.print(f"[red]Error:[/red] {label} path exists but is not a directory")
|
||||
err_console.print(f"[red]Error:[/red] {label} path exists but is not a directory")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -320,9 +333,11 @@ def workflow_run(
|
||||
is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file()
|
||||
|
||||
if is_file_source:
|
||||
# When running a YAML file directly, use cwd as project root
|
||||
# without requiring a .specify/ project directory.
|
||||
project_root = Path.cwd()
|
||||
# When running a YAML file directly, use cwd as project root without
|
||||
# requiring a .specify/ project directory — unless SPECIFY_INIT_DIR
|
||||
# explicitly names a project, in which case the strict override applies.
|
||||
override = _resolve_init_dir_override()
|
||||
project_root = override if override is not None else Path.cwd()
|
||||
_reject_unsafe_workflow_storage(project_root)
|
||||
else:
|
||||
project_root = _require_specify_project()
|
||||
@@ -332,25 +347,26 @@ def workflow_run(
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
err = _error_console(json_output)
|
||||
try:
|
||||
definition = engine.load_workflow(source_path if is_file_source else source)
|
||||
except FileNotFoundError:
|
||||
console.print(f"[red]Error:[/red] Workflow not found: {source}")
|
||||
err.print(f"[red]Error:[/red] Workflow not found: {source}")
|
||||
raise typer.Exit(1)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
|
||||
err.print(f"[red]Error:[/red] Invalid workflow: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate
|
||||
errors = engine.validate(definition)
|
||||
if errors:
|
||||
console.print("[red]Workflow validation failed:[/red]")
|
||||
for err in errors:
|
||||
console.print(f" • {err}")
|
||||
err.print("[red]Workflow validation failed:[/red]")
|
||||
for verr in errors:
|
||||
err.print(f" • {verr}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Parse inputs
|
||||
inputs = _parse_input_values(input_values)
|
||||
inputs = _parse_input_values(input_values, json_output=json_output)
|
||||
|
||||
if not json_output:
|
||||
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
|
||||
@@ -360,10 +376,10 @@ def workflow_run(
|
||||
with _stdout_to_stderr_when(json_output):
|
||||
state = engine.execute(definition, inputs)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
err.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Workflow failed:[/red] {exc}")
|
||||
err.print(f"[red]Workflow failed:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
@@ -408,19 +424,20 @@ def workflow_resume(
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
inputs = _parse_input_values(input_values)
|
||||
inputs = _parse_input_values(input_values, json_output=json_output)
|
||||
err = _error_console(json_output)
|
||||
|
||||
try:
|
||||
with _stdout_to_stderr_when(json_output):
|
||||
state = engine.resume(run_id, inputs or None)
|
||||
except FileNotFoundError:
|
||||
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
||||
err.print(f"[red]Error:[/red] Run not found: {run_id}")
|
||||
raise typer.Exit(1)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
err.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Resume failed:[/red] {exc}")
|
||||
err.print(f"[red]Resume failed:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
@@ -614,7 +631,11 @@ def workflow_add(
|
||||
from urllib.parse import urlparse
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
parsed_src = urlparse(source)
|
||||
try:
|
||||
parsed_src = urlparse(source)
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(source)}")
|
||||
raise typer.Exit(1)
|
||||
src_host = parsed_src.hostname or ""
|
||||
src_loopback = src_host == "localhost"
|
||||
if not src_loopback:
|
||||
|
||||
@@ -146,6 +146,102 @@ def _build_namespace(context: Any) -> dict[str, Any]:
|
||||
return ns
|
||||
|
||||
|
||||
def _is_single_expression(stripped: str) -> bool:
|
||||
"""True when *stripped* is exactly one top-level ``{{ ... }}`` block.
|
||||
|
||||
Scans the block body for a ``}}`` that would close it early, ignoring any
|
||||
braces inside string literals. This keeps a lone expression whose string
|
||||
argument contains a literal ``{{`` or ``}}`` (e.g.
|
||||
``{{ inputs.text | contains('}}') }}``) on the typed fast path, while
|
||||
``{{ a }} {{ b }}`` and ``{{ a }}{{ b }}`` are correctly seen as
|
||||
multi-expression. Mirrors the quote handling in
|
||||
``_split_top_level_commas``.
|
||||
|
||||
A regex span check cannot decide this: the pattern's non-greedy body stops
|
||||
at the first ``}}``, so a literal ``}}`` inside a string argument would be
|
||||
mistaken for the closing delimiter (issue #3208, follow-up review).
|
||||
"""
|
||||
if not (stripped.startswith("{{") and stripped.endswith("}}")):
|
||||
return False
|
||||
inner = stripped[2:-2]
|
||||
if not inner.strip():
|
||||
return False
|
||||
quote: str | None = None
|
||||
i = 0
|
||||
n = len(inner)
|
||||
while i < n:
|
||||
ch = inner[i]
|
||||
if quote is not None:
|
||||
if ch == quote:
|
||||
quote = None
|
||||
elif ch in ("'", '"'):
|
||||
quote = ch
|
||||
elif ch == "}" and i + 1 < n and inner[i + 1] == "}":
|
||||
# A ``}}`` outside quotes closes the first block early.
|
||||
return False
|
||||
i += 1
|
||||
return True
|
||||
|
||||
|
||||
def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
|
||||
"""Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware.
|
||||
|
||||
Walks the template and, for each block, finds the closing ``}}`` that lies
|
||||
outside string literals -- the same quote-scanning used by
|
||||
``_is_single_expression``. This keeps a literal ``}}`` inside a string
|
||||
argument (e.g. ``| default('}}')``) from prematurely closing a block.
|
||||
|
||||
``_EXPR_PATTERN.sub`` cannot do this: its non-greedy body stops at the first
|
||||
``}}`` regardless of quoting, so in a multi-expression template any block
|
||||
whose argument contains a literal ``}}`` is captured truncated and mis-parsed
|
||||
(raising ``ValueError`` from the filter parser). #3208/#3228 fixed exactly
|
||||
this for the single-expression fast path but left the interpolation path on
|
||||
the old regex.
|
||||
"""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
n = len(template)
|
||||
while i < n:
|
||||
start = template.find("{{", i)
|
||||
if start == -1:
|
||||
out.append(template[i:])
|
||||
break
|
||||
out.append(template[i:start])
|
||||
# Scan for the block-closing ``}}`` that is outside any string literal.
|
||||
j = start + 2
|
||||
quote: str | None = None
|
||||
close = -1
|
||||
while j < n:
|
||||
ch = template[j]
|
||||
if quote is not None:
|
||||
if ch == quote:
|
||||
quote = None
|
||||
elif ch in ("'", '"'):
|
||||
quote = ch
|
||||
elif ch == "}" and j + 1 < n and template[j + 1] == "}":
|
||||
close = j
|
||||
break
|
||||
j += 1
|
||||
if close == -1:
|
||||
# No quote-aware close. Two sub-cases, both kept identical to the old
|
||||
# regex so a malformed template is never silently hidden:
|
||||
# * a raw ``}}`` still exists in the tail (e.g. an unbalanced quote
|
||||
# in a filter arg swallowed the real delimiter) -- fall back to
|
||||
# that first raw ``}}`` and evaluate, letting the parser surface
|
||||
# a ValueError just as ``_EXPR_PATTERN.sub`` would have.
|
||||
# * no ``}}`` at all -- a genuinely unterminated ``{{``; leave the
|
||||
# tail verbatim, again matching the regex (which cannot match).
|
||||
raw_close = template.find("}}", start + 2)
|
||||
if raw_close == -1:
|
||||
out.append(template[start:])
|
||||
break
|
||||
close = raw_close
|
||||
val = _evaluate_simple_expression(template[start + 2:close].strip(), namespace)
|
||||
out.append(str(val) if val is not None else "")
|
||||
i = close + 2
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _split_top_level_commas(text: str) -> list[str]:
|
||||
"""Split *text* on commas that are not inside quotes or nested brackets.
|
||||
|
||||
@@ -371,15 +467,34 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||
return _resolve_dot_path(namespace, expr)
|
||||
|
||||
|
||||
def _coerce_number(value: Any) -> Any:
|
||||
"""Return *value* as int/float if it is a numeric string, else unchanged."""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value) if "." in value else int(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _safe_compare(left: Any, right: Any, op: str) -> bool:
|
||||
"""Safely compare two values, coercing types when possible."""
|
||||
try:
|
||||
if isinstance(left, str):
|
||||
left = float(left) if "." in left else int(left)
|
||||
if isinstance(right, str):
|
||||
right = float(right) if "." in right else int(right)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
"""Compare two values for ordering, coercing numeric strings when possible.
|
||||
|
||||
Numeric coercion is applied only when *both* operands look numeric, so a
|
||||
pair like ``"10"`` and ``"9"`` compares as numbers (10 > 9). When either
|
||||
side is a non-numeric string, both fall back to their original values and
|
||||
are compared directly -- so ordinary strings (dates, semver-ish tags,
|
||||
names) compare lexicographically the way Python does, instead of every
|
||||
such comparison silently returning ``False`` after a failed int()/float()
|
||||
coercion. A genuinely incomparable pair (e.g. number vs non-numeric string)
|
||||
raises ``TypeError`` and yields ``False``.
|
||||
"""
|
||||
cl, cr = _coerce_number(left), _coerce_number(right)
|
||||
# Only use the coerced numbers when both converted; otherwise a numeric
|
||||
# string paired with a plain string would become an int-vs-str mismatch
|
||||
# (always False) rather than a lexicographic string comparison.
|
||||
if isinstance(cl, (int, float)) and isinstance(cr, (int, float)):
|
||||
left, right = cl, cr
|
||||
try:
|
||||
if op == ">":
|
||||
return left > right # type: ignore[operator]
|
||||
@@ -419,17 +534,27 @@ def evaluate_expression(template: str, context: Any) -> Any:
|
||||
|
||||
namespace = _build_namespace(context)
|
||||
|
||||
# Single expression: return typed value
|
||||
match = _EXPR_PATTERN.fullmatch(template.strip())
|
||||
if match:
|
||||
return _evaluate_simple_expression(match.group(1).strip(), namespace)
|
||||
# Single expression: return typed value (preserving type).
|
||||
#
|
||||
# The fast path must fire only when the whole template is one ``{{ ... }}``
|
||||
# block. Neither ``fullmatch`` nor a match-span check on ``_EXPR_PATTERN``
|
||||
# can decide this reliably: the non-greedy body stops at the first ``}}``,
|
||||
# so ``fullmatch`` over-expands ``"{{ a }} {{ b }}"`` to garbage (returning
|
||||
# ``None`` and bypassing interpolation, issue #3208), while a span check
|
||||
# trips over a literal ``}}`` inside a string argument such as
|
||||
# ``{{ inputs.text | contains('}}') }}`` and mis-routes it to interpolation
|
||||
# (coercing its typed return to ``str``). ``_is_single_expression`` scans
|
||||
# for a block-closing ``}}`` outside string literals, so both cases resolve
|
||||
# correctly.
|
||||
stripped = template.strip()
|
||||
if _is_single_expression(stripped):
|
||||
return _evaluate_simple_expression(stripped[2:-2].strip(), namespace)
|
||||
|
||||
# Multi-expression: string interpolation
|
||||
def _replacer(m: re.Match[str]) -> str:
|
||||
val = _evaluate_simple_expression(m.group(1).strip(), namespace)
|
||||
return str(val) if val is not None else ""
|
||||
|
||||
return _EXPR_PATTERN.sub(_replacer, template)
|
||||
# Multi-expression: interpolate each block inline. Uses a quote-aware scan
|
||||
# (not ``_EXPR_PATTERN.sub``) so a literal ``}}`` inside a string argument
|
||||
# in any block does not close that block early -- matching the handling the
|
||||
# single-expression path above already got in #3208/#3228.
|
||||
return _interpolate_expressions(template, namespace)
|
||||
|
||||
|
||||
def evaluate_condition(condition: str, context: Any) -> bool:
|
||||
|
||||
@@ -58,4 +58,13 @@ class FanInStep(StepBase):
|
||||
f"Fan-in step {config.get('id', '?')!r}: "
|
||||
f"'wait_for' must be a non-empty list of step IDs."
|
||||
)
|
||||
output = config.get("output")
|
||||
if output is not None and not isinstance(output, dict):
|
||||
# execute() silently coerces a non-mapping output to {}, so the
|
||||
# author's declared aggregation keys would vanish with no error.
|
||||
# Reject at validation, mirroring the command-step (#3262) fix.
|
||||
errors.append(
|
||||
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
|
||||
f"mapping of key -> expression, got {type(output).__name__}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -73,7 +73,14 @@ class GateStep(StepBase):
|
||||
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||
output["choice"] = choice
|
||||
|
||||
if choice in ("reject", "abort"):
|
||||
# Match rejection case-insensitively. ``_prompt`` echoes the option's
|
||||
# original casing, and ``validate`` accepts a reject option
|
||||
# case-insensitively (``o.lower() in {"reject", "abort"}``), so a gate
|
||||
# authored as ``options: [Approve, Reject]`` passes validation. Comparing
|
||||
# ``choice`` case-sensitively here would then treat a ``Reject`` pick as
|
||||
# approval and silently skip the abort — the reject path must agree with
|
||||
# the check that let the option through.
|
||||
if choice.lower() in ("reject", "abort"):
|
||||
if on_reject == "abort":
|
||||
output["aborted"] = True
|
||||
return StepResult(
|
||||
|
||||
@@ -90,6 +90,16 @@ class ShellStep(StepBase):
|
||||
errors.append(
|
||||
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
|
||||
)
|
||||
elif not isinstance(config["run"], str):
|
||||
# execute() str()-coerces run and invokes it under shell=True, so a
|
||||
# null or list 'run' would run the Python repr ('None', "['echo']")
|
||||
# as a command. Reject non-strings at validation, mirroring the
|
||||
# command-step input/options and gate options type checks. An
|
||||
# expression like "{{ ... }}" is still a str, so it stays valid.
|
||||
errors.append(
|
||||
f"Shell step {config.get('id', '?')!r}: 'run' must be a string, "
|
||||
f"got {type(config['run']).__name__}."
|
||||
)
|
||||
output_format = config.get("output_format")
|
||||
if output_format is not None and output_format != "json":
|
||||
errors.append(
|
||||
|
||||
@@ -83,6 +83,20 @@ def _isolate_auth_config(monkeypatch):
|
||||
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _strip_specify_env(monkeypatch):
|
||||
"""Drop any inherited SPECIFY_* vars for every test.
|
||||
|
||||
The Python CLI's project resolver (`_require_specify_project`) now honors
|
||||
SPECIFY_INIT_DIR, and the shell resolvers honor SPECIFY_FEATURE* — so a
|
||||
developer or CI runner with any SPECIFY_* var exported would silently
|
||||
retarget (or hard-error) the many command/script tests that resolve a
|
||||
project. Stripping them here keeps resolution tests deterministic; a test
|
||||
that wants an override sets it explicitly via monkeypatch afterwards."""
|
||||
for key in [k for k in os.environ if k.startswith("SPECIFY_")]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_environ(monkeypatch):
|
||||
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -404,3 +405,315 @@ def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "claude" in result.output and "copilot" in result.output
|
||||
|
||||
|
||||
# ===== Private GitHub release asset URL resolution =====
|
||||
|
||||
|
||||
class FakeBundleResponse:
|
||||
"""Minimal context-manager response stub for open_url fakes."""
|
||||
|
||||
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
|
||||
self._data = data
|
||||
self._url = url
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
|
||||
def _make_catalog_config(catalog_path: Path, project: Path) -> None:
|
||||
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{
|
||||
"id": "test",
|
||||
"url": str(catalog_path),
|
||||
"priority": 1,
|
||||
"install_policy": "install-allowed",
|
||||
}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_info_resolves_github_browser_release_url(project: Path):
|
||||
"""bundle info resolves a private-repo browser release URL via the GitHub API."""
|
||||
browser_url = "https://github.com/org/repo/releases/download/v1.0/bundle.yml"
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
|
||||
captured = []
|
||||
manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode()
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
# GitHub API release-tags lookup — return asset list
|
||||
return FakeBundleResponse(
|
||||
json.dumps({
|
||||
"assets": [{"name": "bundle.yml", "url": api_asset_url}]
|
||||
}).encode(),
|
||||
url=url,
|
||||
)
|
||||
# Actual asset download
|
||||
return FakeBundleResponse(manifest_yaml, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# The browser release URL must have been resolved via the GitHub tags API
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1, f"Expected exactly one tags API call; got {captured}"
|
||||
assert "releases/tags/v1.0" in tag_calls[0]
|
||||
|
||||
# The actual download must use the resolved API asset URL with octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][0] == api_asset_url
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
def test_bundle_info_passes_through_api_asset_url(project: Path):
|
||||
"""bundle info passes a direct GitHub API asset URL through with octet-stream."""
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77"
|
||||
|
||||
captured = []
|
||||
manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode()
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
return FakeBundleResponse(manifest_yaml, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# No tags API call — URL was already a REST asset URL
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 0
|
||||
|
||||
# Exactly one download call to the asset URL with octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][0] == api_asset_url
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
def test_bundle_info_resolves_github_browser_release_url_zip(project: Path):
|
||||
"""bundle info resolves a browser release URL for a .zip artifact and extracts bundle.yml."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
browser_url = "https://github.com/org/repo/releases/download/v2.0/bundle.zip"
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/88"
|
||||
|
||||
# Build a minimal in-memory ZIP containing bundle.yml
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
|
||||
zip_bytes = buf.getvalue()
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeBundleResponse(
|
||||
json.dumps({
|
||||
"assets": [{"name": "bundle.zip", "url": api_asset_url}]
|
||||
}).encode(),
|
||||
url=url,
|
||||
)
|
||||
return FakeBundleResponse(zip_bytes, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# tags API lookup must have fired
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
assert "releases/tags/v2.0" in tag_calls[0]
|
||||
|
||||
# Asset download uses the resolved API URL with octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][0] == api_asset_url
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
# Manifest was successfully parsed from the ZIP
|
||||
payload = json.loads(result.output)
|
||||
assert payload["id"] == "demo-bundle"
|
||||
|
||||
|
||||
def test_bundle_info_api_asset_url_zip_detected_by_magic_bytes(project: Path):
|
||||
"""bundle info correctly handles a direct API asset URL that serves ZIP bytes."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/55"
|
||||
|
||||
# Build a minimal in-memory ZIP containing bundle.yml
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
|
||||
zip_bytes = buf.getvalue()
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
return FakeBundleResponse(zip_bytes, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# No tags API call — URL was already a REST asset URL
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 0
|
||||
|
||||
# Download used octet-stream header
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
# ZIP bytes were detected by magic and bundle.yml extracted correctly
|
||||
payload = json.loads(result.output)
|
||||
assert payload["id"] == "demo-bundle"
|
||||
|
||||
|
||||
def test_bundle_info_github_release_url_resolution_failure_falls_back_and_errors(project: Path):
|
||||
"""When the GitHub tags API lookup finds no matching asset, fall back to the
|
||||
original browser URL and surface a meaningful error (not a raw traceback)."""
|
||||
browser_url = "https://github.com/org/repo/releases/download/v3.0/bundle.yml"
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
# Tags API responds but the asset list doesn't include our file
|
||||
return FakeBundleResponse(
|
||||
json.dumps({"assets": []}).encode(),
|
||||
url=url,
|
||||
)
|
||||
# Fallback download: GitHub serves HTML (SSO redirect) instead of YAML
|
||||
return FakeBundleResponse(b"<html>SSO login required</html>", url=url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
# Must exit non-zero — the HTML body is not a valid bundle manifest
|
||||
assert result.exit_code == 1
|
||||
|
||||
# The tags API lookup must have fired
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
|
||||
# The fallback download should use the original browser URL (no octet-stream)
|
||||
fallback_calls = [(url, h) for url, h in captured if url == browser_url]
|
||||
assert len(fallback_calls) == 1
|
||||
assert fallback_calls[0][1] is None # no Accept header on the original URL
|
||||
|
||||
# Error output must be actionable (not a raw traceback)
|
||||
assert "Error:" in result.output
|
||||
|
||||
|
||||
def test_bundle_info_resolves_ghes_browser_release_url(project: Path):
|
||||
"""bundle info resolves a GHES private-repo browser release URL via /api/v3."""
|
||||
ghes_host = "ghes.example"
|
||||
browser_url = f"https://{ghes_host}/org/repo/releases/download/v1.0/bundle.yml"
|
||||
api_asset_url = f"https://{ghes_host}/api/v3/repos/org/repo/releases/assets/42"
|
||||
|
||||
captured = []
|
||||
manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode()
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
if "/api/v3/repos/" in url and "releases/tags/" in url:
|
||||
return FakeBundleResponse(
|
||||
json.dumps({
|
||||
"assets": [{"name": "bundle.yml", "url": api_asset_url}]
|
||||
}).encode(),
|
||||
url=url,
|
||||
)
|
||||
return FakeBundleResponse(manifest_yaml, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
|
||||
patch("specify_cli.authentication.http.github_provider_hosts", return_value=(ghes_host,)):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# The GHES /api/v3 tags lookup must have fired
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
assert f"{ghes_host}/api/v3/repos/org/repo/releases/tags/v1.0" in tag_calls[0]
|
||||
|
||||
# Asset download must use the resolved GHES API URL with octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][0] == api_asset_url
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
payload = json.loads(result.output)
|
||||
assert payload["id"] == "demo-bundle"
|
||||
|
||||
@@ -122,10 +122,10 @@ def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None =
|
||||
)
|
||||
|
||||
|
||||
def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
def _run_pwsh(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess:
|
||||
"""Run an extension PowerShell script."""
|
||||
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
|
||||
env = {**os.environ, **_GIT_ENV}
|
||||
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
|
||||
return subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), *args],
|
||||
cwd=cwd,
|
||||
@@ -320,6 +320,15 @@ class TestCreateFeatureBash:
|
||||
assert rt.returncode == 0, rt.stderr
|
||||
assert "HAS_GIT" not in rt.stdout
|
||||
|
||||
def test_help_documents_branch_prefix(self, tmp_path: Path):
|
||||
"""--help documents both template config knobs."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash("create-new-feature-branch.sh", project, "--help")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "branch_template" in result.stdout
|
||||
assert "branch_prefix" in result.stdout
|
||||
|
||||
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
|
||||
"""A short word is dropped from the derived branch name unless it appears
|
||||
as an acronym in UPPERCASE in the description (case-sensitive, must match the
|
||||
@@ -363,6 +372,183 @@ class TestCreateFeatureBash:
|
||||
data = json.loads(result.stdout)
|
||||
assert data["FEATURE_NUM"] == "003"
|
||||
|
||||
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
|
||||
"""branch_template namespaces generated branch names for monorepos."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
|
||||
"""branch_prefix expands to a namespace before the default branch shape."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_prefix: "features/{app}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
|
||||
"""Numeric-looking namespace segments must not be parsed as feature numbers."""
|
||||
project = _setup_project(tmp_path / "2026-app")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
|
||||
"""Malformed timestamp-looking branches must not inflate sequential numbering."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
|
||||
"""Templated branch numbering ignores branches outside the current namespace."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_requires_number_token(self, tmp_path: Path):
|
||||
"""Configured templates must include {number} so generated branches validate."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{number}" in result.stderr
|
||||
|
||||
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
|
||||
"""Templates must render a final path segment that validation accepts."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{number}-" in result.stderr
|
||||
|
||||
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
|
||||
"""{slug} before {number} would make branch-number scanning slug-specific."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{slug}" in result.stderr
|
||||
assert "{number}" in result.stderr
|
||||
|
||||
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
|
||||
"""GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
|
||||
"""GIT_BRANCH_NAME uses the feature segment, not numeric namespace segments."""
|
||||
project = _setup_project(tmp_path / "2026-app")
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
|
||||
"""GIT_BRANCH_NAME without a feature marker keeps the historical FEATURE_NUM."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
result = _run_bash(
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
|
||||
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
|
||||
|
||||
def test_truncation_warning_reports_utf8_bytes(self):
|
||||
"""Bash truncation warnings should use the same byte counter as enforcement."""
|
||||
source = (EXT_BASH / "create-new-feature-branch.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert '_byte_length "$ORIGINAL_BRANCH_NAME"' in source
|
||||
assert '_byte_length "$BRANCH_NAME"' in source
|
||||
|
||||
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
|
||||
"""Branches checked out in sibling worktrees still reserve their prefix."""
|
||||
project = _setup_project(tmp_path / "project")
|
||||
@@ -484,6 +670,15 @@ class TestCreateFeaturePowerShell:
|
||||
assert rt.returncode == 0, rt.stderr
|
||||
assert "HAS_GIT" not in rt.stdout
|
||||
|
||||
def test_help_documents_branch_prefix(self, tmp_path: Path):
|
||||
"""-Help documents both template config knobs."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh("create-new-feature-branch.ps1", project, "-Help")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "branch_template" in result.stdout
|
||||
assert "branch_prefix" in result.stdout
|
||||
|
||||
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
|
||||
"""PowerShell must match the bash twin: a short word is dropped unless it
|
||||
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""
|
||||
@@ -525,6 +720,176 @@ class TestCreateFeaturePowerShell:
|
||||
data = json.loads(result.stdout)
|
||||
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
|
||||
|
||||
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
|
||||
"""PowerShell supports branch_template namespaces."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
|
||||
"""PowerShell supports branch_prefix shorthand namespaces."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_prefix: "features/{app}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
|
||||
"""PowerShell ignores numeric-looking namespace segments when numbering."""
|
||||
project = _setup_project(tmp_path / "2026-app")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
|
||||
"""PowerShell skips malformed timestamp-looking refs during sequential numbering."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
|
||||
"""PowerShell templated numbering ignores branches outside the namespace."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
|
||||
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
|
||||
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
|
||||
assert data["FEATURE_NUM"] == "008"
|
||||
|
||||
def test_branch_template_requires_number_token(self, tmp_path: Path):
|
||||
"""PowerShell rejects templates without {number}."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{number}" in result.stderr
|
||||
|
||||
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
|
||||
"""PowerShell rejects templates whose final segment cannot validate."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{number}-" in result.stderr
|
||||
|
||||
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
|
||||
"""PowerShell rejects templates where {slug} scopes number scanning."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
|
||||
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "branch_template" in result.stderr
|
||||
assert "{slug}" in result.stderr
|
||||
assert "{number}" in result.stderr
|
||||
|
||||
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
|
||||
"""PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
|
||||
"""PowerShell GIT_BRANCH_NAME ignores numeric namespace segments."""
|
||||
project = _setup_project(tmp_path / "2026-app")
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
|
||||
assert data["FEATURE_NUM"] == "042"
|
||||
|
||||
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
|
||||
"""PowerShell keeps the full override name when no feature marker exists."""
|
||||
project = _setup_project(tmp_path / "app-a")
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "Ignored description",
|
||||
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
|
||||
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
"""create-new-feature-branch.ps1 works without git."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
@@ -1011,13 +1376,31 @@ class TestGitCommonBash:
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path):
|
||||
def test_check_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_feature_branch_rejects_nested_prefix_without_number(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/no-number" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_check_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && check_feature_branch "jdoe/2026-app/no-number" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
@@ -1037,3 +1420,33 @@ class TestGitCommonPowerShell:
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_test_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/app-a/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_test_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/2026-app/no-number" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
@@ -71,6 +71,47 @@ def test_search_dedupes_by_precedence_and_filters():
|
||||
assert [r.entry.id for r in qa_only] == ["beta"]
|
||||
|
||||
|
||||
def test_search_does_not_surface_a_shadowed_lower_precedence_entry():
|
||||
"""Search must resolve each id at its highest-precedence source, then
|
||||
filter — never fall through to a shadowed lower-precedence entry the query
|
||||
happens to match.
|
||||
|
||||
If the query matched only the lower-precedence copy of an id, search used
|
||||
to return that copy, even though `resolve()`/install always use the
|
||||
higher-precedence one. That advertised a bundle (name/version/source) the
|
||||
user could never actually get.
|
||||
"""
|
||||
sources = [_source("high", 1, "install-allowed"), _source("low", 2, "install-allowed")]
|
||||
payloads = {
|
||||
# Highest-precedence entry for 'shared' does NOT match "widget".
|
||||
"high": catalog_payload({
|
||||
"shared": catalog_entry_dict(
|
||||
"shared", name="Alpha Tool", role="developer",
|
||||
description="nothing relevant", version="2.0.0",
|
||||
),
|
||||
}),
|
||||
# Lower-precedence entry for the same id DOES match "widget".
|
||||
"low": catalog_payload({
|
||||
"shared": catalog_entry_dict(
|
||||
"shared", name="Searchable Widget", version="1.0.0",
|
||||
),
|
||||
}),
|
||||
}
|
||||
stack = _stack(sources, payloads)
|
||||
|
||||
# resolve() uses the high-precedence entry.
|
||||
assert stack.resolve("shared").source.id == "high"
|
||||
|
||||
# A query that only the shadowed low-precedence entry matches returns
|
||||
# nothing — search agrees with resolve().
|
||||
assert stack.search("widget") == []
|
||||
|
||||
# And a query the high-precedence entry matches returns it (from 'high').
|
||||
alpha = stack.search("alpha tool")
|
||||
assert [r.entry.id for r in alpha] == ["shared"]
|
||||
assert alpha[0].source.id == "high"
|
||||
|
||||
|
||||
def test_unreachable_source_raises_named_error():
|
||||
def fetcher(src):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
@@ -220,3 +220,68 @@ def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
|
||||
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
assert ("extensions", "ext-a") in installer.installed
|
||||
|
||||
|
||||
def _bundle(manifest_id, ext_ids, *, version="1.0.0"):
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = manifest_id
|
||||
data["bundle"]["version"] = version
|
||||
data["provides"] = {
|
||||
"extensions": [{"id": e, "version": version} for e in ext_ids]
|
||||
}
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path):
|
||||
"""`bundle update` must uninstall components the new version no longer
|
||||
ships, instead of orphaning them (installed on disk, tracked by nothing)."""
|
||||
make_project(tmp_path)
|
||||
installer = FakeInstaller()
|
||||
|
||||
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
|
||||
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
|
||||
assert ("extensions", "ext-b") in installer.installed
|
||||
|
||||
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
|
||||
)
|
||||
|
||||
# ext-b was dropped by v2 -> uninstalled and reported.
|
||||
assert ("extensions", "ext-b") in installer.remove_calls
|
||||
assert ("extensions", "ext-b") in {(c.kind, c.id) for c in result.uninstalled}
|
||||
assert ("extensions", "ext-b") not in installer.installed
|
||||
assert ("extensions", "ext-a") in installer.installed
|
||||
|
||||
# The saved record lists only ext-a.
|
||||
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
|
||||
keys = {(c.kind, c.id) for c in rec.contributed_components}
|
||||
assert ("extensions", "ext-a") in keys
|
||||
assert ("extensions", "ext-b") not in keys
|
||||
|
||||
|
||||
def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
|
||||
"""A dropped component still owned by another bundle stays installed."""
|
||||
make_project(tmp_path)
|
||||
installer = FakeInstaller()
|
||||
|
||||
man_sib = _bundle("sibling", ["ext-b"])
|
||||
install_bundle(tmp_path, _plan(man_sib), installer, manifest=man_sib)
|
||||
|
||||
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
|
||||
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
|
||||
|
||||
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
|
||||
install_bundle(
|
||||
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
|
||||
)
|
||||
|
||||
# ext-b is still needed by 'sibling' -> not removed, stays installed.
|
||||
assert ("extensions", "ext-b") not in installer.remove_calls
|
||||
assert ("extensions", "ext-b") in installer.installed
|
||||
|
||||
# But demo's record no longer attributes it.
|
||||
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
|
||||
assert ("extensions", "ext-b") not in {
|
||||
(c.kind, c.id) for c in rec.contributed_components
|
||||
}
|
||||
|
||||
@@ -171,3 +171,22 @@ def test_find_project_root_ignores_symlinked_specify(tmp_path: Path):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
# A symlinked .specify must not be accepted as a project root.
|
||||
assert find_project_root(project) is None
|
||||
|
||||
|
||||
def test_find_project_root_override_errors_on_symlinked_specify(tmp_path: Path, monkeypatch):
|
||||
"""The SPECIFY_INIT_DIR override path refuses a symlinked .specify too,
|
||||
matching the cwd loop path (regression: the override returned early and
|
||||
skipped the symlink guard)."""
|
||||
from specify_cli.bundler.lib.project import find_project_root
|
||||
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
try:
|
||||
(project / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(project))
|
||||
with pytest.raises(BundlerError, match="symlinked \\.specify"):
|
||||
find_project_root(None)
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
"""Shared test helpers for integration tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import MarkdownIntegration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
"""Keep integration tests from reading or writing the real user home."""
|
||||
home = tmp_path / "home"
|
||||
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache"))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
|
||||
|
||||
|
||||
class StubIntegration(MarkdownIntegration):
|
||||
"""Minimal concrete integration for testing."""
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import (
|
||||
@@ -299,3 +301,186 @@ class TestResolveCommandRefs:
|
||||
text = "__SPECKIT_COMMAND_V2_PLAN__"
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "/speckit.v2.plan"
|
||||
|
||||
|
||||
class TestResolvePythonInterpreter:
|
||||
def test_returns_python_on_path(self, monkeypatch):
|
||||
# Positive: when python3 is on PATH it is preferred over python.
|
||||
def fake_which(name):
|
||||
return f"/usr/bin/{name}" if name in ("python3", "python") else None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", fake_which
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python3"
|
||||
|
||||
def test_falls_back_to_python_when_no_python3(self, monkeypatch):
|
||||
def fake_which(name):
|
||||
return "/usr/bin/python" if name == "python" else None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", fake_which
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python"
|
||||
|
||||
def test_falls_back_to_sys_executable_when_nothing_found(self, monkeypatch):
|
||||
# Negative: nothing on PATH and no venv -> the running interpreter
|
||||
# (sys.executable) is used so the command works in this environment.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable", "/opt/py/bin/python"
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "/opt/py/bin/python"
|
||||
|
||||
def test_falls_back_to_python3_when_no_interpreter_at_all(self, monkeypatch):
|
||||
# Negative edge: neither PATH nor sys.executable resolves.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable", ""
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python3"
|
||||
|
||||
def test_prefers_project_venv_posix(self, monkeypatch, tmp_path):
|
||||
venv_python = tmp_path / ".venv" / "bin" / "python"
|
||||
venv_python.parent.mkdir(parents=True)
|
||||
venv_python.write_text("")
|
||||
# Even if python3 is on PATH, the project venv wins. The returned
|
||||
# path is relative to the project root for portability.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3",
|
||||
)
|
||||
result = IntegrationBase.resolve_python_interpreter(tmp_path)
|
||||
assert result == ".venv/bin/python"
|
||||
|
||||
def test_prefers_project_venv_windows(self, monkeypatch, tmp_path):
|
||||
venv_python = tmp_path / ".venv" / "Scripts" / "python.exe"
|
||||
venv_python.parent.mkdir(parents=True)
|
||||
venv_python.write_text("")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
result = IntegrationBase.resolve_python_interpreter(tmp_path)
|
||||
assert result == ".venv/Scripts/python.exe"
|
||||
|
||||
def test_ignores_missing_venv(self, monkeypatch, tmp_path):
|
||||
# Negative: no venv directory -> PATH resolution is used instead.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3"
|
||||
|
||||
|
||||
class TestProcessTemplatePyScriptType:
|
||||
CONTENT = (
|
||||
"---\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/check-prerequisites.sh --json\n"
|
||||
" ps: scripts/powershell/check-prerequisites.ps1 -Json\n"
|
||||
" py: scripts/python/check-prerequisites.py --json\n"
|
||||
"---\n"
|
||||
"Run {SCRIPT} now."
|
||||
)
|
||||
|
||||
def test_py_prefixes_interpreter(self, monkeypatch):
|
||||
# Positive: py script type prefixes a resolved interpreter and the
|
||||
# script path is rewritten to the .specify location.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
|
||||
assert "python3 .specify/scripts/python/check-prerequisites.py --json" in result
|
||||
# The scripts: frontmatter block is stripped.
|
||||
assert "scripts:" not in result
|
||||
|
||||
def test_sh_does_not_prefix_interpreter(self):
|
||||
# Negative: non-py script types are never prefixed with an interpreter.
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "sh")
|
||||
assert ".specify/scripts/bash/check-prerequisites.sh --json" in result
|
||||
assert "python" not in result
|
||||
|
||||
def test_py_quotes_interpreter_with_spaces(self, monkeypatch):
|
||||
# An interpreter path containing whitespace (e.g. Windows
|
||||
# ``Program Files``) must be quoted so it isn't split into args.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
r"C:\Program Files\Python\python.exe",
|
||||
)
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
|
||||
assert (
|
||||
'"C:\\Program Files\\Python\\python.exe" '
|
||||
".specify/scripts/python/check-prerequisites.py --json"
|
||||
) in result
|
||||
|
||||
def test_py_does_not_quote_interpreter_without_spaces(self, monkeypatch):
|
||||
# Negative: a whitespace-free interpreter is left unquoted.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
|
||||
assert '"' not in result.split("check-prerequisites.py")[0]
|
||||
|
||||
def test_py_uses_project_venv(self, monkeypatch, tmp_path):
|
||||
venv_python = tmp_path / ".venv" / "bin" / "python"
|
||||
venv_python.parent.mkdir(parents=True)
|
||||
venv_python.write_text("")
|
||||
result = IntegrationBase.process_template(
|
||||
self.CONTENT, "agent", "py", project_root=tmp_path
|
||||
)
|
||||
assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result
|
||||
|
||||
|
||||
class TestInstallScriptsPython:
|
||||
def _make_integration_with_scripts(self, monkeypatch, tmp_path):
|
||||
scripts_src = tmp_path / "bundled_scripts"
|
||||
scripts_src.mkdir()
|
||||
(scripts_src / "common.py").write_text("print('hi')\n")
|
||||
(scripts_src / "common.sh").write_text("echo hi\n")
|
||||
(scripts_src / "notes.txt").write_text("not executable\n")
|
||||
integration = StubIntegration()
|
||||
monkeypatch.setattr(
|
||||
integration, "integration_scripts_dir", lambda: scripts_src
|
||||
)
|
||||
return integration
|
||||
|
||||
def test_copies_all_script_files(self, monkeypatch, tmp_path):
|
||||
# Cross-platform: every bundled file is copied into the project.
|
||||
integration = self._make_integration_with_scripts(monkeypatch, tmp_path)
|
||||
project_root = tmp_path / "proj"
|
||||
project_root.mkdir()
|
||||
manifest = IntegrationManifest("stub", project_root.resolve())
|
||||
|
||||
created = integration.install_scripts(project_root, manifest)
|
||||
names = {p.name for p in created}
|
||||
assert {"common.py", "common.sh", "notes.txt"} == names
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="chmod exec bit not reliable on Windows"
|
||||
)
|
||||
def test_marks_py_and_sh_executable(self, monkeypatch, tmp_path):
|
||||
integration = self._make_integration_with_scripts(monkeypatch, tmp_path)
|
||||
project_root = tmp_path / "proj"
|
||||
project_root.mkdir()
|
||||
manifest = IntegrationManifest("stub", project_root.resolve())
|
||||
|
||||
integration.install_scripts(project_root, manifest)
|
||||
|
||||
dest = project_root / ".specify" / "integrations" / "stub" / "scripts"
|
||||
py_file = dest / "common.py"
|
||||
sh_file = dest / "common.sh"
|
||||
txt_file = dest / "notes.txt"
|
||||
# Positive: .py and .sh are executable.
|
||||
assert py_file.stat().st_mode & 0o111
|
||||
assert sh_file.stat().st_mode & 0o111
|
||||
# Negative: a non-script file is not made executable.
|
||||
assert not (txt_file.stat().st_mode & 0o111)
|
||||
|
||||
@@ -1386,14 +1386,14 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
project.mkdir()
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
assert result.exit_code == 1
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_catalog_list_requires_specify_project(self, tmp_path):
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert result.exit_code == 1
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_primary_integration_commands_require_specify_project(self, tmp_path):
|
||||
project = tmp_path / "bare"
|
||||
@@ -1413,7 +1413,7 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
|
||||
)
|
||||
assert result.exit_code == 1, failure_context
|
||||
assert "Not a spec-kit project" in result.output, failure_context
|
||||
assert "Not a Spec Kit project" in result.output, failure_context
|
||||
|
||||
def test_integration_commands_require_specify_directory(self, tmp_path):
|
||||
project = tmp_path / "bad"
|
||||
@@ -1428,7 +1428,7 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
for command in commands:
|
||||
result = self._invoke(command, project)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_project_scoped_commands_require_specify_directory(self, tmp_path):
|
||||
project = tmp_path / "bad-feature-commands"
|
||||
@@ -1479,7 +1479,7 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
|
||||
)
|
||||
assert result.exit_code == 1, failure_context
|
||||
assert "Not a spec-kit project" in result.output, failure_context
|
||||
assert "Not a Spec Kit project" in result.output, failure_context
|
||||
|
||||
def test_catalog_config_output_uses_posix_paths(self, tmp_path):
|
||||
project = self._make_project(tmp_path)
|
||||
|
||||
21
tests/integrations/test_home_isolation.py
Normal file
21
tests/integrations/test_home_isolation.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Regression tests for integration-test environment isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
|
||||
home = tmp_path / "home"
|
||||
|
||||
assert Path(os.environ["HOME"]) == home
|
||||
assert Path(os.environ["USERPROFILE"]) == home
|
||||
assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache"
|
||||
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
|
||||
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
|
||||
|
||||
assert home.is_dir()
|
||||
assert (home / ".cache").is_dir()
|
||||
assert (home / ".config").is_dir()
|
||||
assert (home / ".local" / "share").is_dir()
|
||||
@@ -81,6 +81,26 @@ class TestAgyBuildExecArgs:
|
||||
result = i.build_exec_args("my prompt", output_json=False)
|
||||
assert result == ["agy", "--print", "my prompt"]
|
||||
|
||||
def test_build_exec_args_honors_extra_args(self, monkeypatch):
|
||||
"""SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be appended after the prompt.
|
||||
|
||||
agy previously skipped _apply_extra_args_env_var entirely, so the
|
||||
documented per-integration extra-args hook was silently ignored
|
||||
(same class as the merged cursor-agent fix #3265).
|
||||
"""
|
||||
from specify_cli.integrations import get_integration
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose")
|
||||
i = get_integration("agy")
|
||||
assert i.build_exec_args("my prompt") == [
|
||||
"agy", "--print", "my prompt", "--verbose",
|
||||
]
|
||||
|
||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||
from specify_cli.integrations import get_integration
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy")
|
||||
i = get_integration("agy")
|
||||
assert i.build_exec_args("my prompt")[0] == "/custom/agy"
|
||||
|
||||
|
||||
class TestAgyHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
|
||||
|
||||
@@ -184,6 +184,23 @@ class YamlIntegrationTests:
|
||||
assert "scripts:" not in parsed["prompt"]
|
||||
assert "---" not in parsed["prompt"]
|
||||
|
||||
def test_yaml_prompt_with_indented_first_line_stays_valid(self):
|
||||
"""A body whose first line is indented must still parse.
|
||||
|
||||
A bare ``|`` block scalar infers its indentation from the first
|
||||
non-empty line, so a body starting with an indented line (e.g. a
|
||||
markdown code block or nested list item) made the parser expect that
|
||||
deeper indent for the whole block and reject the later, shallower
|
||||
lines. The explicit ``|2`` indicator pins the indent so it parses."""
|
||||
body = " indented first line\nback to normal\n indented again"
|
||||
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
|
||||
|
||||
yaml_lines = [
|
||||
ln for ln in rendered.split("\n") if not ln.startswith("# Source:")
|
||||
]
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
assert parsed["prompt"].rstrip("\n") == body
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
|
||||
@@ -590,7 +590,7 @@ class TestIntegrationUpgrade:
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_upgrade_no_integration_installed(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -125,6 +125,55 @@ class TestCursorAgentCliDispatch:
|
||||
assert argv is not None
|
||||
assert argv[0] == "cursor-agent"
|
||||
|
||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE`` overrides argv[0].
|
||||
|
||||
Every other CLI-dispatch integration (codex, devin, ...) routes
|
||||
argv[0] through ``_resolve_executable()`` so operators can pin a
|
||||
binary path (issue #2596). cursor-agent hardcoded ``self.key`` and
|
||||
silently ignored the documented override.
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE", "/custom/cursor"
|
||||
)
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args("/speckit-plan", output_json=False)
|
||||
assert args[0] == "/custom/cursor"
|
||||
# The mandatory headless flags must still be present.
|
||||
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
|
||||
assert flag in args
|
||||
|
||||
def test_build_exec_args_honors_extra_args_override(self, monkeypatch):
|
||||
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS`` flags are injected
|
||||
*before* Spec Kit's canonical ``--model`` / ``--output-format`` flags.
|
||||
|
||||
The ``_apply_extra_args_env_var()`` hook (issue #2595) was never
|
||||
invoked by cursor-agent, so operator-supplied flags were dropped.
|
||||
Insertion order is the real contract: extra args must land after the
|
||||
mandatory headless flags but before ``--model`` / ``--output-format``,
|
||||
so they cannot clobber, displace, or reorder Spec Kit's canonical
|
||||
trailing flags. Exercise with both a model and JSON output so both
|
||||
canonical flags are present to pin against.
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS", "--foo bar"
|
||||
)
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args(
|
||||
"/speckit-plan", model="sonnet-4-thinking", output_json=True
|
||||
)
|
||||
assert "--foo" in args
|
||||
assert "bar" in args
|
||||
# "bar" is the value of "--foo": the tokens stay adjacent and in order.
|
||||
assert args.index("bar") == args.index("--foo") + 1
|
||||
# Extra args are inserted before the canonical flags, so they cannot
|
||||
# clobber or reorder them (the behavioral contract this test guards).
|
||||
assert args.index("--foo") < args.index("--model")
|
||||
assert args.index("--foo") < args.index("--output-format")
|
||||
# The canonical flags themselves remain intact and correctly paired.
|
||||
assert args[args.index("--model") + 1] == "sonnet-4-thinking"
|
||||
assert args[args.index("--output-format") + 1] == "json"
|
||||
|
||||
def test_build_command_invocation_uses_hyphenated_skill_name(self):
|
||||
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
|
||||
i = get_integration("cursor-agent")
|
||||
|
||||
@@ -353,3 +353,38 @@ class TestHermesInitFlow:
|
||||
if "agent-context" not in d.name
|
||||
]
|
||||
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
|
||||
|
||||
|
||||
class TestHermesBuildExecArgs:
|
||||
"""CLI dispatch argv, including the operator extra-args env hook."""
|
||||
|
||||
def test_build_exec_args_default_shape(self):
|
||||
i = get_integration("hermes")
|
||||
assert i.build_exec_args("/speckit-plan hi", output_json=True) == [
|
||||
"hermes", "chat", "-Q", "--json", "-s", "speckit-plan", "-q", "hi",
|
||||
]
|
||||
|
||||
def test_build_exec_args_honors_extra_args(self, monkeypatch):
|
||||
"""SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS is injected before the
|
||||
canonical -m/--json/-s/-q flags (same env hook as codex/opencode/
|
||||
devin; hermes previously skipped _apply_extra_args_env_var entirely).
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS", "--temperature 0.2"
|
||||
)
|
||||
i = get_integration("hermes")
|
||||
args = i.build_exec_args("/speckit-plan hi", output_json=True)
|
||||
assert args == [
|
||||
"hermes", "chat", "-Q", "--temperature", "0.2",
|
||||
"--json", "-s", "speckit-plan", "-q", "hi",
|
||||
]
|
||||
# Injected before the canonical flags so it can't displace them.
|
||||
assert args.index("--temperature") < args.index("--json")
|
||||
assert args.index("--temperature") < args.index("-s")
|
||||
|
||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_HERMES_EXECUTABLE", "/custom/hermes"
|
||||
)
|
||||
i = get_integration("hermes")
|
||||
assert i.build_exec_args("/speckit-plan hi")[0] == "/custom/hermes"
|
||||
|
||||
@@ -97,7 +97,7 @@ class TestIntegrationList:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_list_shows_installed(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -167,7 +167,7 @@ class TestIntegrationStatus:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "status"])
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_status_reports_healthy_project(self, copilot_project):
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
@@ -988,7 +988,7 @@ class TestIntegrationInstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_install_unknown_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path)
|
||||
@@ -1384,7 +1384,7 @@ class TestIntegrationUninstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_uninstall_no_integration(self, tmp_path):
|
||||
project = tmp_path / "proj"
|
||||
@@ -1687,7 +1687,7 @@ class TestIntegrationSwitch:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_switch_unknown_target(self, tmp_path):
|
||||
project = _init_project(tmp_path)
|
||||
|
||||
@@ -481,3 +481,40 @@ class TestRecordExistingNewGuards:
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
|
||||
m.record_existing("dir/../file.txt")
|
||||
|
||||
|
||||
class TestManifestUnreadableFile:
|
||||
"""A managed file that is unreadable (e.g. PermissionError) must not crash
|
||||
check_modified()/uninstall() — the CLI handlers surfaced a raw traceback."""
|
||||
|
||||
def _mk(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("sub/f.md", "content")
|
||||
return m
|
||||
|
||||
def test_check_modified_treats_unreadable_as_modified(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
# Before the fix this raised PermissionError.
|
||||
assert m.check_modified() == ["sub/f.md"]
|
||||
|
||||
def test_uninstall_preserves_unreadable_file(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
removed, skipped = m.uninstall(force=False)
|
||||
# Can't verify ownership => preserve, don't crash and don't delete.
|
||||
assert removed == []
|
||||
assert (tmp_path / "sub" / "f.md") in skipped
|
||||
assert (tmp_path / "sub" / "f.md").exists()
|
||||
|
||||
@@ -48,6 +48,19 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]:
|
||||
]
|
||||
|
||||
|
||||
def _multi_install_safe_orders() -> list[list[str]]:
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
if len(safe_keys) < 2:
|
||||
return [safe_keys]
|
||||
return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))]
|
||||
|
||||
|
||||
def _multi_install_safe_order_id(ordered_keys: list[str]) -> str:
|
||||
if not ordered_keys:
|
||||
return "no-safe-integrations"
|
||||
return f"init-{ordered_keys[0]}"
|
||||
|
||||
|
||||
def _posix_path(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
@@ -87,16 +100,6 @@ def _paths_overlap(first: str | None, second: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _path_is_inside(path: str | None, directory: str | None) -> bool:
|
||||
if not path or not directory:
|
||||
return False
|
||||
try:
|
||||
PurePosixPath(path).relative_to(PurePosixPath(directory))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_registry_is_dict(self):
|
||||
assert isinstance(INTEGRATION_REGISTRY, dict)
|
||||
@@ -162,6 +165,15 @@ class TestRegistrarKeyAlignment:
|
||||
class TestMultiInstallSafeContracts:
|
||||
"""Declared safe integrations must stay isolated from each other."""
|
||||
|
||||
def test_safe_install_orders_rotate_each_integration_through_init(self):
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
orders = _multi_install_safe_orders()
|
||||
|
||||
assert len(safe_keys) >= 2
|
||||
assert [order[0] for order in orders] == safe_keys
|
||||
assert len({tuple(order) for order in orders}) == len(safe_keys)
|
||||
assert all(sorted(order) == safe_keys for order in orders)
|
||||
|
||||
@pytest.mark.parametrize("key", _multi_install_safe_keys())
|
||||
def test_safe_integrations_have_static_isolated_paths(self, key):
|
||||
assert _integration_root_dir(key), (
|
||||
@@ -187,62 +199,77 @@ class TestMultiInstallSafeContracts:
|
||||
f"{_integration_commands_dir(second)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
@pytest.mark.parametrize(
|
||||
"ordered_keys",
|
||||
_multi_install_safe_orders(),
|
||||
ids=_multi_install_safe_order_id,
|
||||
)
|
||||
def test_safe_integrations_have_disjoint_manifests(
|
||||
self,
|
||||
tmp_path,
|
||||
first,
|
||||
second,
|
||||
ordered_keys,
|
||||
):
|
||||
for initial, additional in ((first, second), (second, first)):
|
||||
project_root = tmp_path / f"project-{initial}-{additional}"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
# The pairwise disjointness contract is only meaningful with at least
|
||||
# two safe integrations. Guard so a shrunken registry fails loudly here
|
||||
# rather than passing vacuously (or tripping over ordered_keys[0] below).
|
||||
assert len(ordered_keys) >= 2, (
|
||||
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
|
||||
)
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
initial,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
project_root = tmp_path / "project"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
|
||||
# Install every safe integration once into a single project, then assert
|
||||
# pairwise manifest isolation. Each safe integration writes only to its
|
||||
# own (disjoint) directories and always records what it writes, so a
|
||||
# manifest's contents are independent of install order and of which other
|
||||
# integrations are co-installed. The parametrized rotations keep the
|
||||
# aggregate setup while placing each safe integration first once, so each
|
||||
# one still exercises the `specify init --integration ...` path.
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
ordered_keys[0],
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
|
||||
for key in ordered_keys[1:]:
|
||||
install_result = runner.invoke(
|
||||
app,
|
||||
["integration", "install", additional, "--script", "sh"],
|
||||
["integration", "install", key, "--script", "sh"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert install_result.exit_code == 0, install_result.output
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
initial_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
additional_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{additional}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
integrations_dir = project_root / ".specify" / "integrations"
|
||||
manifests = {}
|
||||
for key in ordered_keys:
|
||||
manifest = json.loads(
|
||||
(integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
files = manifest.get("files", {})
|
||||
assert isinstance(files, dict), f"{key} manifest files must be an object"
|
||||
manifests[key] = set(files.keys())
|
||||
|
||||
initial_files = set(initial_manifest.get("files", {}))
|
||||
additional_files = set(additional_manifest.get("files", {}))
|
||||
|
||||
assert initial_files.isdisjoint(additional_files), (
|
||||
f"{initial} and {additional} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(initial_files & additional_files)}"
|
||||
for first, second in _multi_install_safe_pairs():
|
||||
overlap = manifests[first] & manifests[second]
|
||||
assert not overlap, (
|
||||
f"{first} and {second} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(overlap)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -845,6 +845,22 @@ class TestRedirectStripping:
|
||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
||||
assert auth3 == "Bearer tok"
|
||||
|
||||
def test_malformed_redirect_url_raises_urlerror_not_valueerror(self):
|
||||
"""A redirect to a malformed URL (unterminated IPv6 bracket) surfaces
|
||||
as URLError, which download paths already handle, rather than an
|
||||
unhandled ValueError traceback."""
|
||||
import urllib.error
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo")
|
||||
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://[::1/asset")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_latest_release_tag delegation
|
||||
|
||||
@@ -121,6 +121,45 @@ def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
assert "001-my-feature" in data.get("BRANCH", "")
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
("use_env_var", "specify_feature", "expected_branch"),
|
||||
[
|
||||
(False, None, "001-my-feature"),
|
||||
(True, None, "001-my-feature"),
|
||||
(False, "my-explicit-branch", "my-explicit-branch"),
|
||||
],
|
||||
ids=["feature_json", "env_var", "explicit_feature"],
|
||||
)
|
||||
def test_current_branch_falls_back_to_feature_dir_basename(
|
||||
prereq_repo: Path, use_env_var: bool, specify_feature: str | None, expected_branch: str
|
||||
) -> None:
|
||||
"""With no SPECIFY_FEATURE, BRANCH falls back to the feature directory
|
||||
basename (from feature.json or SPECIFY_FEATURE_DIRECTORY) instead of being
|
||||
emitted empty. If SPECIFY_FEATURE is set, it remains authoritative (#3026)."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
env = _clean_env()
|
||||
if specify_feature:
|
||||
env["SPECIFY_FEATURE"] = specify_feature
|
||||
if use_env_var:
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-my-feature"
|
||||
else:
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH"] == expected_branch
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only without --json must return text paths from feature.json."""
|
||||
@@ -249,6 +288,46 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
assert "FEATURE_DIR" in data
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("use_env_var", "specify_feature", "expected_branch"),
|
||||
[
|
||||
(False, None, "001-my-feature"),
|
||||
(True, None, "001-my-feature"),
|
||||
(False, "my-explicit-branch", "my-explicit-branch"),
|
||||
],
|
||||
ids=["feature_json", "env_var", "explicit_feature"],
|
||||
)
|
||||
def test_ps_current_branch_falls_back_to_feature_dir_basename(
|
||||
prereq_repo: Path, use_env_var: bool, specify_feature: str | None, expected_branch: str
|
||||
) -> None:
|
||||
"""With no SPECIFY_FEATURE, BRANCH falls back to the feature directory
|
||||
basename (from feature.json or SPECIFY_FEATURE_DIRECTORY) instead of being
|
||||
emitted empty. If SPECIFY_FEATURE is set, it remains authoritative (#3026)."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
env = _clean_env()
|
||||
if specify_feature:
|
||||
env["SPECIFY_FEATURE"] = specify_feature
|
||||
if use_env_var:
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-my-feature"
|
||||
else:
|
||||
_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", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH"] == expected_branch
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
|
||||
|
||||
339
tests/test_check_prerequisites_python_parity.py
Normal file
339
tests/test_check_prerequisites_python_parity.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""Parity tests for the Python check-prerequisites PoC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
|
||||
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_WINDOWS_POWERSHELL = (
|
||||
shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
) if os.name == "nt" else None
|
||||
|
||||
|
||||
def _install_scripts(repo: Path) -> None:
|
||||
bash_dir = repo / ".specify" / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, bash_dir / "common.sh")
|
||||
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
|
||||
|
||||
ps_dir = repo / ".specify" / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
|
||||
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
|
||||
|
||||
py_dir = repo / ".specify" / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prereq_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
def _py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _bash_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
return ["bash", str(script), *args]
|
||||
|
||||
|
||||
def _ps_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
return [exe, "-NoProfile", "-File", str(script), *args]
|
||||
|
||||
|
||||
def _run(
|
||||
cmd: list[str], repo: Path, env: dict[str, str] | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env or _clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _normalize_status_text(text: str) -> str:
|
||||
return (
|
||||
text.replace(" ✓ ", " [OK] ")
|
||||
.replace(" ✗ ", " [FAIL] ")
|
||||
.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
|
||||
def _normalize_help_text(text: str) -> str:
|
||||
normalized = text.replace("\r\n", "\n").replace(
|
||||
"check-prerequisites.sh", "check_prerequisites.py"
|
||||
)
|
||||
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("--json",),
|
||||
("--json", "--include-tasks"),
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("--json", "--paths-only"),
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "contracts").mkdir()
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.stderr == bash.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("py_args", "ps_args"),
|
||||
[
|
||||
(("--json",), ("-Json",)),
|
||||
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
|
||||
(
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("-Json", "-RequireTasks", "-IncludeTasks"),
|
||||
),
|
||||
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
|
||||
],
|
||||
ids=[
|
||||
"json",
|
||||
"json_include_tasks",
|
||||
"json_require_tasks_include_tasks",
|
||||
"json_paths_only",
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_powershell(
|
||||
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
|
||||
) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert py.stderr == ps.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(ps)
|
||||
|
||||
|
||||
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
outside = tmp_path / "outside"
|
||||
repo.mkdir()
|
||||
outside.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_write_feature_json(repo)
|
||||
(repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
|
||||
py_dir = repo / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
|
||||
|
||||
|
||||
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
feature_json = prereq_repo / ".specify" / "feature.json"
|
||||
before = feature_json.read_text(encoding="utf-8")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
|
||||
assert feature_json.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
feat = prereq_repo / "specs" / "002-other"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
data = json.loads(
|
||||
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert data["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "expected"),
|
||||
[
|
||||
(("--json",), "Feature directory not found"),
|
||||
(("--json",), "plan.md not found"),
|
||||
(("--json", "--require-tasks"), "tasks.md not found"),
|
||||
],
|
||||
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
|
||||
)
|
||||
def test_python_negative_errors_are_stderr_only(
|
||||
tmp_path: Path, args: tuple[str, ...], expected: str
|
||||
) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
|
||||
if expected in {"plan.md not found", "tasks.md not found"}:
|
||||
feat = repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
_write_feature_json(repo)
|
||||
if expected == "tasks.md not found":
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
py = _run(_py_cmd(repo, *args), repo)
|
||||
|
||||
assert py.returncode != 0
|
||||
assert expected in py.stderr
|
||||
assert expected not in py.stdout
|
||||
assert py.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
|
||||
@@ -24,6 +24,20 @@ def test_agent_config_importable():
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
def test_script_type_choices_includes_python():
|
||||
from specify_cli._agent_config import SCRIPT_TYPE_CHOICES
|
||||
assert SCRIPT_TYPE_CHOICES.get("py") == "Python"
|
||||
# The three supported variants are sh, ps, and py.
|
||||
assert {"sh", "ps", "py"} <= set(SCRIPT_TYPE_CHOICES)
|
||||
|
||||
|
||||
def test_workflow_init_valid_script_types_includes_python():
|
||||
from specify_cli.workflows.steps.init import VALID_SCRIPT_TYPES
|
||||
assert "py" in VALID_SCRIPT_TYPES
|
||||
# Negative: an unknown variant is not accepted.
|
||||
assert "rb" not in VALID_SCRIPT_TYPES
|
||||
|
||||
|
||||
def test_agent_config_re_exported_from_init():
|
||||
from specify_cli import AGENT_CONFIG, SCRIPT_TYPE_CHOICES
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
|
||||
@@ -850,6 +850,67 @@ class TestExtensionSkillRegistration:
|
||||
assert ".specify/templates/checklist.md" in content
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
|
||||
def test_skill_registration_uses_extension_local_script_paths(self, project_dir, temp_dir):
|
||||
"""Auto-registered skills should not rewrite extension scripts into core scripts."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
ext_dir = temp_dir / "scripted-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "scripted-ext",
|
||||
"name": "Scripted Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.scripted-ext.check",
|
||||
"file": "commands/check.md",
|
||||
"description": "Scripted check command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.safe_dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "scripts" / "bash").mkdir(parents=True)
|
||||
(ext_dir / "scripts" / "bash" / "resolve-skill.sh").write_text(
|
||||
"#!/usr/bin/env bash\n"
|
||||
)
|
||||
(ext_dir / "scripts" / "bash" / "ensure-skills.sh").write_text(
|
||||
"#!/usr/bin/env bash\n"
|
||||
)
|
||||
(ext_dir / "commands" / "check.md").write_text(
|
||||
"---\n"
|
||||
"description: Scripted check command\n"
|
||||
"scripts:\n"
|
||||
' sh: scripts/bash/resolve-skill.sh "{ARGS}"\n'
|
||||
"---\n\n"
|
||||
"Run {SCRIPT}\n"
|
||||
"Then run scripts/bash/ensure-skills.sh.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
content = (skills_dir / "speckit-scripted-ext-check" / "SKILL.md").read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert (
|
||||
'.specify/extensions/scripted-ext/scripts/bash/resolve-skill.sh "$ARGUMENTS"'
|
||||
in content
|
||||
)
|
||||
assert ".specify/extensions/scripted-ext/scripts/bash/ensure-skills.sh" in content
|
||||
assert ".specify/scripts/bash/resolve-skill.sh" not in content
|
||||
assert ".specify/scripts/bash/ensure-skills.sh" not in content
|
||||
|
||||
def test_missing_command_file_skipped(self, skills_project, temp_dir):
|
||||
"""Commands with missing source files should be skipped gracefully."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
@@ -31,6 +31,7 @@ from specify_cli.extensions import (
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
CommandRegistrar,
|
||||
ConfigManager,
|
||||
HookExecutor,
|
||||
ExtensionCatalog,
|
||||
ExtensionError,
|
||||
@@ -233,6 +234,73 @@ class TestExtensionManifest:
|
||||
|
||||
assert CORE_COMMAND_NAMES == expected
|
||||
|
||||
def test_load_core_command_names_discovers_from_source_checkout(self, monkeypatch):
|
||||
"""Discovery must actually read the repo-root templates, not silently
|
||||
fall back (#3274).
|
||||
|
||||
The fallback set happens to equal the real command stems today, so an
|
||||
equality check against the live tree cannot tell a working loader apart
|
||||
from a dead one. Point ``_repo_root`` at a temp tree with *different*
|
||||
command names: the old off-by-one path math read nothing and returned
|
||||
the baked-in fallback; the fixed loader returns the temp stems.
|
||||
"""
|
||||
from specify_cli.extensions import (
|
||||
_load_core_command_names,
|
||||
_FALLBACK_CORE_COMMAND_NAMES,
|
||||
)
|
||||
import specify_cli.extensions as ext
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
commands = Path(tmp) / "templates" / "commands"
|
||||
commands.mkdir(parents=True)
|
||||
(commands / "widget.md").write_text("# widget", encoding="utf-8")
|
||||
(commands / "gadget.md").write_text("# gadget", encoding="utf-8")
|
||||
(commands / "notacommand.txt").write_text("skip me", encoding="utf-8")
|
||||
|
||||
# No wheel bundle in this scenario; force the source-checkout path.
|
||||
monkeypatch.setattr(ext, "_locate_core_pack", lambda: None)
|
||||
monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp))
|
||||
|
||||
result = _load_core_command_names()
|
||||
|
||||
assert result == {"widget", "gadget"}
|
||||
assert result != _FALLBACK_CORE_COMMAND_NAMES
|
||||
|
||||
def test_load_core_command_names_prefers_wheel_core_pack(self, monkeypatch):
|
||||
"""When a wheel ``core_pack`` bundle exists, discovery reads
|
||||
``core_pack/commands`` (the force-include target) ahead of the source
|
||||
tree (#3274)."""
|
||||
from specify_cli.extensions import _load_core_command_names
|
||||
import specify_cli.extensions as ext
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
core_pack = Path(tmp) / "core_pack"
|
||||
(core_pack / "commands").mkdir(parents=True)
|
||||
(core_pack / "commands" / "sprocket.md").write_text("# sprocket", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(ext, "_locate_core_pack", lambda: core_pack)
|
||||
# Source fallback should be ignored while the bundle resolves.
|
||||
monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp) / "nonexistent")
|
||||
|
||||
result = _load_core_command_names()
|
||||
|
||||
assert result == {"sprocket"}
|
||||
|
||||
def test_load_core_command_names_falls_back_when_nothing_found(self, monkeypatch):
|
||||
"""With neither a bundle nor a source tree, discovery returns the
|
||||
baked-in fallback so validation still works (#3274)."""
|
||||
from specify_cli.extensions import (
|
||||
_load_core_command_names,
|
||||
_FALLBACK_CORE_COMMAND_NAMES,
|
||||
)
|
||||
import specify_cli.extensions as ext
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
monkeypatch.setattr(ext, "_locate_core_pack", lambda: None)
|
||||
monkeypatch.setattr(ext, "_repo_root", lambda: Path(tmp) / "nonexistent")
|
||||
|
||||
assert _load_core_command_names() == _FALLBACK_CORE_COMMAND_NAMES
|
||||
|
||||
def test_missing_required_field(self, temp_dir):
|
||||
"""Test manifest missing required field."""
|
||||
import yaml
|
||||
@@ -1079,10 +1147,12 @@ class TestExtensionManager:
|
||||
context_note=None,
|
||||
link_outputs=False,
|
||||
create_missing_active_skills_dir=False,
|
||||
extension_id=None,
|
||||
):
|
||||
captured["create_missing_active_skills_dir"] = (
|
||||
create_missing_active_skills_dir
|
||||
)
|
||||
captured["extension_id"] = extension_id
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -1096,6 +1166,7 @@ class TestExtensionManager:
|
||||
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
|
||||
|
||||
assert captured["create_missing_active_skills_dir"] is False
|
||||
assert captured["extension_id"] == manifest.id
|
||||
|
||||
def test_install_duplicate(self, extension_dir, project_dir):
|
||||
"""Test installing already installed extension."""
|
||||
@@ -1627,6 +1698,29 @@ $ARGUMENTS
|
||||
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
|
||||
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
|
||||
|
||||
def test_adjust_script_paths_rewrites_extension_top_level_scripts(self):
|
||||
"""Extension command-local scripts should resolve under the installed extension."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
original = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/resolve-skill.sh {ARGS}",
|
||||
"ps": "../../scripts/powershell/setup-plan.ps1 -Json",
|
||||
}
|
||||
}
|
||||
|
||||
adjusted = registrar._adjust_script_paths(original, extension_id="test-ext")
|
||||
|
||||
assert (
|
||||
adjusted["scripts"]["sh"]
|
||||
== ".specify/extensions/test-ext/scripts/bash/resolve-skill.sh {ARGS}"
|
||||
)
|
||||
assert (
|
||||
adjusted["scripts"]["ps"]
|
||||
== ".specify/scripts/powershell/setup-plan.ps1 -Json"
|
||||
)
|
||||
|
||||
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
|
||||
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
@@ -1641,6 +1735,24 @@ $ARGUMENTS
|
||||
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
|
||||
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
|
||||
|
||||
def test_rewrite_project_relative_paths_uses_extension_context_for_scripts(self):
|
||||
"""Extension source bodies treat top-level scripts/ as extension-local."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
body = (
|
||||
"Run scripts/bash/ensure-skills.sh\n"
|
||||
"Fallback ../../scripts/bash/setup-plan.sh\n"
|
||||
"Read templates/checklist.md\n"
|
||||
)
|
||||
|
||||
rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(
|
||||
body, extension_id="test-ext"
|
||||
)
|
||||
|
||||
assert ".specify/extensions/test-ext/scripts/bash/ensure-skills.sh" in rewritten
|
||||
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
|
||||
assert ".specify/templates/checklist.md" in rewritten
|
||||
|
||||
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
|
||||
"""TOML renderer should stay valid when body includes triple double-quotes."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
@@ -5329,6 +5441,29 @@ class TestExtensionAddCLI:
|
||||
f"confirm must precede spinner, got: {call_order}"
|
||||
assert result.exit_code == 0 # user declined → clean exit
|
||||
|
||||
def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://[::1/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Invalid URL" in plain
|
||||
|
||||
def test_add_status_escapes_extension_markup(self, tmp_path):
|
||||
"""User-controlled extension names must not be parsed as Rich markup."""
|
||||
from rich.markup import escape as escape_markup
|
||||
@@ -7425,3 +7560,52 @@ def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, mo
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]
|
||||
|
||||
|
||||
class TestConfigManagerNonMappingYaml:
|
||||
"""A non-mapping YAML config root must not crash config/hook resolution."""
|
||||
|
||||
def _make(self, tmp_path, body: str):
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "jira-config.yml").write_text(body, encoding="utf-8")
|
||||
return ConfigManager(tmp_path, "jira")
|
||||
|
||||
def test_get_config_coerces_list_root(self, tmp_path):
|
||||
"""A YAML list root previously raised AttributeError in _merge_configs."""
|
||||
cm = self._make(tmp_path, "- foo\n- bar\n")
|
||||
assert cm.get_config() == {}
|
||||
|
||||
def test_get_config_coerces_scalar_root(self, tmp_path):
|
||||
cm = self._make(tmp_path, "just a string\n")
|
||||
assert cm.get_config() == {}
|
||||
|
||||
def test_has_value_and_get_value_do_not_raise(self, tmp_path):
|
||||
cm = self._make(tmp_path, "- foo\n")
|
||||
assert cm.has_value("anything") is False
|
||||
assert cm.get_value("anything") is None
|
||||
|
||||
def test_valid_local_config_layers_over_list_root_project_config(self, tmp_path):
|
||||
"""A malformed project config must not block a valid local config."""
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "jira-config.yml").write_text("- foo\n- bar\n", encoding="utf-8")
|
||||
(ext_dir / "local-config.yml").write_text(
|
||||
"notifications:\n enabled: true\n", encoding="utf-8"
|
||||
)
|
||||
cm = ConfigManager(tmp_path, "jira")
|
||||
assert cm.get_value("notifications.enabled") is True
|
||||
|
||||
def test_hook_condition_returns_false_without_raising(self, tmp_path):
|
||||
"""`config.x is set` on a scalar-root config must evaluate cleanly.
|
||||
|
||||
Before the fix, _merge_configs raised AttributeError and the
|
||||
exception was swallowed by should_execute_hook, silently disabling
|
||||
every config-based hook for the extension. Assert on
|
||||
_evaluate_condition directly so the crash isn't masked.
|
||||
"""
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
|
||||
executor = HookExecutor(tmp_path)
|
||||
assert executor._evaluate_condition("config.x is set", "jira") is False
|
||||
|
||||
@@ -233,6 +233,23 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_returns_none_on_malformed_ghes_port(self):
|
||||
"""A malformed port on an allowlisted GHES host returns None, not a
|
||||
ValueError (contract: resolve or return None, never raise)."""
|
||||
called = []
|
||||
|
||||
def open_never(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
raise AssertionError("open_url_fn must not be called")
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example:notaport/o/r/releases/download/v1/ext.zip",
|
||||
open_never,
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
||||
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
||||
not allowlisted: passthrough issues no API request, and the download
|
||||
|
||||
294
tests/test_init_dir_cli.py
Normal file
294
tests/test_init_dir_cli.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Tests for the SPECIFY_INIT_DIR override in the Python CLI (`specify`).
|
||||
|
||||
PR #2892 taught the shell resolver (`get_repo_root` / `Get-RepoRoot`) to honor
|
||||
SPECIFY_INIT_DIR, so the core slash-command scripts can target a member project
|
||||
from a monorepo root. This extends the same validation rules to the Python CLI's
|
||||
project resolution — `_require_specify_project()` (the chokepoint for every
|
||||
project-scoped subcommand) and the `workflow run <file>` standalone-YAML path —
|
||||
so those can target a member project without `cd` too.
|
||||
|
||||
The contract mirrors `tests/test_init_dir.py` (the shell side): the value names
|
||||
the project root (the directory *containing* `.specify/`), relative paths
|
||||
resolve against cwd, and an invalid value hard-errors with no silent fallback to
|
||||
cwd. See proposals/monorepo-support and github/spec-kit discussion #2834.
|
||||
|
||||
SPECIFY_* vars are stripped from the environment for every test by the autouse
|
||||
`_strip_specify_env` fixture in conftest.py; tests that want an override set it
|
||||
explicitly via monkeypatch.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _make_project(root, name):
|
||||
"""Create <root>/<name>/.specify (the minimal Spec Kit project marker)."""
|
||||
proj = root / name
|
||||
(proj / ".specify").mkdir(parents=True)
|
||||
return proj
|
||||
|
||||
|
||||
def _workflow_yaml(wf_id):
|
||||
"""A minimal valid standalone workflow YAML with a single no-op shell step."""
|
||||
return yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": wf_id,
|
||||
"name": wf_id,
|
||||
"version": "1.0.0",
|
||||
"description": f"standalone workflow {wf_id}",
|
||||
},
|
||||
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── chokepoint: _require_specify_project() via `workflow list` ───────────────
|
||||
# `workflow list` is the lightest subcommand routed through the chokepoint: it
|
||||
# resolves the project, then reads <project>/.specify/workflows/. An empty
|
||||
# project prints "No workflows installed"; a failed resolution prints the error
|
||||
# and exits non-zero.
|
||||
|
||||
|
||||
def test_override_redirects_to_sibling_from_nonproject_cwd(tmp_path, monkeypatch):
|
||||
"""A valid SPECIFY_INIT_DIR resolves the target even when cwd is not itself a
|
||||
project — without the override this would error 'Not a Spec Kit project'."""
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
web = _make_project(tmp_path, "web")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
|
||||
|
||||
def test_override_relative_path_normalized_against_cwd(tmp_path, monkeypatch):
|
||||
web = _make_project(tmp_path, "web")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", "web")
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
assert web.exists()
|
||||
|
||||
|
||||
def test_override_trailing_slash_tolerated(tmp_path, monkeypatch):
|
||||
_make_project(tmp_path, "web")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", "web/")
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
|
||||
|
||||
def test_override_redirects_bundle_commands(tmp_path, monkeypatch):
|
||||
web = _make_project(tmp_path, "web")
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No bundles installed" in result.output
|
||||
|
||||
|
||||
def test_unset_override_uses_cwd(tmp_path, monkeypatch):
|
||||
"""With SPECIFY_INIT_DIR unset, the project is the current directory."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
|
||||
|
||||
def test_empty_override_treated_as_unset(tmp_path, monkeypatch):
|
||||
"""An empty SPECIFY_INIT_DIR behaves as unset (falls through to cwd), not as
|
||||
'.' — which from a deep non-project cwd would otherwise diverge."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", "")
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
|
||||
|
||||
def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch):
|
||||
"""A non-existent path hard-errors even from inside a valid project, proving
|
||||
there is no silent fallback to the cwd project."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code != 0
|
||||
assert "does not point to an existing directory" in result.output
|
||||
assert "No workflows installed" not in result.output # no fallback to cwd
|
||||
|
||||
|
||||
def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch):
|
||||
"""Bundle commands also honor the strict override contract."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
||||
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code != 0
|
||||
assert "does not point to an existing directory" in result.output
|
||||
assert "No bundles installed" not in result.output
|
||||
|
||||
|
||||
def test_override_nonexistent_bundle_json_error_stays_off_stdout(tmp_path, monkeypatch):
|
||||
"""Invalid override errors must not contaminate JSON stdout."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
||||
|
||||
result = runner.invoke(app, ["bundle", "list", "--json"])
|
||||
assert result.exit_code != 0
|
||||
assert result.stdout == ""
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
|
||||
|
||||
def test_override_symlinked_specify_errors_bundle_init_no_fallback(tmp_path, monkeypatch):
|
||||
"""A symlinked override .specify must not make bundle init fall back to cwd."""
|
||||
web = tmp_path / "web"
|
||||
web.mkdir()
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
try:
|
||||
(web / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["bundle", "init", "--offline"])
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify" in result.output
|
||||
assert not (elsewhere / ".specify").exists()
|
||||
|
||||
|
||||
def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch):
|
||||
"""A path that exists but lacks .specify/ hard-errors, no fallback."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
nodot = tmp_path / "nodot"
|
||||
nodot.mkdir()
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(nodot))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code != 0
|
||||
assert "not a Spec Kit project" in result.output
|
||||
assert "No workflows installed" not in result.output
|
||||
|
||||
|
||||
def test_override_file_path_errors_no_fallback(tmp_path, monkeypatch):
|
||||
"""A path that is a file (not a directory) hard-errors with the
|
||||
existing-directory message."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
a_file = tmp_path / "afile"
|
||||
a_file.write_text("x")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(a_file))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code != 0
|
||||
assert "does not point to an existing directory" in result.output
|
||||
|
||||
|
||||
# ── bypass: `workflow run <file>` ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_override_redirects_workflow_run_file(tmp_path, monkeypatch):
|
||||
"""Running a standalone YAML with SPECIFY_INIT_DIR set uses the target as the
|
||||
project root: run artifacts land under the target, not cwd."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
workflow_file = elsewhere / "wf.yml"
|
||||
workflow_file.write_text(_workflow_yaml("override-run"), encoding="utf-8")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "run", str(workflow_file)], catch_exceptions=False)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (web / ".specify" / "workflows" / "runs").is_dir()
|
||||
assert not (elsewhere / ".specify").exists() # cwd was not used as the project
|
||||
|
||||
|
||||
def test_override_invalid_errors_workflow_run_file(tmp_path, monkeypatch):
|
||||
"""An invalid SPECIFY_INIT_DIR hard-errors the file path too — no fallback to
|
||||
cwd's standalone-YAML behavior."""
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
workflow_file = elsewhere / "wf.yml"
|
||||
workflow_file.write_text(_workflow_yaml("x"), encoding="utf-8")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
|
||||
assert result.exit_code != 0
|
||||
assert "does not point to an existing directory" in result.output
|
||||
|
||||
|
||||
def test_override_rejects_symlinked_specify(tmp_path, monkeypatch):
|
||||
"""`workflow run <file>` refuses a symlinked .specify under the override
|
||||
target, matching the guard the cwd path applies (the override resolver's
|
||||
is_dir() check follows symlinks, so this is re-checked on the override path)."""
|
||||
web = tmp_path / "web"
|
||||
web.mkdir()
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
try:
|
||||
(web / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
workflow_file = elsewhere / "wf.yml"
|
||||
workflow_file.write_text(_workflow_yaml("symlink-run"), encoding="utf-8")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to use symlinked .specify path" in result.output
|
||||
|
||||
|
||||
def test_override_rejects_symlinked_specify_json_error_stays_off_stdout(tmp_path, monkeypatch):
|
||||
"""`workflow run --json <file>` must keep this hard error off stdout."""
|
||||
web = tmp_path / "web"
|
||||
web.mkdir()
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
try:
|
||||
(web / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
workflow_file = elsewhere / "wf.yml"
|
||||
workflow_file.write_text(_workflow_yaml("symlink-json-run"), encoding="utf-8")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "run", str(workflow_file), "--json"])
|
||||
assert result.exit_code != 0
|
||||
assert result.stdout == ""
|
||||
assert "Refusing to use symlinked .specify path" in result.stderr
|
||||
245
tests/test_post_process.py
Normal file
245
tests/test_post_process.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Tests for post_process_command_content() hook on IntegrationBase.
|
||||
|
||||
Verifies that the generalized post-processing hook:
|
||||
- Runs for non-skills format types (Markdown, TOML, YAML)
|
||||
- Does NOT run for skills-format agents
|
||||
- Default no-op returns content unchanged
|
||||
- Exceptions propagate to caller
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registrar():
|
||||
return CommandRegistrar()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext_dir(tmp_path):
|
||||
"""Create a mock extension with a simple command template."""
|
||||
ext = tmp_path / "extension"
|
||||
ext.mkdir()
|
||||
cmd_dir = ext / "commands"
|
||||
cmd_dir.mkdir()
|
||||
return ext, cmd_dir
|
||||
|
||||
|
||||
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
|
||||
cmd_file = cmd_dir / name
|
||||
cmd_file.write_text(
|
||||
f"---\ndescription: Test command\n---\n\n{body}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return cmd_file
|
||||
|
||||
|
||||
class TestDefaultNoOp:
|
||||
def test_returns_content_unchanged(self):
|
||||
base = IntegrationBase()
|
||||
content = "Some command content\nwith multiple lines."
|
||||
assert base.post_process_command_content(content) == content
|
||||
|
||||
def test_empty_string(self):
|
||||
base = IntegrationBase()
|
||||
assert base.post_process_command_content("") == ""
|
||||
|
||||
|
||||
class TestMarkdownAgentPostProcess:
|
||||
def test_opencode_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
opencode = get_integration("opencode")
|
||||
marker = "<!-- POST_PROCESSED -->"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + marker
|
||||
|
||||
monkeypatch.setattr(
|
||||
opencode.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"opencode", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestTomlAgentPostProcess:
|
||||
def test_gemini_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
gemini = get_integration("gemini")
|
||||
marker = "# POST_PROCESSED"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + f"\n{marker}\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
gemini.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"gemini", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestYamlAgentPostProcess:
|
||||
def test_goose_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
goose = get_integration("goose")
|
||||
marker = "# POST_PROCESSED"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + f"\n{marker}\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
goose.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"goose", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestSkillsAgentExcluded:
|
||||
def test_claude_post_process_not_called(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
claude = get_integration("claude")
|
||||
marker = "<!-- SHOULD_NOT_APPEAR -->"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + marker
|
||||
|
||||
monkeypatch.setattr(
|
||||
claude.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"claude", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
skill_file = (
|
||||
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
|
||||
)
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert marker not in content
|
||||
|
||||
def test_skills_agent_method_never_called(
|
||||
self, tmp_path, registrar, ext_dir
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
claude = get_integration("claude")
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
|
||||
with patch.object(
|
||||
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
|
||||
) as mock_method:
|
||||
registrar.register_commands(
|
||||
"claude", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
mock_method.assert_not_called()
|
||||
|
||||
|
||||
class TestExceptionPropagation:
|
||||
def test_hook_exception_propagates(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
opencode = get_integration("opencode")
|
||||
|
||||
def _raise(self, content):
|
||||
raise RuntimeError("Hook failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
opencode.__class__, "post_process_command_content", _raise
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
with pytest.raises(RuntimeError, match="Hook failed"):
|
||||
registrar.register_commands(
|
||||
"opencode", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
|
||||
class TestRegressionPlainTemplate:
|
||||
@pytest.mark.parametrize(
|
||||
"agent,path_pattern",
|
||||
[
|
||||
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
|
||||
("opencode", ".opencode/commands/speckit.test.plain.md"),
|
||||
],
|
||||
ids=["skills", "markdown"],
|
||||
)
|
||||
def test_plain_template_unchanged(
|
||||
self, tmp_path, registrar, ext_dir, agent, path_pattern
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
body_text = "This is a plain command with no special content.\n"
|
||||
_write_cmd(cmd_dir, name="plain.md", body=body_text)
|
||||
|
||||
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
|
||||
registrar.register_commands(
|
||||
agent, commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
output_file = tmp_path / path_pattern
|
||||
assert output_file.exists(), f"Output file missing for {agent}"
|
||||
content = output_file.read_text(encoding="utf-8")
|
||||
assert body_text.strip() in content, f"Body text missing in {agent} output"
|
||||
@@ -4538,6 +4538,27 @@ class TestBundledPresetLocator:
|
||||
assert "got https://" not in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url") as open_url:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["preset", "add", "--from", "https://[::1/preset.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid URL" in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
|
||||
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
|
||||
import typer
|
||||
|
||||
@@ -108,7 +108,7 @@ class TestWorkflowRunWithoutProject:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
assert "Not a Spec Kit project" in result.output
|
||||
|
||||
def test_workflow_run_missing_yaml_file(self, tmp_path):
|
||||
"""Running a non-existent .yml file should still require a project."""
|
||||
@@ -322,3 +322,87 @@ class TestWorkflowRunWithoutProject:
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert ".specify path exists but is not a directory" in result.output
|
||||
|
||||
|
||||
class TestWorkflowRunJsonErrorStream:
|
||||
"""Under --json, error text must go to stderr so stdout stays parseable."""
|
||||
|
||||
def _bad_workflow(self, tmp_path):
|
||||
wf = tmp_path / "bad.yml"
|
||||
wf.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "bad-wf",
|
||||
"name": "Bad",
|
||||
"version": "1.0.0",
|
||||
"description": "fails validation",
|
||||
},
|
||||
# shell step missing required 'run' -> validation error
|
||||
"steps": [{"id": "s", "type": "shell"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return wf
|
||||
|
||||
def test_run_json_validation_error_not_on_stdout(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
wf = self._bad_workflow(tmp_path)
|
||||
runner = CliRunner()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "run", str(wf), "--json"], catch_exceptions=False
|
||||
)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
assert result.exit_code == 1
|
||||
# stdout must carry only JSON (here: nothing) — never human error text.
|
||||
assert "validation failed" not in result.stdout
|
||||
assert "Error" not in result.stdout
|
||||
# The message is routed to stderr instead.
|
||||
assert "validation failed" in result.stderr
|
||||
|
||||
def test_run_json_invalid_input_not_on_stdout(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
# A valid single-shell workflow so we get past load/validate to
|
||||
# _parse_input_values, which rejects the malformed --input.
|
||||
wf = tmp_path / "ok.yml"
|
||||
wf.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "ok-wf",
|
||||
"name": "OK",
|
||||
"version": "1.0.0",
|
||||
"description": "x",
|
||||
},
|
||||
"steps": [{"id": "s", "type": "shell", "run": "echo hi"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["workflow", "run", str(wf), "--json", "--input", "no-equals"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid input format" not in result.stdout
|
||||
assert "Invalid input format" in result.stderr
|
||||
|
||||
@@ -226,6 +226,94 @@ class TestExpressions:
|
||||
result = evaluate_expression("Feature: {{ inputs.name }} done", ctx)
|
||||
assert result == "Feature: login done"
|
||||
|
||||
def test_multi_expression_no_surrounding_text(self):
|
||||
"""Two expressions with no surrounding literal text must interpolate each,
|
||||
not collapse to None via the fullmatch fast path (#3208)."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"issue": "23"}, run_id="47c5eb4b")
|
||||
result = evaluate_expression(
|
||||
"{{ context.run_id }} {{ inputs.issue }}", ctx
|
||||
)
|
||||
assert result == "47c5eb4b 23"
|
||||
|
||||
def test_multi_expression_adjacent_no_separator(self):
|
||||
"""Back-to-back expressions with no separator still interpolate (#3208)."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"a": "foo", "b": "bar"})
|
||||
result = evaluate_expression("{{ inputs.a }}{{ inputs.b }}", ctx)
|
||||
assert result == "foobar"
|
||||
|
||||
def test_single_expression_with_literal_braces_preserves_type(self):
|
||||
"""A lone expression whose string argument contains a literal ``{{`` or ``}}``
|
||||
must still take the typed fast path and return a bool, not a string
|
||||
(the fix for #3208 must not coerce it to ``\"True\"``)."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"text": "uses {{ jinja }} syntax"})
|
||||
assert evaluate_expression("{{ inputs.text | contains('{{') }}", ctx) is True
|
||||
|
||||
ctx = StepContext(inputs={"text": "uses }} syntax"})
|
||||
assert evaluate_expression("{{ inputs.text | contains('}}') }}", ctx) is True
|
||||
|
||||
def test_multi_expression_with_literal_close_brace_in_argument(self):
|
||||
"""A multi-expression template with a literal ``}}`` inside a string
|
||||
argument must interpolate, not raise. #3208/#3228 hardened the single-
|
||||
expression fast path for literal braces but left the interpolation path
|
||||
on ``_EXPR_PATTERN``, whose non-greedy body stops at the first ``}}`` --
|
||||
so the block was captured truncated and the filter parser raised
|
||||
ValueError."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"name": "Bob", "missing": None})
|
||||
# ``}}`` in the default fallback of the second block.
|
||||
result = evaluate_expression(
|
||||
"{{ inputs.name }}: {{ inputs.missing | default('}}') }}", ctx
|
||||
)
|
||||
assert result == "Bob: }}"
|
||||
# ``}}`` in the first block, expression following it.
|
||||
result = evaluate_expression(
|
||||
"{{ inputs.missing | default('}}') }} / {{ inputs.name }}", ctx
|
||||
)
|
||||
assert result == "}} / Bob"
|
||||
|
||||
def test_multi_expression_with_literal_open_brace_in_argument(self):
|
||||
"""A literal ``{{`` inside a string argument in a multi-expression
|
||||
template must not confuse block detection either."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"name": "Bob", "missing": None})
|
||||
result = evaluate_expression(
|
||||
"{{ inputs.name }} {{ inputs.missing | default('{{') }}", ctx
|
||||
)
|
||||
assert result == "Bob {{"
|
||||
|
||||
def test_multi_expression_unbalanced_quote_still_raises(self):
|
||||
"""A malformed block (an unbalanced quote in a filter arg) must still
|
||||
surface a ValueError, not be silently emitted verbatim.
|
||||
|
||||
The quote-aware scan never finds a block-closing ``}}`` when a quote is
|
||||
left open, but a raw ``}}`` is still present in the tail. It must fall
|
||||
back to that raw delimiter and evaluate — same as the old regex path —
|
||||
so a typo fails loudly instead of being hidden (Copilot review on
|
||||
#3307)."""
|
||||
import pytest
|
||||
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"name": "Bob", "missing": None})
|
||||
with pytest.raises(ValueError):
|
||||
evaluate_expression(
|
||||
"{{ inputs.name }} {{ inputs.missing | default('oops }}", ctx
|
||||
)
|
||||
|
||||
def test_comparison_equals(self):
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
@@ -254,6 +342,35 @@ class TestExpressions:
|
||||
assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True
|
||||
assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False
|
||||
|
||||
def test_ordering_comparison_of_non_numeric_strings(self):
|
||||
"""`<`/`>`/`<=`/`>=` between non-numeric strings must compare
|
||||
lexicographically, not silently return False.
|
||||
|
||||
`_safe_compare` used to coerce both operands to int/float unconditionally;
|
||||
a non-numeric string (date, version tag, name) failed that coercion and
|
||||
the whole comparison returned False. Ordinary strings should order the
|
||||
way Python does; numeric strings must still compare as numbers."""
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
# ISO dates compare lexicographically (correct chronological order).
|
||||
ctx = StepContext(inputs={"d": "2026-01-01"})
|
||||
assert evaluate_expression("{{ inputs.d < '2026-02-01' }}", ctx) is True
|
||||
assert evaluate_expression("{{ inputs.d > '2026-02-01' }}", ctx) is False
|
||||
|
||||
# Plain string ordering.
|
||||
ctx = StepContext(inputs={"name": "beta"})
|
||||
assert evaluate_expression("{{ inputs.name > 'alpha' }}", ctx) is True
|
||||
|
||||
# Two numeric strings still compare numerically, not lexically
|
||||
# ("10" > "9" is True as numbers; as strings it would be False).
|
||||
ctx = StepContext(inputs={"v": "10"})
|
||||
assert evaluate_expression("{{ inputs.v > '9' }}", ctx) is True
|
||||
|
||||
# A number vs a non-numeric string is genuinely incomparable -> False.
|
||||
ctx = StepContext(inputs={"n": 5})
|
||||
assert evaluate_expression("{{ inputs.n > 'abc' }}", ctx) is False
|
||||
|
||||
def test_boolean_and(self):
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
@@ -1149,6 +1266,26 @@ class TestShellStep:
|
||||
errors = step.validate({"id": "test"})
|
||||
assert any("missing 'run'" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("bad_run", [None, ["echo", "hi"], 42])
|
||||
def test_validate_rejects_non_string_run(self, bad_run):
|
||||
"""A non-string 'run' must be rejected at validation.
|
||||
|
||||
execute() str()-coerces run and invokes it under shell=True, so a
|
||||
null or list run would otherwise run the Python repr as a command.
|
||||
"""
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
step = ShellStep()
|
||||
errors = step.validate({"id": "s", "run": bad_run})
|
||||
assert any("'run' must be a string" in e for e in errors)
|
||||
|
||||
def test_validate_accepts_string_and_expression_run(self):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
step = ShellStep()
|
||||
assert step.validate({"id": "s", "run": "echo hi"}) == []
|
||||
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
|
||||
|
||||
|
||||
def test_output_format_json_exposes_data(self, tmp_path):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
@@ -2065,6 +2202,27 @@ class TestFanInStep:
|
||||
errors = step.validate({"id": "test", "wait_for": "not-a-list"})
|
||||
assert any("non-empty list" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("bad_output", [["{{ fan_in.results }}"], "{{ x }}", 42])
|
||||
def test_validate_rejects_non_mapping_output(self, bad_output):
|
||||
"""A non-mapping 'output' must be rejected: execute() would otherwise
|
||||
silently coerce it to {} and drop the declared aggregation keys."""
|
||||
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||
|
||||
step = FanInStep()
|
||||
errors = step.validate(
|
||||
{"id": "j", "wait_for": ["a"], "output": bad_output}
|
||||
)
|
||||
assert any("'output' must be a mapping" in e for e in errors)
|
||||
|
||||
def test_validate_accepts_mapping_or_absent_output(self):
|
||||
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||
|
||||
step = FanInStep()
|
||||
assert step.validate(
|
||||
{"id": "j", "wait_for": ["a"], "output": {"joined": "{{ x }}"}}
|
||||
) == []
|
||||
assert step.validate({"id": "j", "wait_for": ["a"]}) == []
|
||||
|
||||
|
||||
class TestFanOutConcurrency:
|
||||
"""Fan-out honors max_concurrency (WorkflowEngine._run_fan_out)."""
|
||||
@@ -3943,6 +4101,48 @@ steps:
|
||||
assert state.status == RunStatus.ABORTED
|
||||
assert "should-not-run" not in state.step_results
|
||||
|
||||
def test_gate_reject_matches_case_insensitively(
|
||||
self, project_dir, monkeypatch
|
||||
):
|
||||
"""A capitalised reject option (`options: [Approve, Reject]`) still
|
||||
aborts the run. `validate` accepts a reject choice case-insensitively,
|
||||
so the runtime reject check must agree — a case-sensitive comparison
|
||||
would treat the echoed `Reject` as approval and silently run
|
||||
downstream steps.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
from specify_cli.workflows.steps.gate import GateStep
|
||||
|
||||
# `_prompt` echoes the option's original casing, so the operator
|
||||
# picking "Reject" hands `execute` the capitalised string.
|
||||
_force_gate_stdin(monkeypatch, tty=True)
|
||||
monkeypatch.setattr(
|
||||
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "Reject")
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "gate-reject-case"
|
||||
name: "Gate Reject Case"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: gate-step
|
||||
type: gate
|
||||
message: "Approve?"
|
||||
options: [Approve, Reject]
|
||||
on_reject: abort
|
||||
- id: should-not-run
|
||||
type: shell
|
||||
run: "echo nope"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.ABORTED
|
||||
assert "should-not-run" not in state.step_results
|
||||
|
||||
def test_validation_rejects_non_bool_continue_on_error(self):
|
||||
"""`continue_on_error` must be a literal boolean; coerced
|
||||
strings like `"true"` are rejected at validation time so
|
||||
@@ -5393,6 +5593,23 @@ class TestWorkflowRemoveGuard:
|
||||
|
||||
|
||||
class TestWorkflowAddSymlinkGuard:
|
||||
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify").mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["workflow", "add", "https://[::1/wf.yaml"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Invalid URL" in result.output
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
|
||||
"""workflow add must refuse a symlinked .specify (writes could escape root)."""
|
||||
|
||||
@@ -69,3 +69,30 @@ def test_http_fetch_rejects_non_https_final_url(monkeypatch):
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
fetcher(_source("https://example.com/c.json"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://:8080", # port only, no host
|
||||
"https://:0",
|
||||
"https://user@", # userinfo only, no host
|
||||
"https://user:pw@",
|
||||
"https://:8080/catalog.json",
|
||||
],
|
||||
)
|
||||
def test_validate_remote_url_rejects_host_less_urls(url):
|
||||
"""A URL with a truthy netloc but no host (``https://:8080``,
|
||||
``https://user@``) must be rejected.
|
||||
|
||||
``urlparse`` gives these a non-empty ``netloc`` but ``hostname is None``,
|
||||
so a ``netloc`` check would wrongly accept them. This mirrors the fix in
|
||||
``specify_cli.catalogs`` (#3210), which the docstring says this validator
|
||||
mirrors."""
|
||||
with pytest.raises(BundlerError, match="valid URL with a host"):
|
||||
adapters._validate_remote_url("team", url)
|
||||
|
||||
|
||||
def test_validate_remote_url_accepts_normal_https_url():
|
||||
# Sanity: a real host with a port still passes.
|
||||
adapters._validate_remote_url("team", "https://example.com:8080/c.json")
|
||||
|
||||
@@ -222,3 +222,39 @@ def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
|
||||
# A relative path containing ':' but no '://' is still a local path.
|
||||
source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
|
||||
assert source.url.endswith("weird:name.json") or "weird" in source.url
|
||||
|
||||
|
||||
def test_add_source_rejects_plain_http_for_non_localhost(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="HTTPS"):
|
||||
cc.add_source(project, "http://example.com/catalog.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_allows_http_for_localhost(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
source = cc.add_source(project, "http://localhost:8080/c.json", policy="install-allowed", priority=50)
|
||||
assert source.url == "http://localhost:8080/c.json"
|
||||
|
||||
|
||||
def test_add_source_rejects_host_less_remote_urls(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
for url in ("https://:8080", "https://user@"):
|
||||
with pytest.raises(BundlerError, match="host"):
|
||||
cc.add_source(project, url, policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="Invalid catalog url"):
|
||||
cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="No project-scoped catalog source"):
|
||||
cc.remove_source(project, "https://[::1/c.json")
|
||||
|
||||
Reference in New Issue
Block a user