Compare commits

...

23 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
b3f25da16f Fix Windows absolute path detection and .zip example in docstring
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ddeae546-8287-421f-bc5d-1636515bf99a

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-04-28 18:11:14 +00:00
copilot-swe-agent[bot]
a1543bc1f0 Add --extension flag to specify init for installing extensions at init time
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ddeae546-8287-421f-bc5d-1636515bf99a

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-04-28 18:09:29 +00:00
copilot-swe-agent[bot]
e3cf9d76e8 Initial plan 2026-04-28 17:58:35 +00:00
Ben Buttigieg
0aa588a9b4 Merge pull request #2392 from BenBtg/community/add-m365-extension
Add m365 to community catalog
2026-04-28 17:06:54 +01:00
Ben Buttigieg
ea92155b52 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 16:55:12 +01:00
Manfred Riem
047be2308c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 10:50:23 -05:00
Ben Buttigieg
7d0f670b83 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 16:40:27 +01:00
Ben Buttigieg
5b3ebabcaf Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 16:36:01 +01:00
Ben Buttigieg
719eef3ff1 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 16:31:53 +01:00
Ben Buttigieg
fe9f19d569 Potential fix for pull request finding
"microsoft-365",
        "teams",
        "meetings",
        "transcripts",

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 16:16:47 +01:00
Ben Buttigieg
56f9b95b0d Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 16:07:20 +01:00
Ben Buttigieg
7b99fef2bc Merge branch 'main' into community/add-m365-extension 2026-04-28 15:51:40 +01:00
Ben Buttigieg
bd3ae9aaef Add MarkItDown Document Converter extension to community catalog (#2390) 2026-04-28 09:28:05 -05:00
Ben Buttigieg
a0634ef96e Merge branch 'github:main' into community/add-m365-extension 2026-04-28 15:18:50 +01:00
adaumann
a918979236 feat: Speckit preset fiction book v1.7 - Support for RAG (Chroma DB) offline semantic search (#2367)
* Update preset-fiction-book-writing to community catalog

- Preset ID: fiction-book-writing
- Version: 1.5.0
- Author: Andreas Daumann
- Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections

* Add fiction-book-writing preset to community catalog

- Preset ID: fiction-book-writing
- Version: 1.6.0
- Author: Andreas Daumann
- Description: Added support for 12 languages, export with templates, cover builder, bio builder, workflow fixes

* Update presets/catalog.community.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fixed update_at for fiction-book-writing preset

* Update README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fixed description for fiction-book-writing

* "Add fiction-book-preset to community catalog

- Preset ID: fiction-book-writing
- Version: 1.7.0
- Author: Andreas Daumann
- Description: It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. V1.7.0: Support for offline semantic search.

* Update presets/catalog.community.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update presets/catalog.community.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add fiction-book-writing to community catalog

- Preset ID: fiction-book-writing
- Version: 1.7.0
- Author: Andreas Daumann
- Description: Spec-Driven Development for novel and long-form fiction. RAG support

* Update docs/community/presets.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 08:58:30 -05:00
Quratulain-bilal
3a7f64c8a5 fix(extensions): use explicit UTF-8 encoding when reading manifest YAML (#2370)
* fix(extensions): use explicit UTF-8 encoding when reading manifest YAML

On Windows, Python's open() defaults to the system locale encoding
(e.g., GBK on Chinese Windows), which causes UnicodeDecodeError when
extension.yml or preset.yml contains non-ASCII content such as Chinese
characters in description fields.

Add encoding='utf-8' to ExtensionManifest._load_yaml and
PresetManifest._load_yaml so manifests are read consistently across
platforms.

Fixes #2325

* test(extensions,presets): add UTF-8 manifest regression tests for #2325

Positive: extension.yml/preset.yml with non-ASCII (Chinese + emoji)
descriptions load correctly when written as UTF-8 bytes — fails on
Windows without explicit encoding='utf-8'.

Negative: files containing invalid UTF-8 bytes raise a clean error
(ValidationError or UnicodeDecodeError), not a silent crash.

* fix(extensions,presets): wrap I/O and decode errors as ValidationError

Address remaining Copilot concerns on #2370:

- Catch UnicodeDecodeError and OSError in both manifest loaders and
  re-raise as ValidationError / PresetValidationError so callers see a
  consistent error type, not a bare decode/IO traceback.
- Validate that PresetManifest YAML root is a mapping (extensions.py
  already had this; presets.py was missing it). Treat None as {} for
  empty-file compatibility.
- Tighten the negative regression tests to assert the specific message,
  and add a non-mapping-root test for PresetManifest matching the
  existing one for ExtensionManifest.
2026-04-28 08:47:22 -05:00
Ben Buttigieg
77ca5f4ed5 catalog: add m365 community extension
Add Microsoft 365 Integration to community catalog and README.
Ingests Teams messages, files, and meeting transcripts as Markdown
for use with speckit specify.
2026-04-27 17:54:50 +01:00
Manfred Riem
171b65ac33 docs: replace deprecated --ai flag with --integration in all documentation (#2359)
* docs: replace deprecated --ai flag with --integration in all documentation

Replace all user-facing --ai, --ai-skills, and --ai-commands-dir references
with their modern equivalents:

- --ai <agent> → --integration <agent>
- --ai-skills → --integration-options="--skills"
- --ai-commands-dir <dir> → --integration generic --integration-options="--commands-dir <dir>"

Updated files:
- README.md (~17 occurrences)
- docs/installation.md (~8 occurrences)
- docs/upgrade.md (~11 occurrences)
- docs/local-development.md (~5 occurrences)
- CONTRIBUTING.md (1 occurrence)
- extensions/EXTENSION-USER-GUIDE.md (1 occurrence)
- src/specify_cli/__init__.py (docstring examples and error messages)

Left unchanged:
- CHANGELOG.md (historical record)
- Test files (intentionally exercise deprecated flag path)
- CLI flag implementation (backward compatibility)

Closes #2358

* docs: address review feedback on pre-existing issues

- Fix duplicate copilot example in README.md (replace with codex)
- Fix invalid gemini --integration-options="--skills" example (gemini
  does not support --skills)
- Update generic integration comment from 'Unsupported agent' to
  'Bring your own agent; requires --commands-dir'
- Clarify EXTENSION-USER-GUIDE.md: skills auto-register for
  skills-based integrations, not only with --integration-options

* docs: replace bare 'AI agent' / 'AI assistant' with 'coding agent' throughout

Full sweep across all documentation and user-facing CLI messages to
align terminology. Bare references like 'AI agent', 'AI assistant',
and 'AI Agent' are replaced with 'coding agent' or 'coding agent
integration' as appropriate.

Intentionally left unchanged:
- 'AI coding agent' (already correct expanded form)
- Deprecated --ai flag help text and error messages (describes the
  deprecated flag itself)
- Community extension descriptions (external project text)
- 'generated by an AI' in CONTRIBUTING.md (general AI, not agent)

* docs: address review — remove deprecated --offline, qualify --skills scope

- Remove --offline from docstring examples (deprecated no-op)
- Remove --offline from CONTRIBUTING.md testing example
- Replace --offline instructions in docs/installation.md with note that
  bundled assets are used by default
- Qualify --integration-options="--skills" in README.md to note it only
  applies to integrations that support skills mode
2026-04-24 16:04:04 -05:00
Taylor Mulder
232c19cb04 feat(extensions,presets): authenticate GitHub-hosted catalog and download requests with GITHUB_TOKEN/GH_TOKEN (#2331)
* feat(extensions,presets): authenticate GitHub-hosted catalog and download requests with GITHUB_TOKEN/GH_TOKEN

Squashed from #2087 (original author: @anasseth).

Adds GitHub-token authentication to extension and preset catalog fetching
and ZIP downloads so private GitHub repos work when GITHUB_TOKEN/GH_TOKEN
is set, while preventing credential leakage to non-GitHub hosts.

- Introduces shared _github_http module with build_github_request() and
  open_github_url() helpers
- Routes ExtensionCatalog and PresetCatalog network calls through
  GitHub-auth-aware opener
- Adds comprehensive unit/integration tests for auth header behavior
- Updates user docs for both extensions and presets

Co-authored-by: anasseth <16745089+anasseth@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(auth): address review feedback from #2087

- Fix redirect handler to preserve Authorization on GitHub-to-GitHub
  redirects (e.g. github.com → codeload.github.com). The previous
  implementation relied on super().redirect_request() which strips
  auth on cross-host redirects, breaking private repo archive downloads.
- Add codeload.github.com to documented host lists in both
  EXTENSION-USER-GUIDE.md and presets/README.md
- Add redirect auth-preservation and auth-stripping tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(auth): use Bearer scheme instead of token for consistency

Aligns with the rest of the codebase (e.g. __init__.py:1721) and
GitHub's current API guidance. Updates all test assertions accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address second round of Copilot review feedback

- Fix docstring to say Bearer instead of token (matches implementation)
- Remove unused imports/fixtures from redirect tests (GITHUB_HOSTS,
  MagicMock, temp_dir, monkeypatch)
- Replace __import__('io').BytesIO() with normal import io pattern
  in test_presets.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: anasseth <16745089+anasseth@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 14:17:40 -05:00
Manfred Riem
ca51d739fb Update extensify to v1.1.0 in community catalog (#2337) 2026-04-24 13:58:34 -05:00
Manfred Riem
03f3024c66 feat(init): deprecate --no-git flag, gate deprecations at v0.10.0 (#2357)
* feat(init): deprecate --no-git flag, gate deprecations at v0.10.0

- Add deprecation warning when --no-git is used on specify init
- Update --ai deprecation gate from 1.0.0 to 0.10.0
- Update test expectation for the new version gate

Closes #2167

* fix: address PR review feedback

- Update --no-git deprecation message to reference existing 'specify extension'
  commands instead of non-existent --extension flag
- Add test_no_git_emits_deprecation_warning CLI test

* fix: strengthen --no-git deprecation test assertions

Add assertions unique to the --no-git message ('will be removed',
'git extension will no longer be enabled by default') to prevent
false positives from the --ai deprecation panel.
2026-04-24 13:54:40 -05:00
Quratulain-bilal
aad7b16188 Add Spec Orchestrator extension to community catalog (#2350) 2026-04-24 13:11:39 -05:00
Manfred Riem
6cec171772 chore: release 0.8.1, begin 0.8.2.dev0 development (#2356)
* chore: bump version to 0.8.1

* chore: begin 0.8.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-24 12:50:38 -05:00
20 changed files with 1085 additions and 126 deletions

View File

@@ -2,6 +2,22 @@
<!-- insert new changelog below this comment -->
## [0.8.1] - 2026-04-24
### Changed
- fix(plan): use .specify/feature.json to allow /speckit.plan on custom git branches (#2305) (#2349)
- feat(vibe): migrate to SkillsIntegration from the old prompts-based MarkdownIntegration (#2336)
- docs: move community presets table to docs site, add missing entries (#2341)
- docs(presets): add lean preset README and enrich catalog metadata (#2340)
- fix: resolve command references per integration type (dot vs hyphen) (#2354)
- Update product-forge to v1.5.1 in community catalog (#2352)
- chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#2345)
- fix: replace xargs trim with sed to handle quotes in descriptions (#2351)
- feat: register jira preset in community catalog (#2224)
- feat: Preset screenwriting (#2332)
- chore: release 0.8.0, begin 0.8.1.dev0 development (#2333)
## [0.8.0] - 2026-04-23
### Changed

View File

@@ -94,7 +94,7 @@ uv pip install -e .
# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing.
# Initialize a test project using your local changes
uv run specify init <temp-dir>/speckit-test --ai <agent> --offline
uv run specify init <temp-dir>/speckit-test --integration <agent>
cd <temp-dir>/speckit-test
# Open in your agent
@@ -102,7 +102,7 @@ cd <temp-dir>/speckit-test
#### Manual testing process
Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR.
Any change that affects a slash command's behavior requires manually testing that command through a coding agent and submitting results with the PR.
1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing.
2. **Set up a test project** — scaffold from your local branch (see [Testing setup](#testing-setup)).

View File

@@ -81,9 +81,9 @@ And use the tool directly:
specify init <PROJECT_NAME>
# Or initialize in existing project
specify init . --ai copilot
specify init . --integration copilot
# or
specify init --here --ai copilot
specify init --here --integration copilot
# Check installed tools
specify check
@@ -105,9 +105,9 @@ Run directly without installing:
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Or initialize in existing project
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --integration copilot
# or
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
```
**Benefits of persistent installation:**
@@ -123,7 +123,7 @@ If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-G
### 2. Establish project principles
Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
@@ -228,9 +228,11 @@ The following community-contributed extensions are available in [`catalog.commun
| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) |
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| 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) |
| 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) |
@@ -255,6 +257,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
@@ -301,7 +304,7 @@ Run `specify integration list` to see all available integrations in your install
## Available Slash Commands
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai <agent> --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`.
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration <agent> --integration-options="--skills"` installs agent skills instead of slash-command prompt files.
#### Core Commands
@@ -474,37 +477,37 @@ specify init --here --force
![Specify CLI bootstrapping a new project in the terminal](./media/specify_cli.gif)
You will be prompted to select the AI agent you are using. You can also proactively specify it directly in the terminal:
You will be prompted to select the coding agent integration you are using. You can also proactively specify it directly in the terminal:
```bash
specify init <project_name> --ai copilot
specify init <project_name> --ai gemini
specify init <project_name> --ai copilot
specify init <project_name> --integration copilot
specify init <project_name> --integration gemini
specify init <project_name> --integration codex
# Or in current directory:
specify init . --ai copilot
specify init . --ai codex --ai-skills
specify init . --integration copilot
specify init . --integration codex --integration-options="--skills"
# or use --here flag
specify init --here --ai copilot
specify init --here --ai codex --ai-skills
specify init --here --integration copilot
specify init --here --integration codex --integration-options="--skills"
# Force merge into a non-empty current directory
specify init . --force --ai copilot
specify init . --force --integration copilot
# or
specify init --here --force --ai copilot
specify init --here --force --integration copilot
```
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash
specify init <project_name> --ai copilot --ignore-agent-tools
specify init <project_name> --integration copilot --ignore-agent-tools
```
### **STEP 1:** Establish project principles
Go to the project folder and run your AI agent. In our example, we're using `claude`.
Go to the project folder and run your coding agent. In our example, we're using `claude`.
![Bootstrapping Claude Code environment](./media/bootstrap-claude-code.gif)
@@ -516,7 +519,7 @@ The first step should be establishing your project's governing principles using
/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements. Include governance for how these principles should guide technical decisions and implementation choices.
```
This step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the AI agent will reference during specification, planning, and implementation phases.
This step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the coding agent will reference during specification, planning, and implementation phases.
### **STEP 2:** Create project specifications
@@ -724,9 +727,9 @@ The `/speckit.implement` command will:
- Provide progress updates and handle errors appropriately
> [!IMPORTANT]
> The AI agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine.
> The coding agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine.
Once the implementation is complete, test the application and resolve any runtime errors that may not be visible in CLI logs (e.g., browser console errors). You can copy and paste such errors back to your AI agent for resolution.
Once the implementation is complete, test the application and resolve any runtime errors that may not be visible in CLI logs (e.g., browser console errors). You can copy and paste such errors back to your coding agent for resolution.
</details>

View File

@@ -11,7 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 1 script | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |

View File

@@ -39,16 +39,16 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init .
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here
```
### Specify AI Agent
### Specify Integration
You can proactively specify your AI agent during initialization:
You can proactively specify your coding agent integration during initialization:
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai claude
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai gemini
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai codebuddy
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai pi
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration claude
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration gemini
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration codebuddy
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration pi
```
### Specify Script Type (Shell vs PowerShell)
@@ -73,7 +73,7 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <proje
If you prefer to get the templates without checking for the right tools:
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai claude --ignore-agent-tools
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration claude --ignore-agent-tools
```
## Verification
@@ -86,7 +86,7 @@ specify version
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
After initialization, you should see the following commands available in your AI agent:
After initialization, you should see the following commands available in your coding agent:
- `/speckit.specify` - Create specifications
- `/speckit.plan` - Generate implementation plans
@@ -131,12 +131,10 @@ pip install --no-index --find-links=./dist specify-cli
```bash
# Initialize a project — no GitHub access needed
specify init my-project --ai claude --offline
specify init my-project --integration claude
```
The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub.
> **Deprecation notice:** Starting with v0.6.0, `specify init` will use bundled assets by default and the `--offline` flag will be removed. The GitHub download path will be retired because bundled assets eliminate the need for network access, avoid proxy/firewall issues, and guarantee that templates always match the installed CLI version. No action will be needed — `specify init` will simply work without network access out of the box.
Bundled assets are used by default — no network access is required.
> **Note:** Python 3.11+ is required.

View File

@@ -20,7 +20,7 @@ You can execute the CLI via the module entrypoint without installing anything:
```bash
# From repo root
python -m src.specify_cli --help
python -m src.specify_cli init demo-project --ai claude --ignore-agent-tools --script sh
python -m src.specify_cli init demo-project --integration claude --ignore-agent-tools --script sh
```
If you prefer invoking the script file style (uses shebang):
@@ -52,7 +52,7 @@ Re-running after code edits requires no reinstall because of editable mode.
`uvx` can run from a local path (or a Git ref) to simulate user flows:
```bash
uvx --from . specify init demo-uvx --ai copilot --ignore-agent-tools --script sh
uvx --from . specify init demo-uvx --integration copilot --ignore-agent-tools --script sh
```
You can also point uvx at a specific branch without merging:
@@ -69,14 +69,14 @@ If you're in another directory, use an absolute path instead of `.`:
```bash
uvx --from /mnt/c/GitHub/spec-kit specify --help
uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --ai copilot --ignore-agent-tools --script sh
uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --integration copilot --ignore-agent-tools --script sh
```
Set an environment variable for convenience:
```bash
export SPEC_KIT_SRC=/mnt/c/GitHub/spec-kit
uvx --from "$SPEC_KIT_SRC" specify init demo-env --ai copilot --ignore-agent-tools --script ps
uvx --from "$SPEC_KIT_SRC" specify init demo-env --integration copilot --ignore-agent-tools --script ps
```
(Optional) Define a shell function:
@@ -123,7 +123,7 @@ When testing `init --here` in a dirty directory, create a temp workspace:
```bash
mkdir /tmp/spec-test && cd /tmp/spec-test
python -m src.specify_cli init --here --ai claude --ignore-agent-tools --script sh # if repo copied here
python -m src.specify_cli init --here --integration claude --ignore-agent-tools --script sh # if repo copied here
```
Or copy only the modified CLI portion if you want a lighter sandbox.

View File

@@ -42,7 +42,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
### Step 2: Define Your Constitution
**In your AI Agent's chat interface**, use the `/speckit.constitution` slash command to establish the core rules and principles for your project. You should provide your project's specific principles as arguments.
**In your coding agent's chat interface**, use the `/speckit.constitution` slash command to establish the core rules and principles for your project. You should provide your project's specific principles as arguments.
```markdown
/speckit.constitution This project follows a "Library-First" approach. All features must be implemented as standalone libraries first. We use TDD strictly. We prefer functional programming patterns.
@@ -159,7 +159,7 @@ Generate an actionable task list using the `/speckit.tasks` command:
### Step 7: Validate and Implement
Have your AI agent audit the implementation plan using `/speckit.analyze`:
Have your coding agent audit the implementation plan using `/speckit.analyze`:
```bash
/speckit.analyze
@@ -180,7 +180,7 @@ Finally, implement the solution:
- **Don't focus on tech stack** during specification phase
- **Iterate and refine** your specifications before implementation
- **Validate** the plan before coding begins
- **Let the AI agent handle** the implementation details
- **Let the coding agent handle** the implementation details
## Next Steps

View File

@@ -10,7 +10,7 @@
|----------------|---------|-------------|
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
| **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project |
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
---
@@ -32,7 +32,7 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki
Specify the desired release tag:
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
```
### If you installed with `pipx`
@@ -82,7 +82,7 @@ The `specs/` directory is completely excluded from template packages and will ne
Run this inside your project directory:
```bash
specify init --here --force --ai <your-agent>
specify init --here --force --integration <your-agent>
```
Replace `<your-agent>` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md)
@@ -90,7 +90,7 @@ Replace `<your-agent>` with your AI coding agent. Refer to this list of [Support
**Example:**
```bash
specify init --here --force --ai copilot
specify init --here --force --integration copilot
```
### Understanding the `--force` flag
@@ -124,7 +124,7 @@ Without `--force`, shared infrastructure files that already exist are skipped
cp .specify/memory/constitution.md .specify/memory/constitution-backup.md
# 2. Run the upgrade
specify init --here --force --ai copilot
specify init --here --force --integration copilot
# 3. Restore your customized constitution
mv .specify/memory/constitution-backup.md .specify/memory/constitution.md
@@ -182,7 +182,7 @@ Restart your IDE to refresh the command list.
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
# Update project files to get new commands
specify init --here --force --ai copilot
specify init --here --force --integration copilot
# Restore your constitution if customized
git restore .specify/memory/constitution.md
@@ -199,7 +199,7 @@ cp -r .specify/templates /tmp/templates-backup
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
# 3. Update project
specify init --here --force --ai copilot
specify init --here --force --integration copilot
# 4. Restore customizations
mv /tmp/constitution-backup.md .specify/memory/constitution.md
@@ -232,7 +232,7 @@ If you initialized your project with `--no-git`, you can still upgrade:
cp .specify/memory/constitution.md /tmp/constitution-backup.md
# Run upgrade
specify init --here --force --ai copilot --no-git
specify init --here --force --integration copilot --no-git
# Restore customizations
mv /tmp/constitution-backup.md .specify/memory/constitution.md
@@ -253,13 +253,13 @@ The `--no-git` flag tells Spec Kit to **skip git repository initialization**. Th
**During initial setup:**
```bash
specify init my-project --ai copilot --no-git
specify init my-project --integration copilot --no-git
```
**During upgrade:**
```bash
specify init --here --force --ai copilot --no-git
specify init --here --force --integration copilot --no-git
```
### What `--no-git` does NOT do
@@ -367,7 +367,7 @@ Only Spec Kit infrastructure files:
- **Use `--force` flag** - Skip this confirmation entirely:
```bash
specify init --here --force --ai copilot
specify init --here --force --integration copilot
```
**When you see this warning:**

View File

@@ -153,7 +153,7 @@ This will:
2. Validate the manifest
3. Check compatibility with your spec-kit version
4. Install to `.specify/extensions/jira/`
5. Register commands with your AI agent
5. Register commands with your coding agent
6. Create config template
### Install from URL
@@ -189,7 +189,7 @@ Provided commands:
### Automatic Agent Skill Registration
If your project was initialized with `--ai-skills`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification.
If your project uses a skills-based integration (e.g., `--integration claude`, `--integration codex`) or was initialized with `--integration-options="--skills"`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification.
```text
✓ Extension installed successfully!
@@ -208,7 +208,7 @@ When an extension is removed, its corresponding skills are also cleaned up autom
### Using Extension Commands
Extensions add commands that appear in your AI agent (Claude Code):
Extensions add commands that appear in your coding agent (Claude Code):
```text
# In Claude Code
@@ -423,7 +423,7 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`),
| Variable | Description | Default |
|----------|-------------|---------|
| `SPECKIT_CATALOG_URL` | Override the full catalog stack with a single URL (backward compat) | Built-in default stack |
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None |
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or extension ZIPs are hosted in a private GitHub repository. | None |
#### Example: Using a custom catalog for testing
@@ -435,6 +435,21 @@ export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json"
export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json"
```
#### Example: Using a private GitHub-hosted catalog
```bash
# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI)
export GITHUB_TOKEN=$(gh auth token)
# Search a private catalog added via `specify extension catalog add`
specify extension search jira
# Install from a private catalog
specify extension add jira-sync
```
The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials.
---
## Extension Catalogs
@@ -780,12 +795,12 @@ specify extension add --dev /path/to/extension
### Command Not Available
**Issue**: Extension command not appearing in AI agent
**Issue**: Extension command not appearing in coding agent
**Solutions**:
1. Check extension is enabled: `specify extension list`
2. Restart AI agent (Claude Code)
2. Restart coding agent (Claude Code)
3. Check command file exists:
```bash
@@ -819,8 +834,8 @@ specify extension add --dev /path/to/extension
**Solutions**:
1. Check MCP server is installed
2. Check AI agent MCP configuration
3. Restart AI agent
2. Check coding agent MCP configuration
3. Restart coding agent
4. Check extension requirements: `specify extension info jira`
### Permission Denied

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-24T00:00:00Z",
"updated_at": "2026-04-28T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -657,18 +657,18 @@
"id": "extensify",
"description": "Create and validate extensions and extension catalogs.",
"author": "mnriem",
"version": "1.0.0",
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.0.0/extensify.zip",
"version": "1.1.0",
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.1.0/extensify.zip",
"repository": "https://github.com/mnriem/spec-kit-extensions",
"homepage": "https://github.com/mnriem/spec-kit-extensions",
"documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/README.md",
"changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.2.0"
"speckit_version": ">=0.8.0"
},
"provides": {
"commands": 4,
"commands": 5,
"hooks": 0
},
"tags": [
@@ -681,7 +681,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
"updated_at": "2026-04-23T00:00:00Z"
},
"fix-findings": {
"name": "Fix Findings",
@@ -941,6 +941,44 @@
"created_at": "2026-03-17T00:00:00Z",
"updated_at": "2026-03-17T00:00:00Z"
},
"m365": {
"name": "Microsoft 365 Integration",
"id": "m365",
"description": "Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation.",
"author": "BenBtg",
"version": "1.0.0",
"download_url": "https://github.com/BenBtg/spec-kit-m365/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/BenBtg/spec-kit-m365",
"homepage": "https://github.com/BenBtg/spec-kit-m365",
"documentation": "https://github.com/BenBtg/spec-kit-m365/blob/main/README.md",
"changelog": "https://github.com/BenBtg/spec-kit-m365/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "m365",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"microsoft-365",
"teams",
"transcripts",
"collaboration",
"summarization"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-28T00:00:00Z"
},
"maqa": {
"name": "MAQA — Multi-Agent & Quality Assurance",
"id": "maqa",
@@ -1167,6 +1205,45 @@
"created_at": "2026-03-26T00:00:00Z",
"updated_at": "2026-03-26T00:00:00Z"
},
"markitdown": {
"name": "MarkItDown Document Converter",
"id": "markitdown",
"description": "Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material in Spec Kit workflows.",
"author": "BenBtg",
"version": "1.0.0",
"download_url": "https://github.com/BenBtg/spec-kit-markitdown/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/BenBtg/spec-kit-markitdown",
"homepage": "https://github.com/BenBtg/spec-kit-markitdown",
"documentation": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/README.md",
"changelog": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "markitdown",
"version": ">=0.1.0",
"required": true
}
]
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"markdown",
"pdf",
"document-conversion",
"reference-material",
"extraction"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-28T00:00:00Z"
},
"memory-loader": {
"name": "Memory Loader",
"id": "memory-loader",
@@ -1327,6 +1404,38 @@
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z"
},
"orchestrator": {
"name": "Spec Orchestrator",
"id": "orchestrator",
"description": "Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-orchestrator",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-orchestrator",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/releases",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 4,
"hooks": 0
},
"tags": [
"orchestration",
"multi-feature",
"coordination",
"workflow",
"parallel"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-24T14:00:00Z",
"updated_at": "2026-04-24T14:00:00Z"
},
"plan-review-gate": {
"name": "Plan Review Gate",
"id": "plan-review-gate",

View File

@@ -123,9 +123,25 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset
## Environment Variables
| Variable | Description |
|----------|-------------|
| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
| Variable | Description | Default |
|----------|-------------|---------|
| `SPECKIT_PRESET_CATALOG_URL` | Override the full catalog stack with a single URL (replaces all defaults) | Built-in default stack |
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or preset ZIPs are hosted in a private GitHub repository. | None |
#### Example: Using a private GitHub-hosted catalog
```bash
# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI)
export GITHUB_TOKEN=$(gh auth token)
# Search a private catalog added via `specify preset catalog add`
specify preset search my-template
# Install from a private catalog
specify preset add my-template
```
The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials.
## Configuration Files

View File

@@ -108,11 +108,11 @@
"fiction-book-writing": {
"name": "Fiction Book Writing",
"id": "fiction-book-writing",
"version": "1.6.0",
"description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported.",
"version": "1.7.0",
"description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.6.0.zip",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.7.0.zip",
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
"license": "MIT",
@@ -122,7 +122,7 @@
"provides": {
"templates": 22,
"commands": 27,
"scripts": 1
"scripts": 2
},
"tags": [
"writing",
@@ -140,7 +140,7 @@
"language-support"
],
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-04-19T08:00:00Z"
"updated_at": "2026-04-27T08:00:00Z"
},
"jira": {
"name": "Jira Issue Tracking",

View File

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

View File

@@ -127,7 +127,7 @@ def _build_ai_deprecation_warning(
ai_commands_dir=ai_commands_dir,
)
return (
"[bold]--ai[/bold] is deprecated and will no longer be available in version 1.0.0 or later.\n\n"
"[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n"
f"Use [bold]{replacement}[/bold] instead."
)
@@ -961,13 +961,106 @@ SKILL_DESCRIPTIONS = {
}
def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_version: str) -> str:
"""Install a single extension during ``specify init``.
Handles bundled extension names, local directory paths, and HTTPS URLs.
Returns a short status message on success.
Raises ``ValueError`` on failure so the caller can convert it to a
tracker error without aborting the entire init.
"""
from urllib.parse import urlparse
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
manager = ExtensionManager(project_path)
# --- URL ---
parsed = urlparse(ext_spec)
if parsed.scheme in ("http", "https"):
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ValueError("URL must use HTTPS (HTTP is only allowed for localhost)")
import urllib.request
import urllib.error as _urllib_error
download_dir = project_path / ".specify" / "extensions" / ".cache" / "downloads"
download_dir.mkdir(parents=True, exist_ok=True)
import re as _re
safe_name = _re.sub(r"[^a-z0-9-]", "-", (parsed.path.split("/")[-1] or "download").lower())[:64]
zip_path = download_dir / f"{safe_name}-init-download.zip"
try:
with urllib.request.urlopen(ext_spec, timeout=60) as _resp:
zip_path.write_bytes(_resp.read())
manifest = manager.install_from_zip(zip_path, speckit_version)
except _urllib_error.URLError as exc:
raise ValueError(f"Failed to download from {ext_spec}: {exc}") from exc
finally:
zip_path.unlink(missing_ok=True)
return f"{manifest.name} v{manifest.version} installed"
# --- Local path ---
if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute():
source_path = Path(ext_spec).expanduser().resolve()
if not source_path.exists():
raise ValueError(f"Directory not found: {source_path}")
if not (source_path / "extension.yml").exists():
raise ValueError(f"No extension.yml found in {source_path}")
manifest = manager.install_from_directory(source_path, speckit_version)
return f"{manifest.name} v{manifest.version} installed"
# --- Bundled extension name or catalog ID ---
bundled_path = _locate_bundled_extension(ext_spec)
if bundled_path is not None:
if manager.registry.is_installed(ext_spec):
return "already installed"
manifest = manager.install_from_directory(bundled_path, speckit_version)
return f"{manifest.name} v{manifest.version} installed"
# Fall back to catalog
catalog = ExtensionCatalog(project_path)
ext_info, catalog_error = _resolve_catalog_extension(ext_spec, catalog, "add")
if catalog_error:
raise ValueError(f"Could not query extension catalog: {catalog_error}")
if not ext_info:
raise ValueError(f"Extension '{ext_spec}' not found in bundled extensions or catalog")
resolved_id = ext_info["id"]
if resolved_id != ext_spec:
bundled_path = _locate_bundled_extension(resolved_id)
if bundled_path is not None:
if manager.registry.is_installed(resolved_id):
return "already installed"
manifest = manager.install_from_directory(bundled_path, speckit_version)
return f"{manifest.name} v{manifest.version} installed"
if ext_info.get("bundled") and not ext_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
raise ValueError(
f"Extension '{resolved_id}' is bundled with spec-kit but not found in the installed package. "
f"Try reinstalling spec-kit: {REINSTALL_COMMAND}"
)
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")
raise ValueError(
f"Extension '{ext_spec}' is in the '{catalog_name}' catalog but installation is not allowed from that catalog"
)
zip_path = catalog.download_extension(resolved_id)
try:
manifest = manager.install_from_zip(zip_path, speckit_version)
finally:
zip_path.unlink(missing_ok=True)
return f"{manifest.name} v{manifest.version} installed"
@app.command()
def init(
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
@@ -980,6 +1073,7 @@ def init(
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
extensions: list[str] | None = typer.Option(None, "--extension", help="Install an extension during initialization (bundled name, local path, or HTTPS URL). Repeatable."),
):
"""
Initialize a new Specify project.
@@ -997,29 +1091,32 @@ def init(
This command will:
1. Check that required tools are installed (git is optional)
2. Let you choose your AI assistant
2. Let you choose your coding agent integration
3. Download template from GitHub (or use bundled assets with --offline)
4. Initialize a fresh git repository (if not --no-git and no existing repo)
5. Optionally set up AI assistant commands
5. Optionally set up coding agent integration commands
Examples:
specify init my-project
specify init my-project --ai claude
specify init my-project --ai copilot --no-git
specify init my-project --integration claude
specify init my-project --integration copilot --no-git
specify init --ignore-agent-tools my-project
specify init . --ai claude # Initialize in current directory
specify init . # Initialize in current directory (interactive AI selection)
specify init --here --ai claude # Alternative syntax for current directory
specify init --here --ai codex --ai-skills
specify init --here --ai codebuddy
specify init --here --ai vibe # Initialize with Mistral Vibe support
specify init . --integration claude # Initialize in current directory
specify init . # Initialize in current directory (interactive integration selection)
specify init --here --integration claude # Alternative syntax for current directory
specify init --here --integration codex --integration-options="--skills"
specify init --here --integration codebuddy
specify init --here --integration vibe # Initialize with Mistral Vibe support
specify init --here
specify init --here --force # Skip confirmation when current directory not empty
specify init my-project --ai claude # Claude installs skills by default
specify init --here --ai gemini --ai-skills
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
specify init my-project --offline # Use bundled assets (no network access)
specify init my-project --ai claude --preset healthcare-compliance # With preset
specify init my-project --integration claude # Claude installs skills by default
specify init --here --integration gemini
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
specify init my-project --integration claude --preset healthcare-compliance # With preset
specify init my-project --integration copilot --extension git # With bundled extension
specify init my-project --extension git --extension selftest # Multiple extensions
specify init my-project --extension ./my-extensions/custom-ext # Local path extension
specify init my-project --extension https://example.com/extensions/my-ext.zip # URL extension
"""
show_banner()
@@ -1029,14 +1126,14 @@ def init(
if ai_assistant and ai_assistant.startswith("--"):
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?")
console.print("[yellow]Example:[/yellow] specify init --ai claude --here")
console.print("[yellow]Example:[/yellow] specify init --integration claude --here")
console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}")
raise typer.Exit(1)
if ai_commands_dir and ai_commands_dir.startswith("--"):
console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'")
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?")
console.print("[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/")
console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"")
raise typer.Exit(1)
if ai_assistant:
@@ -1088,6 +1185,13 @@ def init(
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
)
if no_git:
console.print(
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
"[yellow]The git extension will no longer be enabled by default "
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
)
if project_name == ".":
here = True
project_name = None # Clear project_name to use existing validation logic
@@ -1163,7 +1267,7 @@ def init(
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
selected_ai = select_with_arrows(
ai_choices,
"Choose your AI assistant:",
"Choose your coding agent integration:",
"copilot"
)
@@ -1234,7 +1338,7 @@ def init(
else:
selected_script = default_script
console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}")
console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}")
console.print(f"[cyan]Selected script type:[/cyan] {selected_script}")
tracker = StepTracker("Initialize Specify Project")
@@ -1243,7 +1347,7 @@ def init(
tracker.add("precheck", "Check required tools")
tracker.complete("precheck", "ok")
tracker.add("ai-select", "Select AI assistant")
tracker.add("ai-select", "Select coding agent integration")
tracker.complete("ai-select", f"{selected_ai}")
tracker.add("script-select", "Select script type")
tracker.complete("script-select", selected_script)
@@ -1256,10 +1360,15 @@ def init(
("constitution", "Constitution setup"),
("git", "Install git extension"),
("workflow", "Install bundled workflow"),
("final", "Finalize"),
]:
tracker.add(key, label)
if extensions:
for i, ext_spec in enumerate(extensions):
tracker.add(f"extension-{i}", f"Install extension: {ext_spec}")
tracker.add("final", "Finalize")
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
tracker.attach_refresh(lambda: live.update(tracker.render()))
try:
@@ -1464,6 +1573,18 @@ def init(
except Exception as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
# Install extensions specified via --extension
if extensions:
speckit_ver = get_speckit_version()
for i, ext_spec in enumerate(extensions):
tracker.start(f"extension-{i}")
try:
status_msg = _install_extension_during_init(project_path, ext_spec, speckit_ver)
tracker.complete(f"extension-{i}", status_msg)
except Exception as ext_err:
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
tracker.error(f"extension-{i}", f"failed: {sanitized_ext[:120]}")
tracker.complete("final", "project ready")
except (typer.Exit, SystemExit):
raise
@@ -1558,7 +1679,7 @@ def init(
return f"/speckit-{name}"
return f"/speckit.{name}"
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:")
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
@@ -1629,7 +1750,7 @@ def check():
console.print("[dim]Tip: Install git for repository management[/dim]")
if not any(agent_results.values()):
console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]")
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
@app.command()
def version():
@@ -1875,7 +1996,7 @@ def get_speckit_version() -> str:
integration_app = typer.Typer(
name="integration",
help="Manage AI agent integrations",
help="Manage coding agent integrations",
add_completion=False,
)
app.add_typer(integration_app, name="integration")
@@ -2013,7 +2134,7 @@ def integration_list(
console.print(table)
return
table = Table(title="AI Agent Integrations")
table = Table(title="Coding Agent Integrations")
table.add_column("Key", style="cyan")
table.add_column("Name")
table.add_column("Status")

View File

@@ -0,0 +1,80 @@
"""Shared GitHub-authenticated HTTP helpers.
Used by both ExtensionCatalog and PresetCatalog to attach
GITHUB_TOKEN / GH_TOKEN credentials to requests targeting
GitHub-hosted domains, while preventing token leakage to
third-party hosts on redirects.
"""
import os
import urllib.request
from urllib.parse import urlparse
from typing import Dict
# GitHub-owned hostnames that should receive the Authorization header.
# Includes codeload.github.com because GitHub archive URL downloads
# (e.g. /archive/refs/tags/<tag>.zip) redirect there and require auth
# for private repositories.
GITHUB_HOSTS = frozenset({
"raw.githubusercontent.com",
"github.com",
"api.github.com",
"codeload.github.com",
})
def build_github_request(url: str) -> urllib.request.Request:
"""Build a urllib Request, adding a GitHub auth header when available.
Reads GITHUB_TOKEN or GH_TOKEN from the environment and attaches an
``Authorization: Bearer <value>`` header when the target hostname is one
of the known GitHub-owned domains. Non-GitHub URLs are returned as plain
requests so credentials are never leaked to third-party hosts.
"""
headers: Dict[str, str] = {}
github_token = (os.environ.get("GITHUB_TOKEN") or "").strip()
gh_token = (os.environ.get("GH_TOKEN") or "").strip()
token = github_token or gh_token or None
hostname = (urlparse(url).hostname or "").lower()
if token and hostname in GITHUB_HOSTS:
headers["Authorization"] = f"Bearer {token}"
return urllib.request.Request(url, headers=headers)
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Redirect handler that drops the Authorization header when leaving GitHub.
Prevents token leakage to CDNs or other third-party hosts that GitHub
may redirect to (e.g. S3 for release asset downloads, objects.githubusercontent.com).
Auth is preserved as long as the redirect target remains within GITHUB_HOSTS.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
original_auth = req.get_header("Authorization")
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
hostname = (urlparse(newurl).hostname or "").lower()
if hostname in GITHUB_HOSTS:
if original_auth:
new_req.add_unredirected_header("Authorization", original_auth)
else:
new_req.headers.pop("Authorization", None)
new_req.unredirected_hdrs.pop("Authorization", None)
return new_req
def open_github_url(url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
When the request carries an Authorization header, a custom redirect
handler drops that header if the redirect target is not a GitHub-owned
domain, preventing token leakage to CDNs or other third-party hosts
that GitHub may redirect to (e.g. S3 for release asset downloads).
"""
req = build_github_request(url)
if not req.get_header("Authorization"):
return urllib.request.urlopen(req, timeout=timeout)
opener = urllib.request.build_opener(_StripAuthOnRedirect)
return opener.open(req, timeout=timeout)

View File

@@ -139,12 +139,18 @@ class ExtensionManifest:
def _load_yaml(self, path: Path) -> dict:
"""Load YAML file safely."""
try:
with open(path, 'r') as f:
with open(path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ValidationError(f"Invalid YAML in {path}: {e}")
except FileNotFoundError:
raise ValidationError(f"Manifest not found: {path}")
except UnicodeDecodeError as e:
raise ValidationError(
f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})"
)
except OSError as e:
raise ValidationError(f"Could not read manifest {path}: {e}")
if not isinstance(data, dict):
raise ValidationError(
f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}"
@@ -1539,6 +1545,22 @@ class ExtensionCatalog:
if not parsed.netloc:
raise ValidationError("Catalog URL must be a valid URL with a host.")
def _make_request(self, url: str):
"""Build a urllib Request, adding a GitHub auth header when available.
Delegates to :func:`specify_cli._github_http.build_github_request`.
"""
from specify_cli._github_http import build_github_request
return build_github_request(url)
def _open_url(self, url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
Delegates to :func:`specify_cli._github_http.open_github_url`.
"""
from specify_cli._github_http import open_github_url
return open_github_url(url, timeout)
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
@@ -1695,7 +1717,6 @@ class ExtensionCatalog:
Raises:
ExtensionError: If catalog cannot be fetched or has invalid format
"""
import urllib.request
import urllib.error
# Determine cache file paths (backward compat for default catalog)
@@ -1729,7 +1750,7 @@ class ExtensionCatalog:
# Fetch from network
try:
with urllib.request.urlopen(entry.url, timeout=10) as response:
with self._open_url(entry.url, timeout=10) as response:
catalog_data = json.loads(response.read())
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
@@ -1843,10 +1864,9 @@ class ExtensionCatalog:
catalog_url = self.get_catalog_url()
try:
import urllib.request
import urllib.error
with urllib.request.urlopen(catalog_url, timeout=10) as response:
with self._open_url(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read())
# Validate catalog structure
@@ -1957,7 +1977,6 @@ class ExtensionCatalog:
Raises:
ExtensionError: If extension not found or download fails
"""
import urllib.request
import urllib.error
# Get extension info from catalog
@@ -1997,7 +2016,7 @@ class ExtensionCatalog:
# Download the ZIP file
try:
with urllib.request.urlopen(download_url, timeout=60) as response:
with self._open_url(download_url, timeout=60) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)

View File

@@ -136,12 +136,25 @@ class PresetManifest:
def _load_yaml(self, path: Path) -> dict:
"""Load YAML file safely."""
try:
with open(path, 'r') as f:
return yaml.safe_load(f) or {}
with open(path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise PresetValidationError(f"Invalid YAML in {path}: {e}")
except FileNotFoundError:
raise PresetValidationError(f"Manifest not found: {path}")
except UnicodeDecodeError as e:
raise PresetValidationError(
f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})"
)
except OSError as e:
raise PresetValidationError(f"Could not read manifest {path}: {e}")
if data is None:
return {}
if not isinstance(data, dict):
raise PresetValidationError(
f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}"
)
return data
def _validate(self):
"""Validate manifest structure and required fields."""
@@ -1831,6 +1844,22 @@ class PresetCatalog:
"Catalog URL must be a valid URL with a host."
)
def _make_request(self, url: str):
"""Build a urllib Request, adding a GitHub auth header when available.
Delegates to :func:`specify_cli._github_http.build_github_request`.
"""
from specify_cli._github_http import build_github_request
return build_github_request(url)
def _open_url(self, url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
Delegates to :func:`specify_cli._github_http.open_github_url`.
"""
from specify_cli._github_http import open_github_url
return open_github_url(url, timeout)
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
@@ -2013,10 +2042,7 @@ class PresetCatalog:
pass
try:
import urllib.request
import urllib.error
with urllib.request.urlopen(entry.url, timeout=10) as response:
with self._open_url(entry.url, timeout=10) as response:
catalog_data = json.loads(response.read())
if (
@@ -2109,10 +2135,7 @@ class PresetCatalog:
pass
try:
import urllib.request
import urllib.error
with urllib.request.urlopen(catalog_url, timeout=10) as response:
with self._open_url(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read())
if (
@@ -2231,7 +2254,6 @@ class PresetCatalog:
Raises:
PresetError: If pack not found or download fails
"""
import urllib.request
import urllib.error
pack_info = self.get_pack_info(pack_id)
@@ -2283,7 +2305,7 @@ class PresetCatalog:
zip_path = target_dir / zip_filename
try:
with urllib.request.urlopen(download_url, timeout=60) as response:
with self._open_url(download_url, timeout=60) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)

View File

@@ -112,7 +112,7 @@ class TestInitIntegrationFlag:
assert "--ai" in normalized_output
assert "deprecated" in normalized_output
assert "no longer be available" in normalized_output
assert "1.0.0" in normalized_output
assert "0.10.0" in normalized_output
assert "--integration copilot" in normalized_output
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
@@ -446,6 +446,33 @@ class TestGitExtensionAutoInstall:
ext_dir = project / ".specify" / "extensions" / "git"
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
def test_no_git_emits_deprecation_warning(self, tmp_path):
"""Using --no-git emits a visible deprecation warning."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "no-git-warn"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "--no-git" in normalized_output
assert "deprecated" in normalized_output
assert "0.10.0" in normalized_output
assert "specify extension" in normalized_output
assert "will be removed" in normalized_output
assert "git extension will no longer be enabled by default" in normalized_output
def test_git_extension_commands_registered(self, tmp_path):
"""Git extension commands are registered with the agent during init."""
from typer.testing import CliRunner
@@ -601,3 +628,106 @@ class TestSharedInfraCommandRefs:
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
assert "__SPECKIT_COMMAND_" not in content
class TestExtensionFlag:
"""Tests for the --extension flag on specify init."""
def _run_init(self, tmp_path, args, project_name="ext-test"):
from unittest.mock import patch
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / project_name
project.mkdir(exist_ok=True)
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
# Patch get_speckit_version to return a stable (non-dev) version so that
# the extension compatibility check (SpecifierSet(">=0.2.0")) passes.
with patch("specify_cli.get_speckit_version", return_value="0.8.2"):
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
] + args, catch_exceptions=False)
finally:
os.chdir(old_cwd)
return project, result
def test_bundled_extension_installed(self, tmp_path):
"""--extension git installs the bundled git extension."""
project, result = self._run_init(tmp_path, ["--extension", "git"], project_name="ext-bundled")
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension directory not found"
assert (ext_dir / "extension.yml").exists(), "extension.yml not found"
# Tracker should show extension step as done
normalized = _normalize_cli_output(result.output)
assert "Install extension: git" in normalized
def test_multiple_extensions_installed(self, tmp_path):
"""--extension can be specified multiple times."""
project, result = self._run_init(
tmp_path,
["--extension", "git", "--extension", "selftest"],
project_name="ext-multi",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir_git = project / ".specify" / "extensions" / "git"
ext_dir_selftest = project / ".specify" / "extensions" / "selftest"
assert ext_dir_git.exists(), "git extension not installed"
assert ext_dir_selftest.exists(), "selftest extension not installed"
def test_local_path_extension_installed(self, tmp_path):
"""--extension /abs/path installs from a local absolute directory path."""
from specify_cli import _locate_bundled_extension
# Use the bundled git extension directory as our "local" extension source
bundled_git = _locate_bundled_extension("git")
assert bundled_git is not None, "bundled git extension not found; cannot run test"
# Pass the absolute path directly (starts with "/")
project, result = self._run_init(
tmp_path,
["--extension", str(bundled_git)],
project_name="ext-local",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "extension from local path not installed"
def test_unknown_extension_shows_error_in_tracker(self, tmp_path):
"""An unknown extension name records a tracker error but does not abort init."""
project, result = self._run_init(
tmp_path,
["--extension", "nonexistent-xyz-ext"],
project_name="ext-unknown",
)
assert result.exit_code == 0, "init should not abort on unknown extension"
normalized = _normalize_cli_output(result.output)
assert "failed" in normalized.lower(), "expected 'failed' for unknown extension"
def test_extension_flag_works_with_preset(self, tmp_path):
"""--extension and --preset can be combined."""
project, result = self._run_init(
tmp_path,
["--extension", "git", "--preset", "lean"],
project_name="ext-preset",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension not installed alongside preset"

View File

@@ -225,6 +225,35 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="YAML mapping"):
ExtensionManifest(manifest_path)
def test_utf8_non_ascii_description_loads(self, temp_dir, valid_manifest_data):
"""Regression for #2325: non-ASCII (UTF-8) description loads on any platform.
On Windows, Python's default text-mode encoding is the locale codepage
(e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes
outside the ASCII range. The loader must open with encoding='utf-8'.
"""
import yaml
valid_manifest_data["extension"]["description"] = "中文测试 — émojis 🚀"
manifest_path = temp_dir / "extension.yml"
# Write UTF-8 bytes explicitly so the test exercises the read path,
# not the (locale-dependent) write path.
manifest_path.write_bytes(
yaml.safe_dump(valid_manifest_data, allow_unicode=True).encode("utf-8")
)
manifest = ExtensionManifest(manifest_path)
assert manifest.description == "中文测试 — émojis 🚀"
def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir):
"""Negative case: file containing invalid UTF-8 bytes raises ValidationError, not raw UnicodeDecodeError."""
manifest_path = temp_dir / "extension.yml"
# 0xFF/0xFE are not valid UTF-8 lead bytes.
manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n")
with pytest.raises(ValidationError, match="not valid UTF-8"):
ExtensionManifest(manifest_path)
def test_invalid_extension_id(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid extension ID format."""
import yaml
@@ -2416,6 +2445,215 @@ class TestExtensionCatalog:
assert not catalog.cache_file.exists()
assert not catalog.cache_metadata_file.exists()
# --- _make_request / GitHub auth ---
def _make_catalog(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
return ExtensionCatalog(project_dir)
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
"""Without a token, requests carry no Authorization header."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_only_github_token_ignored(self, temp_dir, monkeypatch):
"""A whitespace-only GITHUB_TOKEN is treated as unset."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, temp_dir, monkeypatch):
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_fallback"
def test_make_request_github_token_added_for_raw_githubusercontent(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_gh_token_fallback(self, temp_dir, monkeypatch):
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo")
assert req.get_header("Authorization") == "Bearer ghp_primary"
def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch):
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://internal.example.com/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch):
"""Auth header is not attached to hosts that include github.com as a suffix."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the URL path."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the query string."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for api.github.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_redirect_preserves_auth_for_github_to_codeload(self):
"""Auth header is preserved when GitHub redirects to codeload.github.com."""
from specify_cli._github_http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect()
original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip"
redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1"
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
fp = io.BytesIO(b"")
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
assert new_req is not None
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
assert auth == "Bearer ghp_test"
def test_redirect_strips_auth_for_github_to_external(self):
"""Auth header is stripped when GitHub redirects to a non-GitHub host."""
from specify_cli._github_http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect()
original_url = "https://github.com/org/repo/releases/download/v1/asset.zip"
redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345"
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
fp = io.BytesIO(b"")
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
assert new_req is not None
auth_header = new_req.headers.get("Authorization")
auth_unredirected = new_req.unredirected_hdrs.get("Authorization")
assert auth_header is None
assert auth_unredirected is None
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
catalog_data = {"schema_version": "1.0", "extensions": {}}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
mock_opener.open.side_effect = fake_open
entry = CatalogEntry(
url="https://raw.githubusercontent.com/org/repo/main/catalog.json",
name="private",
priority=1,
install_allowed=True,
)
with patch("urllib.request.build_opener", return_value=mock_opener):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
"""download_extension passes Authorization header via opener for GitHub URLs."""
from unittest.mock import patch, MagicMock
import zipfile, io
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
# Build a minimal valid ZIP in memory
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
mock_response = MagicMock()
mock_response.read.return_value = zip_bytes
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
mock_opener.open.side_effect = fake_open
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://github.com/org/repo/releases/download/v1/test-ext.zip",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch("urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
# ===== CatalogEntry Tests =====

View File

@@ -160,6 +160,38 @@ class TestPresetManifest:
with pytest.raises(PresetValidationError, match="Invalid YAML"):
PresetManifest(bad_file)
def test_utf8_non_ascii_description_loads(self, temp_dir, valid_pack_data):
"""Regression for #2325: non-ASCII (UTF-8) description loads on any platform.
On Windows, Python's default text-mode encoding is the locale codepage
(e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes
outside the ASCII range. The loader must open with encoding='utf-8'.
"""
valid_pack_data["preset"]["description"] = "中文测试 — émojis 🚀"
manifest_path = temp_dir / "preset.yml"
manifest_path.write_bytes(
yaml.safe_dump(valid_pack_data, allow_unicode=True).encode("utf-8")
)
manifest = PresetManifest(manifest_path)
assert manifest.description == "中文测试 — émojis 🚀"
def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir):
"""Negative case: file containing invalid UTF-8 bytes raises PresetValidationError, not raw UnicodeDecodeError."""
manifest_path = temp_dir / "preset.yml"
manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n")
with pytest.raises(PresetValidationError, match="not valid UTF-8"):
PresetManifest(manifest_path)
def test_non_mapping_yaml_raises_validation_error(self, temp_dir):
"""Manifest whose YAML root is a scalar or list raises PresetValidationError, not TypeError."""
manifest_path = temp_dir / "preset.yml"
for bad_content in ("42\n", "[1, 2]\n"):
manifest_path.write_text(bad_content, encoding="utf-8")
with pytest.raises(PresetValidationError, match="YAML mapping"):
PresetManifest(manifest_path)
def test_missing_schema_version(self, temp_dir, valid_pack_data):
"""Test missing schema_version field."""
del valid_pack_data["schema_version"]
@@ -1363,6 +1395,166 @@ class TestPresetCatalog:
catalog = PresetCatalog(project_dir)
assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json"
# --- _make_request / GitHub auth ---
def test_make_request_no_token_no_auth_header(self, project_dir, monkeypatch):
"""Without a token, requests carry no Authorization header."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_only_github_token_ignored(self, project_dir, monkeypatch):
"""A whitespace-only GITHUB_TOKEN is treated as unset."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, project_dir, monkeypatch):
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_fallback"
def test_make_request_github_token_added_for_github_url(self, project_dir, monkeypatch):
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_gh_token_fallback(self, project_dir, monkeypatch):
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch):
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo")
assert req.get_header("Authorization") == "Bearer ghp_primary"
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch):
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://internal.example.com/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch):
"""Auth header is not attached to hosts that include github.com as a suffix."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_path(self, project_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the URL path."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_query(self, project_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the query string."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/pack.zip")
assert "Authorization" not in req.headers
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
catalog_data = {"schema_version": "1.0", "presets": {}}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
mock_opener.open.side_effect = fake_open
entry = PresetCatalogEntry(
url="https://raw.githubusercontent.com/org/repo/main/presets/catalog.json",
name="private",
priority=1,
install_allowed=True,
)
with patch("urllib.request.build_opener", return_value=mock_opener):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
"""download_pack passes Authorization header via opener for GitHub URLs."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
import io
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
mock_response = MagicMock()
mock_response.read.return_value = zip_bytes
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
mock_opener.open.side_effect = fake_open
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://github.com/org/repo/releases/download/v1/test-pack.zip",
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch("urllib.request.build_opener", return_value=mock_opener):
catalog.download_pack("test-pack", target_dir=project_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
# ===== Integration Tests =====