Commit Graph

46 Commits

Author SHA1 Message Date
Ali jawwad
63a2a17305 fix(extensions): preserve argument-hint in extension Claude SKILL.md (#2916)
Extension-provided commands that declare `argument-hint:` in their
frontmatter had that field dropped from the generated Claude
`.claude/skills/<name>/SKILL.md`, while core template commands keep it.
The extension skill generator built the frontmatter via the shared
build_skill_frontmatter() (name/description/compatibility/metadata only)
and never forwarded argument-hint.

Carry argument-hint from the parsed source command frontmatter into the
skill frontmatter dict before serialization, gated on the integration
exposing inject_argument_hint so only argument-hint-aware agents (Claude)
receive the key and build_skill_frontmatter's shared shape stays unchanged
for every other agent. The value is injected into the dict rather than via
the string-based inject_argument_hint helper, so a folded multi-line
description cannot be split into invalid YAML.

Add regression tests covering a folding description (Claude) and the
non-Claude gate (kimi).

Closes #2903

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:28:39 -05:00
Manfred Riem
40e48ed22c feat: add category and effect as first-class fields in extension schema (#2899)
* feat: add category and effect as first-class fields in extension schema

Add `category` and `effect` as optional fields in the extension schema
(`extension.yml`) and community catalog (`catalog.community.json`).

Schema changes:
- Valid categories: docs, code, process, integration, visibility
- Valid effects: read-only, read-write
- Both fields are optional (backward-compatible with existing extensions)
- Validation raises ValidationError for invalid values when present

Propagation:
- Added `category` and `effect` to all 108 entries in catalog.community.json
  (populated from the existing docs/community/extensions.md table)
- Updated extension template with commented category/effect fields
- Updated add-community-extension skill with new JSON template fields
- Updated `specify extension info` CLI output to display category/effect
- Added properties to ExtensionManifest class

Tests:
- test_valid_category: all 5 category values pass
- test_valid_effect: both effect values pass
- test_invalid_category: invalid value raises ValidationError
- test_invalid_effect: invalid value raises ValidationError
- test_category_and_effect_optional: omitting fields still works

Closes #2874

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

* fix: make category free-form, keep effect validated

Category is a free-form string (only validated as non-empty when present),
while effect remains restricted to 'read-only' or 'read-write'.

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

* fix: address PR review feedback

- Add type guard before 'in' check for effect to prevent TypeError on
  unhashable YAML values (list/dict)
- Comment out category/effect in template so authors must opt in
- Use VALID_EFFECTS constant in test instead of hard-coded values

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

* fix: update category docstring to reflect free-form semantics

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

* docs: clarify canonical extension effect values

---------

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-10 07:44:27 -05:00
Quratulain-bilal
de88c23bb6 fix(catalogs): validate extension and preset catalog payload shape (#2621)
* fix(catalogs): validate extension and preset catalog payload shape

`ExtensionCatalog._fetch_single_catalog` and
`PresetCatalog._fetch_single_catalog` only check that the `extensions` /
`presets` key is *present* in the parsed catalog JSON. They don't check
that the value is a JSON object, and they don't check that the root is
a JSON object at all. A malformed (or compromised) upstream catalog
returning:

    {"schema_version": "1.0", "extensions": []}

passes both `"extensions" not in catalog_data` and the subsequent
`response.read()` JSON parse, gets cached on disk, and then crashes
deep inside `_get_merged_extensions` (resp. `_get_merged_packs`) with:

    AttributeError: 'list' object has no attribute 'items'

instead of the existing user-facing
`ExtensionError("Invalid catalog format from <url>")` /
`PresetError("Invalid preset catalog format")` that the surrounding
code is clearly trying to produce.

The sibling integration-catalog reader already validates this — see
`src/specify_cli/integrations/catalog.py` where the fetch path
explicitly checks both `isinstance(catalog_data, dict)` and
`isinstance(catalog_data.get("integrations"), dict)` before returning.
This change mirrors that pattern in the extension and preset readers so
the three catalog fetchers stay consistent and a malformed upstream
surfaces as the user-facing error instead of a raw Python traceback.

Adds parametrized regression tests covering:
- root payload is not a JSON object (list, str, int, null)
- root is a dict but `extensions` / `presets` value is the wrong type
  (list, str, null, int)

All eight bad-payload shapes now raise the expected catalog error.

* fix(catalogs): skip non-mapping entries during extension and preset merge

Addresses Copilot review feedback on this PR.

`_fetch_single_catalog` now validates that the ``extensions`` / ``presets``
value is a mapping, but it doesn't (and shouldn't) validate every entry
inside that mapping. A payload like:

    {"schema_version": "1.0", "extensions": {"good": {...}, "bad": []}}

passes the fetch-level guard, then later crashes inside
``_get_merged_extensions`` (resp. ``_get_merged_packs``) at
``{**ext_data, ...}`` with ``TypeError: 'list' object is not a mapping``.

The sibling integration-catalog reader at
``src/specify_cli/integrations/catalog.py:245`` handles this with a
per-entry ``isinstance(integ_data, dict)`` skip during merge, so one
malformed entry doesn't poison an otherwise valid catalog. This change
mirrors that pattern in the extension and preset mergers and adds
regression tests asserting that valid entries continue to merge while
malformed siblings are silently dropped.

* fix(catalogs): validate cached extension and preset payload shape

Addresses Copilot review feedback on this PR (round 2).

The earlier commits in this branch added payload-shape validation on the
network fetch path. The cache-hit path still returned
``json.loads(cache_file.read_text())`` directly without re-checking the
shape, so a cache poisoned by an older spec-kit version (or a manual
edit, or an upstream that briefly served a bad payload before the
network guards landed) would re-crash every invocation of
``_get_merged_extensions`` / ``_get_merged_packs`` with
``AttributeError: 'list' object has no attribute 'items'`` despite the
cache being "valid" by age.

Extracts the shape validation into ``_validate_catalog_payload`` on both
``ExtensionCatalog`` and ``PresetCatalog``, and calls it from both the
cache-load and network-fetch branches of ``_fetch_single_catalog``. If
the cached payload fails validation, the cache read is treated like a
``json.JSONDecodeError`` — the cached value is discarded and the
function falls through to the network fetch, which refreshes the cache
with a clean payload on success. Never propagates ``AttributeError`` to
the caller.

Regression tests parametrize the four root-bad-type variants plus three
``extensions``/``presets``-bad-type variants per file, asserting that a
poisoned cache silently recovers via network refetch and returns the
freshly-fetched payload.

* fix(catalogs): include URL in missing-keys error to match sibling branches

Addresses Copilot review feedback on this PR (round 3).

``_validate_catalog_payload`` advertises in its docstring that the
catalog URL is included in error messages "so the user can tell which
catalog in a multi-catalog stack is malformed" — but the missing-keys
branch raised ``PresetError("Invalid preset catalog format")`` without
the URL, breaking that contract and making multi-catalog debugging
harder. The root-bad-type and nested-bad-type branches in the same
helper already include the URL; this commit brings the middle branch
in line.

For consistency, the same fix is applied to the legacy single-catalog
fetch paths in ``ExtensionCatalog.fetch_catalog`` and
``PresetCatalog.fetch_catalog`` (where the URL was likewise dropped
from the missing-keys error).

The existing regex matchers in the regression tests target the
``"Invalid (preset )?catalog format"`` prefix, which is preserved
verbatim before the ``from <url>`` suffix — no test changes needed.

* fix(catalogs): broaden cache except tuples and reuse validator in fetch_catalog

Addresses Copilot review feedback on this PR (round 4):

1. ``ExtensionCatalog.fetch_catalog`` and ``PresetCatalog.fetch_catalog``
   — the legacy single-catalog methods — still only checked key
   presence. A payload like ``42`` (root non-object) crashed with
   ``TypeError: argument of type 'int' is not iterable`` during the
   ``"schema_version" in catalog_data`` check, and an entry mapping of
   the wrong type crashed downstream. Both now reuse
   ``_validate_catalog_payload`` so the network-side behaviour of the
   legacy methods stays consistent with the multi-catalog
   ``_fetch_single_catalog`` path. (Copilot #3335623482, #3335623556.)

2. The cache-read ``except`` tuples in ``_fetch_single_catalog`` and
   ``fetch_catalog`` were too narrow. ``read_text`` can raise
   ``OSError`` (permissions / disk / handle limit) or ``UnicodeError``
   (cache file written by an older client in a different encoding)
   in addition to ``json.JSONDecodeError``. Without those in the
   tuple, an unreadable cache crashed the caller instead of falling
   through to the network refetch the cache contract documents. Both
   sites now catch ``(json.JSONDecodeError, OSError, UnicodeError,
   <DomainError>)``. (Copilot #3335623588, #3335623608.)

3. While here, pinned ``encoding="utf-8"`` on every cache ``read_text``
   call so cache files written by an older Windows client (with a
   non-UTF-8 default locale) decode the same way on a newer client.

Regression tests:

  - ``test_fetch_catalog_rejects_malformed_payload`` — 7 parametrized
    payloads per file covering root-non-object + nested-bad-type
    variants asserting ``fetch_catalog`` raises the named domain error.
  - ``test_fetch_catalog_recovers_from_unreadable_cache`` — writes
    ``b"\xff\xfe\x00not-utf-8"`` to the cache file and asserts
    ``fetch_catalog`` silently falls through to the mocked network and
    returns the freshly-fetched payload.

* fix(catalogs): harden cache-validity checks and pin UTF-8 on writes

The cache-best-effort contract added in 7f44b25 was incomplete on two
points raised by Copilot:

1. The cache-validity helpers (is_cache_valid /
   _is_url_cache_valid, plus the inline metadata-age check inside
   _fetch_single_catalog for per-URL caches) read the metadata file
   without specifying an encoding and only caught
   json.JSONDecodeError / ValueError / KeyError /
   TypeError. A metadata file written by a tool using the system
   locale codec, or one whose handle is briefly unavailable, would
   raise UnicodeDecodeError / OSError and propagate past the
   read-side try/except in fetch_catalog — the very crash the
   read-side guard was meant to prevent. The validity checks now read
   with encoding="utf-8" and treat OSError / UnicodeError
   as cache-invalid, matching the documented contract.

2. The network-fetch path wrote the cache and metadata files with bare
   write_text(...), picking up the platform default encoding. The
   read path was already pinned to UTF-8 (and the
   integrations/catalog.py:193-203 sibling writes UTF-8 too), so
   on hosts whose default codec isn't UTF-8 the write/read pair could
   disagree and force an unnecessary refetch on every invocation. All
   four write_text calls now pass encoding="utf-8" so the
   cache survives a round trip on any platform.

Also rewords the misleading # Fetch from network comment in
extensions.fetch_catalog — it sat above the cache-check block,
which read as if the cache step had been skipped.

Tests
-----

Adds two parametrized regression tests per catalog:

* test_fetch_catalog_recovers_from_unreadable_metadata plants
  non-UTF-8 bytes in the metadata file, asserts is_cache_valid()
  returns False (rather than raising), and confirms
  fetch_catalog falls through to the network instead of crashing.

* test_fetch_catalog_writes_cache_as_utf8 round-trips a payload
  containing a non-ASCII identifier (café) through the public
  fetch path and reads the cache back with
  read_text(encoding="utf-8"), catching encoding drift at the
  byte level rather than relying on the system codec to happen to be
  UTF-8.

Both pairs follow the established sibling-file symmetry — the
extension and preset suites stay in lock-step.

* test(catalogs): assert UTF-8 write encoding by recording write_text kwargs

Copilot's review on this PR caught that test_fetch_catalog_writes_cache_as_utf8
claimed to validate UTF-8 at the byte level but actually only round-tripped a
non-ASCII string through json.dumps/read_text. Because json.dumps defaults to
ensure_ascii=True, 'café' was serialized as the all-ASCII escape 'caf\u00e9'
before reaching write_text — the bytes on disk were identical regardless of the
encoding kwarg, so a locale-encoded write would have round-tripped just fine.
The drift guard the test name advertised was not actually being enforced.

Rewriting these tests to observe the production code's argument directly:
each test now monkey-patches pathlib.Path.write_text with a recorder that
captures the encoding kwarg for every call, runs the production fetch, and
asserts every write into the cache directory passed encoding='utf-8'. That is
the substantive thing the regression guard cares about — non-ASCII payload
tricks were the wrong lever to pull, because json.dumps was masking the
encoding choice before write_text ever ran.

Both tests verified locally against the current production code (492 passed in
the extensions+presets suites) and confirmed to fail against a synthetic
no-encoding write (the recorder records None instead of 'utf-8', the assertion
catches it). Same change applied symmetrically to test_extensions.py and
test_presets.py to keep the sibling files in lockstep with the production
code paths in extensions.py and presets.py.

* fix(catalogs): catch AttributeError on non-mapping cache metadata; drop stale line refs

Copilot's review on the previous push pointed out that the
cache-validity helpers still had a gap: metadata.get("cached_at", "")
assumes metadata is a dict, but json.loads happily parses a
file containing [] / "oops" / 42 / true / null into
a non-mapping. The except tuple covered json.JSONDecodeError,
OSError, UnicodeError, ValueError, KeyError and
TypeError but not AttributeError, so a valid-JSON-but-non-dict
metadata payload would still crash the caller instead of degrading to
"cache invalid" as the docstring promised.

This affected four cache-validity sites — symmetric across the two
catalog modules:

* extensions.py — inline per-URL metadata-age check in
  _fetch_single_catalog
* extensions.py — is_cache_valid (legacy default-URL path)
* presets.py — _is_url_cache_valid
* presets.py — is_cache_valid

All four except tuples now include AttributeError with a comment
naming the exact failure (metadata.get(...) on a non-mapping) so
the next reader doesn't have to reconstruct the reasoning.

Separately, Copilot flagged that several comments hard-coded a line
range from a sibling file
(integrations/catalog.py:193-203) — those references will go stale
the moment that file changes. Replaced the hard-coded ranges with
file-only references (integrations/catalog.py) so the pointer
stays accurate as that file evolves. Same change applied to both
modules.

Tests
-----

test_is_cache_valid_handles_non_mapping_metadata is added to both
test_extensions.py and test_presets.py, parametrized over the
five JSON non-mapping root types ([], "oops", 42,
true, null). Each variant plants the metadata file with that
exact content and asserts is_cache_valid() returns False
without raising. The parametrize covers every JSON type the public
spec allows at the root, so a regression that drops AttributeError
from any except tuple is caught against every observable shape rather
than relying on the next reviewer to remember the .get /
non-mapping interaction.

pytest tests/test_extensions.py tests/test_presets.py — 502
passed (was 492 before; the parametrize adds five vectors per file).

* fix(catalogs): make cache writes best-effort to match read-side contract
2026-06-09 07:22:49 -05:00
Seiya Kojima
4ec4635dd1 feat(extensions): per-event hook lists with priority ordering (#2798)
* feat(extensions): per-event hook lists with priority ordering

The manifest validator restricted each hook event to a single mapping,
even though HookExecutor stores entries as a list per event. This blocked
an extension from running multiple commands on one event (e.g. a
verification step plus a doc-generation step after speckit.plan), and
get_hooks_for_event returned entries in raw insertion order with no way
to influence execution order across or within extensions.

This change:

1. Validator: accept hooks.<event> as either a single mapping or a list
   of mappings. Each entry is validated individually and may carry an
   optional integer `priority` (>= 1, default 10; bool rejected).
2. Command-ref normalization: apply rename / alias->canonical rewriting
   to every entry in the list, not just the head.
3. register_hooks: expand list entries, persist `priority`, and
   purge-and-replace all entries owned by the extension on each event so a
   reinstall whose shape changed (single<->list, or a shorter list) leaves
   no orphaned entries behind.
4. get_hooks_for_event: sort enabled entries by `priority` ascending with
   a stable sort (ties keep insertion order). The existing
   normalize_priority helper is reused as the sort key so corrupted
   on-disk values fall back to the default instead of raising.

Backward compatible: existing single-mapping manifests parse and register
unchanged with priority defaulting to 10. The extension-level `priority`
used by preset/template resolution is independent of the new hook-entry
`priority`.

Implements #2378

* fix(extensions): harden register_hooks per PR review

- Skip non-dict hook entries before .get() so a manifest that bypasses
  validation can't crash register_hooks with AttributeError.
- Normalize `priority` on save via normalize_priority so the on-disk
  config stays clean, mirroring the read-side defense in
  get_hooks_for_event.
- Tests: cover the non-dict-entry skip and add encoding="utf-8" to the
  new tests' manifest writes.

* fix(extensions): purge dropped-event hook orphans on reinstall

register_hooks only purged events the new manifest still declared, so an
extension that dropped an event on reinstall left stale entries for it in
the project config. Purge this extension's entries from undeclared events
(and prune emptied events) before registering; scoped to this extension,
and a no-op for the install/update flow where unregister_hooks runs first.

* fix(extensions): reject boolean priority and complete orphan purge

- normalize_priority falls back to default for bool values
- dedup deletes duplicate commands before re-insert for last-wins ties
- register_hooks purges orphans even when all hooks are dropped

* docs(extensions): document per-event hook lists and priority

- EXTENSION-API-REFERENCE: hook event accepts a mapping or list; add
  priority field reference and last-wins dedup note
- EXTENSION-DEVELOPMENT-GUIDE: add list-form example with priority

* docs(extensions): show both single and list hook forms in schema snippet

* docs(extensions): reference DEFAULT_HOOK_PRIORITY in normalize_priority

normalize_priority hard-coded the default as the literal 10 in both its
signature and docstring, duplicating DEFAULT_HOOK_PRIORITY. Reference the
constant in the signature and drop the literal from the docstring so the
default has a single source of truth.
2026-06-08 08:03:46 -05:00
Copilot
7106858c4e feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags (0.10.0) (#2872)
* Initial plan

* feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags at 0.10.0

* refactor(tests): rename stale test_ai_help_* methods to test_agent_config_*

* fix: address review — derive agent folder for generic integration and remove redundant test

- Security notice now falls back to integration_parsed_options['commands_dir']
  when AGENT_CONFIG folder is None (generic integration).
- Remove test_agent_config_includes_kiro_cli which duplicates the assertion
  in test_runtime_config_uses_kiro_cli_and_removes_q.

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

* docs: scrub all remaining --ai flag references from source and tests

- Remove dead AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, and
  _build_ai_assistant_help() from _agent_config.py
- Update comments/docstrings in extensions.py, presets.py, and
  integration subpackages to reference 'skills mode' or
  '--integration' instead of the removed flags
- Fix catalog.json generic integration description
- Update test docstrings/comments in test_extension_skills.py,
  test_extensions.py, and test_presets.py

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

* test: remove legacy --ai flag rejection tests

The flags are fully removed from the CLI; typer handles unknown options
generically. No custom rejection logic exists to test.

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

* revert: remove manual CHANGELOG.md entry

CHANGELOG is generated automatically; manual edits should not be made.

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

* fix: make generic catalog description self-explanatory

Include the required --commands-dir sub-option in the description so
readers don't need to look up integration docs.

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

* fix(tests): rename duplicate test classes to avoid shadowing

The rename from Test*AutoPromote to Test*Integration collided with the
existing Test*Integration(SkillsIntegrationTests) base classes, causing
the shared test suites to be silently overwritten. Rename the CLI init
flow classes to Test*InitFlow instead.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 14:56:28 -05:00
lselvar
f512b8b0d1 fix: resolve GitHub release asset API URL for private repo preset and workflow downloads (#2855)
* fix: resolve GitHub release asset API URL for private repo preset and workflow downloads

- Add shared `resolve_github_release_asset_api_url` utility to `_github_http.py` for
  reuse across preset and workflow download paths
- Apply the same private-repo fix from PR #2792 (extensions) to:
  - `PresetCatalog.download_pack` — ZIP downloads via catalog `download_url`
  - `preset add --from <url>` — ZIP downloads from a direct URL
  - `workflow add <url>` — workflow YAML downloads from a direct URL
  - `workflow add <id>` (catalog) — workflow YAML downloads via catalog `url`
- For browser release URLs (`github.com/…/releases/download/…`), the asset is
  resolved via the GitHub REST API and downloaded with `Accept: application/octet-stream`
- Direct REST API asset URLs (`api.github.com/…/releases/assets/<id>`) are
  downloaded directly with `Accept: application/octet-stream`
- Auth is preserved end-to-end through the existing `open_url` infrastructure
- Update `test_download_pack_sends_auth_header` and add
  `test_download_pack_accepts_direct_github_rest_asset_url` to cover both paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: URL-encode tag in release API URL to handle special characters

Encode the tag as a path segment (using quote with safe='') when
building the releases/tags/<tag> API URL. This prevents malformed
URLs when tags contain reserved characters like '/' or '#'.

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

* test: add CLI-level tests for preset add --from GitHub release URL resolution

Adds regression tests covering:
- resolve_github_release_asset_api_url unit tests (passthrough, resolution,
  network error, URL encoding of special chars in tags)
- CLI-level 'preset add --from <github-release-url>' end-to-end flow
- CLI-level 'preset add --from <api-asset-url>' direct passthrough

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

* refactor: deduplicate release URL resolution; fix test issues

- ExtensionCatalog._resolve_github_release_asset_api_url now delegates
  to the shared helper in _github_http.py (also gains URL-encoding fix)
- Remove unused 'io' import from test_github_http.py
- Remove duplicate 'provides' dict keys accidentally added to test_presets.py

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

* fix: align resolver timeout with download timeout; add workflow CLI tests

- Pass timeout=30 to resolve_github_release_asset_api_url in both
  workflow add paths so worst-case latency matches the download timeout
- Add CLI-level regression tests for 'workflow add <url>' covering
  browser URL resolution and direct API asset URL passthrough

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

* fix: remove unused urllib.request import; add catalog workflow test

- Remove unused 'import urllib.request' in preset add --from path
- Add CLI test for catalog-based 'workflow add <id>' with GitHub
  release URL resolution

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

* style: remove unused MagicMock imports from tests

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 10:41:40 -05:00
minbang
a9a759450d fix: recover active skills registration for extensions (#2803)
Extension command registration now resolves the active skills directory before writing command artifacts. This lets initialized skills-backed agents recover a missing active skills directory while preserving the existing preset registration behavior.

Add regression coverage for missing active skills directories, shared skills directories, and symlinked parent guards.

Fixes #2769.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-06-04 09:53:31 -05:00
Grissiom.GuRui
659a41a6cc feat(extensions): add --force flag to extension add for overwrite reinstall (#2530)
* feat(extensions): add --force flag to extension add for overwrite reinstall

Add --force support to `specify extension add` that allows overwriting
an already-installed extension without manually removing it first.

- install_from_directory() and install_from_zip() accept force=True,
  automatically calling remove() before installation
- The --force CLI flag works with all install modes (--dev, --from URL,
  bundled, and catalog)
- Config files (*-config.yml) are preserved across force reinstall
- Error message suggests --force when extension is already installed
- 6 new tests covering unit and CLI force reinstall flows

* fix: address PR review feedback on --force implementation

- Remove unused `backup_config_dir` variable assignment (Ruff F841)
- Defer `remove()` until after `_validate_install_conflicts()` to prevent
  data loss if validation fails mid-reinstall
- Use `TemporaryDirectory` instead of `NamedTemporaryFile` in ZIP test
  to avoid Windows file-locking failures

* fix: only restore config backup when --force actually triggers a remove

When --force is used but the extension is not already installed, the
backup restore/cleanup should not run. Previously it could resurrect
stale config files from a previous removal and delete the backup
directory unnecessarily.

* fix: address Copilot review feedback on --force implementation

- Clear stale backup dir before remove() so only fresh backups are restored
- Restore only config files (*-config.yml, *-config.local.yml) from backup
- Remove trailing \n from --force console message (console.print adds newline)

* fix: handle non-directory paths in backup cleanup/restore

- Use is_dir() before rmtree/iterdir on backup path to avoid crashes
  when .backup/<id> exists as a file or symlink
- Remove unused manifest1 variable in test_install_force_reinstall

* fix: handle symlinks in backup cleanup/restore and correct CLI message

- Check is_symlink() before is_dir() in backup cleanup and restore:
  Path.is_dir() follows symlinks (returns True for symlink-to-dir) but
  shutil.rmtree() raises OSError on symlinks. Handle symlinks by
  unlinking them instead.
- Skip symlink entries during config file restore.
- Change --force dev-install message from "Reinstalling" to
  "Installing [...] (will overwrite if already installed)" because
  --force also works for first-time installs.
2026-06-03 16:34:24 -05:00
lselvar
c9c02ae790 fix: resolve GitHub release asset API URL for private repo extension downloads (#2792)
* fix: resolve GitHub release asset API URL for private repo downloads

For private or SSO-protected GitHub repos, browser release download URLs
redirect to HTML/SSO instead of the ZIP asset. This commit resolves the
asset via the GitHub REST API and downloads with Accept: application/octet-stream,
falling back to the original URL if the API call fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: support direct GitHub REST release asset URLs in extension downloads

When a catalog download_url is already a GitHub REST release asset URL
(https://api.github.com/repos/<owner>/<repo>/releases/assets/<id>),
skip the release metadata lookup and download directly with
Accept: application/octet-stream. This complements the browser URL
resolution from the previous commit, covering catalogs that reference
the REST API directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:15:36 -05:00
Quratulain-bilal
442a581358 fix(cli): pin UTF-8 encoding on init-options and .extensionignore I/O (#2686)
* fix(cli): pin UTF-8 encoding on init-options and .extensionignore I/O

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

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

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

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

Regression tests:

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

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

Addresses Copilot review feedback on this PR.

Three issues, three fixes:

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

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

3. ``.extensionignore`` reader wraps ``UnicodeDecodeError`` as
   ``ValidationError`` with a pointer to the offending byte
   (Copilot's third comment). Mirrors
   ``ExtensionManifest._load_yaml``'s existing handler for
   ``extension.yml``. Adds
   ``test_extensionignore_invalid_utf8_raises_validation_error``
   asserting installation aborts with the wrapped error instead of a
   raw Python traceback.
2026-06-02 07:19:11 -05:00
Pedro Barbosa
44aac9f6e4 feat: add native Cline integration (#2508)
* test: strip ansi to make asserts work

* feat: add native Cline integration
2026-06-01 11:20:48 -05:00
NgoQuocViet2001
9307093d8a Fix --dev extension agent symlinks (#2554)
* Fix dev extension agent symlinks

* Address dev symlink review feedback

* fix: handle dev symlink relpath failures

* fix: fall back when dev cache writes fail

* test: cover dev symlink fallback without privileges
2026-05-28 09:29:17 -05:00
Manfred Riem
e54653efcc fix: create skills directory on demand during extension/preset install (#2711)
* fix: create skills directory on demand during extension/preset install

_get_skills_dir() in both extensions.py and presets.py returned None
when the skills directory did not yet exist on disk, even though skills
were enabled in init-options. This caused extension skill registration
to silently produce an empty registered_skills list and skip writing
SKILL.md files.

Replace the is_dir() bail-out with mkdir(parents=True, exist_ok=True)
so the directory is created on demand when ai_skills is enabled.

Update the existing test expectation and add a parametrized regression
test (claude + codex) that installs an extension before the skills
directory exists and asserts SKILL.md files and registry entries are
created.

Fixes #2682

* test: assert skills dir is NOT created when skills are disabled

Strengthen negative tests to verify _get_skills_dir does not create the
directory on disk when ai_skills is false or init-options.json is absent.

* fix: add symlink/containment check and preserve Kimi existence gate

Address PR review feedback:

- Use _ensure_safe_shared_directory() instead of raw mkdir() to prevent
  symlink-following writes outside the project root.
- Restore the is_dir() existence gate for the Kimi native-skills
  fallback (ai_skills=false): only create the directory on demand when
  ai_skills is explicitly enabled.
- Update docstrings to reflect the on-demand vs existence-gate behavior.
- Reuse resolve_skills_dir helper in tests instead of manually
  reconstructing paths from AGENT_CONFIG.

* refactor: extract resolve_active_skills_dir shared helper

Deduplicate the _get_skills_dir logic that was nearly identical in
ExtensionManager and PresetManager into a single module-level
resolve_active_skills_dir() function in __init__.py.

The shared helper wraps _ensure_safe_shared_directory errors with
skills-specific messages so users see 'agent skills directory' instead
of 'shared infrastructure directory' in error output.

Both class methods now delegate to the shared helper.

* fix: preserve original error reason in skills dir safety check

Include the original exception message from _ensure_safe_shared_directory
in the re-raised ValueError so the user sees the specific reason (symlink,
not-a-directory, path escape, etc.) instead of a generic message.

* fix: handle skills dir safety errors gracefully during install

Catch ValueError/OSError from _get_skills_dir() inside
_register_extension_skills() so a symlink or permission error logs a
warning and returns [] instead of aborting mid-install and leaving a
partially-installed extension without a registry entry.

Also document OSError in resolve_active_skills_dir() docstring.

* fix: catch errors in _get_skills_dir and use _print_cli_warning

Move the ValueError/OSError catch from _register_extension_skills into
_get_skills_dir itself so all callers (install, uninstall, reconcile)
are protected from unsafe-path exceptions.

Replace logging.getLogger().warning with _print_cli_warning for
consistent Rich-formatted user output.

* fix: use context-aware error messages for skills directory safety

Add a 'context' parameter to _ensure_safe_shared_directory (defaults to
'shared infrastructure directory' for backward compat). The skills dir
caller passes context='agent skills directory' so error messages say
e.g. 'Refusing to use symlinked agent skills directory' instead of
'Refusing to use symlinked shared infrastructure directory'.

Simplify resolve_active_skills_dir by removing the now-unnecessary
try/except wrapper.

* fix: validate Kimi native-skills directory for symlink/containment

The Kimi fallback path (ai_skills=false) used is_dir() which follows
symlinks, so a symlinked .kimi/skills could cause writes outside the
project root. Now validates with _ensure_safe_shared_directory(create=
False) before returning the directory.
2026-05-26 15:10:59 -05:00
WOLIKIMCHENG
51e6a140e2 refactor: migrate extension catalog stack parsing to shared base (#2576)
Co-authored-by: root <1647273252@qq.com>
2026-05-18 07:02:18 -05:00
Dyan Galih
59fdca5997 fix(cli): harden extension registration and discovery workflows (#2499)
* chore: update community catalog with latest extension versions

- Update memory-md from 0.7.9 to 0.8.0
- Update architecture-guard from 1.6.7 to 1.8.0

* fix(cli): harden extension registration with project-level tracking in extensions.yml

* test(cli): add comprehensive unit tests for extension registration logic

* chore: remove out-of-scope catalog changes

* refactor: address PR feedback for extension registration hardening

* fix: harden extension registration defensive logic and add comprehensive unregister_hooks tests

- Add dict guard to register_hooks() to handle corrupted extensions.yml (non-dict root)
- Add 5 comprehensive tests for unregister_hooks() workflow:
  * Full workflow with hooks + installed list removal
  * Resilience when config has no 'hooks' key
  * Corrupted YAML handling
  * Multiple extension scenarios
  * All 11 tests passing

* fix: sanitize installed to strings, guard unregister_hooks dict, handle null hook values

- register_extension(): filter non-string entries from installed before sort
- register_hooks(): normalize hooks to {} when missing or not a dict
- unregister_hooks(): add isinstance(config, dict) guard before key checks
- unregister_hooks(): coerce null/scalar hook lists to [] before iteration
- tests: add 3 regression tests for no-hooks manifest, mixed-type installed, null hook values
- All 14 tests passing

* fix(cli): persist sanitization results and harden hook registration

* Harden extension registration to always persist sanitization results

* Hardening extension registration: support mapping entries, improve persistence, and fix update rollback

* fix(cli): harden extension update and unregistration workflows

* fix(cli): move update sentinels outside try block to prevent NameError on rollback

* fix(cli): sanitize hook event lists in register_hooks to prevent crashes

* fix(cli): deduplicate hook entries and harden rollback hooks-restore guards

* test(cli): add regression tests for extension update and rollback hardening

* fix(cli): deduplicate installed list by id in register_extension

* fix(cli): consolidate and harden extension update rollback logic

* fix(cli): initialize backup_registry_entry before try block to prevent UnboundLocalError on rollback

* fix(tests): return Path from download_extension mock and add Path import

* fix(cli): normalize get_project_config() return to dict; deduplicate in unregister_extension()

* fix(cli): normalize hooks/installed/settings in get_project_config(); use tmp_path-scoped zip in tests

* fix(cli): set modified=True on hook coercion in rollback; sanitize hook event values in get_project_config(); harden test assertions

* fix(cli): filter non-dict hook entries in get_project_config(); remove dead MISSING sentinel

* fix(cli): gate extensions.yml rollback on backup_hooks is not None; update stale comment

* fix(cli): move _AgentReg import outside try block; assert result.exception is None in tests

* fix(extensions): consistent key order in default config; deep-copy backup_installed

* test: fix misleading comment; assert exit_code==1 in rollback test

* test: clean up duplicate imports in hardening tests

* refactor(extensions): extract _sanitize_installed_list helper; strengthen hook unregister assertion

* fix(extensions): validate extension IDs in _sanitize_installed_list; clarify test comment
2026-05-13 12:02:01 -05:00
Copilot
f0998348be feat: Config-driven opt-in authentication registry with multi-platform support (#2393)
* Initial plan

* feat: add authentication provider registry (GitHub + Azure DevOps)

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/da7ecfd0-e1c9-48dc-b692-27be0879e976

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* feat: add try-each-provider HTTP helper and wire all catalog fetches through auth registry

- Add authentication/http.py with open_url() that tries each configured
  provider in registry order, falling through on 401/403 to the next,
  and finally to unauthenticated
- Add build_request() for one-shot request construction
- Add configured_providers() to registry __init__
- Remove api_base_url() from AuthProvider ABC (unused)
- Remove hosts attribute from providers (no host matching)
- Replace _github_http.py usage in ExtensionCatalog and PresetCatalog
- Wire IntegrationCatalog and WorkflowCatalog through open_url (were unauthenticated)
- Wire _fetch_latest_release_tag() through open_url
- Wire all inline --from-url downloads through open_url
- Fix unused stub variable flagged by code-quality bot
- 49 auth tests (positive + negative), 1805 total tests passing

* fix: address review — fix stale docstrings, restore Accept header, add extra_headers to open_url

- Fix _open_url() docstrings in extensions.py and presets.py that
  incorrectly claimed redirect stripping behavior
- Add extra_headers parameter to open_url() so callers can pass
  additional headers (e.g. Accept) that persist across retries
- Restore Accept: application/vnd.github+json header in
  _fetch_latest_release_tag() via extra_headers

* feat: config-driven opt-in auth via ~/.specify/auth.json

Security-first redesign: no credentials are sent unless the user
explicitly creates ~/.specify/auth.json mapping hosts to providers.

- Add authentication/config.py: loads and validates auth.json with
  host-to-provider mappings, supports token/token_env/azure-ad/azure-cli
- Refactor AuthProvider ABC: auth_headers(token, scheme) + resolve_token(entry)
- Refactor GitHubAuth: bearer scheme only, token from config entry
- Refactor AzureDevOpsAuth: 4 schemes (basic-pat, bearer, azure-cli, azure-ad)
  with dynamic token acquisition for azure-cli and azure-ad
- Rewrite authentication/http.py: host matching, redirect stripping,
  provider fallthrough on 401/403, unauthenticated fallback
- Add docs/reference/authentication.md with full reference and template
- 1823 tests passing (67 auth-specific)

* fix: address review — unused imports, host normalization, provider+scheme validation, security hardening

- Remove unused imports (os, field, Any) in config.py
- Normalize hosts during load (strip + lowercase)
- Validate token/token_env are non-empty strings during load
- Validate provider+scheme compatibility during load
- Fix extra_headers order: auth headers applied last, cannot be overridden
- Remove unused 'tried' variable in http.py
- Warn (once) on malformed auth.json instead of silent fallback
- URL-encode OAuth2 client credentials body in azure_devops.py
- Update 403 message to mention auth.json configuration
- Fix registry leak in test_register_duplicate (try/finally)
- Fix import style consistency in test_authentication.py
- Add azure-cli and azure-ad token acquisition tests (mock subprocess/urlopen)
- Add autouse fixture to isolate upgrade tests from real auth.json
- 1829 tests passing

* fix: reject unknown providers, validate azure-ad fields, strip Authorization from extra_headers

- Reject unknown provider keys during auth.json load with clear error message
- Validate azure-ad tenant_id/client_id/client_secret_env as non-empty strings
- Strip Authorization from extra_headers in both build_request and open_url
  to prevent accidental or intentional bypass of provider-configured auth
- Add tests for unknown provider and incompatible scheme validation
- 1831 tests passing

* fix: extract shared auth test helpers, global config isolation, align docstring

- Move _inject_github_config / make_github_auth_entry to tests/auth_helpers.py
  to eliminate duplication across test_extensions, test_presets, test_upgrade
- Move auth config isolation fixture to global conftest.py (autouse) so ALL
  tests are isolated from ~/.specify/auth.json, not just test_upgrade
- Align load_auth_config docstring with actual behavior: ValueError may be
  caught by higher-level HTTP helpers that warn and continue unauthenticated
- 1831 tests passing

* fix: preserve auth header across multi-hop redirect chains

- Read Authorization from both headers and unredirected_hdrs in
  _StripAuthOnRedirect to survive multi-hop chains within allowed hosts
- Add test_multi_hop_redirect_within_hosts_preserves_auth
- 1832 tests passing

* fix: use resolved config path in warning/error messages and patch build_opener in no-network test

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: assert full resolved config path in rate-limit output test

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: close HTTPError on 401/403, remove _VALID_AUTH_SCHEMES, catch TimeoutExpired, skip POSIX test on Windows, remove unused import

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a1e29737-dd6e-4287-96c1-509e0c96fb21

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: use stable ~/.specify/auth.json in rate-limit message, skip POSIX permission check on Windows

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4636bcdb-87ae-45d6-9545-a40e4effd617

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: validate host patterns, cache auth config per-process

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: clarify _is_valid_host_pattern docstring, clean up test sentinel type

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: improve _is_valid_host_pattern docstring and test observability

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 12:51:20 -05:00
Chengyou Liu
5edc9a5358 fix: migrate extension commands on integration switch (#2404)
* fix: migrate extension commands on integration switch

When switching integrations (e.g. kimi → opencode), extension commands
were not re-registered for the new agent, leaving the new agent without
extension support and orphaning files in the old agent's directory.

Changes:
- Add ExtensionManager.unregister_agent_artifacts() to clean up old
  agent extension files and registry entries during switch
- Add ExtensionManager.register_enabled_extensions_for_agent() to
  re-register all enabled extensions for the new agent
- Wire both into integration_switch() after uninstall/install phases
- Handle skills mode (Copilot --skills) correctly
- Add tests for kimi→opencode→claude migration, Copilot skills mode,
  and disabled extension handling

Fixes extension commands not appearing after integration switch.

* Update src/specify_cli/extensions.py

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-01 07:41:38 -05:00
Quratulain-bilal
ab9c70262d fix: include --from git+... in upgrade hint to avoid PyPI squat package (#2411)
The compatibility-error messages in extensions.py and presets.py, plus the
extension troubleshooting guide, told users to upgrade with:

    uv tool install specify-cli --force

Without `--from git+https://github.com/github/spec-kit.git`, uv resolves
`specify-cli` from PyPI, where an unrelated package with the same name
(no author, no project URLs) ships a stub CLI that lacks `extension`,
`preset`, and most spec-kit commands. Users following the upgrade hint
land on the squat package and report "extension command removed"
(see #1982).

Reuse the existing `REINSTALL_COMMAND` constant in extensions.py and
import it from presets.py so all three call sites point at the GitHub
source. The doc fix also adds a one-line note explaining the PyPI
collision so the same advice doesn't get re-stripped later.

Refs #1982
2026-04-29 10:12:04 -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
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
Kennedy
22e76995c7 feat: implement preset wrap strategy (#2189)
* feat: implement strategy: wrap

* fix: resolve merge conflict for strategy wrap correctness

* feat: multi-preset composable wrapping with priority ordering

Implements comment #4 from PR review: multiple installed wrap presets
now compose in priority order rather than overwriting each other.

Key changes:
- PresetResolver.resolve() gains skip_presets flag; resolve_core() wraps
  it to skip tier 2, preventing accidental nesting during replay
- _replay_wraps_for_command() recomposed all enabled wrap presets for a
  command in ascending priority order (innermost-first) after any
  install or remove
- _replay_skill_override() keeps SKILL.md in sync with the recomposed
  command body for ai-skills-enabled projects
- install_from_directory() detects strategy: wrap commands, stores
  wrap_commands in the registry entry, and calls replay after install
- remove() reads wrap_commands before deletion, removes registry entry
  before rmtree so replay sees post-removal state, then replays
  remaining wraps or unregisters when none remain

Tests: TestResolveCore (5), TestReplayWrapsForCommand (5),
TestInstallRemoveWrapLifecycle (5), plus 2 skill/alias regression tests

* fix: resolve extension commands via manifest file mapping

PresetResolver.resolve_extension_command_via_manifest() consults each
installed extension.yml to find the actual file declared for a command
name, rather than assuming the file is named <cmd_name>.md.  This fixes
_substitute_core_template for extensions like selftest where the manifest
maps speckit.selftest.extension → commands/selftest.md.

Resolution order in _substitute_core_template is now:
  1. resolve_core(cmd_name) — project overrides win, then name-based lookup
  2. resolve_extension_command_via_manifest(cmd_name) — manifest fallback
  3. resolve_core(short_name) — core template short-name fallback

Path traversal guard mirrors the containment check already present in
ExtensionManager to reject absolute paths or paths escaping the extension
root.

* fix: add bundled core_pack as Priority 5 in PresetResolver.resolve()

resolve_core() was returning None for built-in commands (implement,
specify, etc.) because PresetResolver only checked .specify/templates/
commands/ (Priority 4), which is never populated for commands in a
normal project. strategy:wrap presets rely on resolve_core() to fetch
the {CORE_TEMPLATE} body, so the wrap was silently skipped and SKILL.md
was never updated.

Priority 5 now checks core_pack/commands/ (wheel install) or
repo_root/templates/commands/ (source checkout), mirroring the pattern
used by _locate_core_pack() elsewhere.

Updated two tests whose assertions assumed resolve_core() always
returned None when .specify/templates/commands/ was absent.

* fix: harden preset wrap replay removal

* fix: stabilize existing directory error output

* fix: track outermost_pack_id from contributing preset; use Path.parts in tests

- outermost_pack_id now updates alongside outermost_frontmatter inside
  the wrap loop, so it reflects the actual last contributing preset
  rather than always taking wrap_presets[0] (which may have been skipped)
- Replace str(path) substring checks in TestResolveCore with Path.parts
  tuple comparisons for correct behaviour on Windows (CI runs windows-latest)

* fix: guard against non-mapping YAML manifests; apply integration post-processing in replay

- ExtensionManifest._load raises ValidationError for non-dict YAML roots instead of TypeError
- PresetManager._replay_wraps_for_command calls integration.post_process_skill_content,
  matching _register_skills behaviour
- PresetResolver skips extensions that raise OSError/TypeError/AttributeError on manifest load
- Tests: non-mapping YAML, OSError manifest skip, and replay integration post-processing
2026-04-21 15:02:31 -05:00
Manfred Riem
8fc2bd3489 fix: allow Claude to chain skills for hook execution (#2227)
* fix: allow Claude to chain skills for hook execution (#2178)

- Set disable-model-invocation to false so Claude can invoke extension
  skills (e.g. speckit-git-feature) from within workflow skills
- Inject dot-to-hyphen normalization note into Claude SKILL.md hook
  sections so the model maps extension.yml command names to skill names
- Replace Unicode checkmark with ASCII [OK] in auto-commit scripts to
  fix PowerShell encoding errors on Windows
- Move Claude-specific frontmatter injection to ClaudeIntegration via
  post_process_skill_content() hook on SkillsIntegration, wired through
  presets and extensions managers
- Add positive and negative tests for all changes

Fixes #2178

* refactor: address PR review feedback

- Preserve line-ending style (CRLF/LF) in _inject_hook_command_note
  instead of always inserting \n, matching the convention used by other
  injection helpers in the same module.

- Extract duplicated _post_process_skill() from extensions.py and
  presets.py into a shared post_process_skill() function in agents.py.
  Both modules now import and call the shared helper.

* fix: match full hook instruction line in regex

The regex in _inject_hook_command_note only matched lines ending
immediately after 'output the following', but the actual template
lines continue with 'based on its `optional` flag:'. Use [^\r\n]*
to capture the rest of the line before the EOL.

* refactor: use integration object directly for post_process_skill_content

Instead of a free function in agents.py that re-resolves the
integration by key, callers in extensions.py and presets.py now
resolve the integration once via get_integration() and call
integration.post_process_skill_content() directly. The base
identity method lives on SkillsIntegration.
2026-04-15 14:35:05 -05:00
Michal Bachorik
33a28ec8f7 fix: unofficial PyPI warning (#1982) and legacy extension command name auto-correction (#2017) (#2027)
* docs: warn about unofficial PyPI packages and recommend version verification (#1982)

Clarify that only packages from github/spec-kit are official, and add
`specify version` as a post-install verification step to help users
catch accidental installation of an unrelated package with the same name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): auto-correct legacy command names instead of hard-failing (#2017)

Community extensions that predate the strict naming requirement use two
common legacy formats ('speckit.command' and 'extension.command').
Instead of rejecting them outright, auto-correct to the required
'speckit.{extension}.{command}' pattern and emit a compatibility warning
so authors know they need to update their manifest. Names that cannot be
safely corrected (e.g. single-segment names) still raise ValidationError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): isolate preset catalog search test from community catalog network calls

test_search_with_cached_data asserted exactly 2 results but was getting 4
because _get_merged_packs() queries the full built-in catalog stack
(default + community). The community catalog had no local cache and hit
the network, returning real presets. Writing a project-level
preset-catalogs.yml that pins the test to the default URL only makes
the count assertions deterministic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): extend auto-correction to aliases (#2017)

The upstream #1994 added alias validation in _collect_manifest_command_names,
which also rejected legacy 2-part alias names (e.g. 'speckit.verify').
Extend the same auto-correction logic from _validate() to cover aliases,
so both 'speckit.command' and 'extension.command' alias formats are
corrected to 'speckit.{ext_id}.{command}' with a compatibility warning
instead of hard-failing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): address PR review feedback (#2017)

- _try_correct_command_name: only correct 'X.Y' to 'speckit.ext_id.Y'
  when X matches ext_id, preventing misleading warnings followed by
  install failure due to namespace mismatch
- _validate: add aliases type/string guards matching _collect_manifest
  _command_names defensive checks
- _validate: track command renames and rewrite any hook.*.command
  references that pointed at a renamed command, emitting a warning
- test: fix test_command_name_autocorrect_no_speckit_prefix to use
  ext_id matching the legacy namespace; add namespace-mismatch test
- test: replace redundant preset-catalogs.yml isolation with
  monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL") so the env var
  cannot bypass catalog restriction in CI environments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update docs/installation.md

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

* fix(extensions): warn when hook command refs are silently canonicalized; fix grammar

- Hook rewrites (alias-form or rename-map) now always emit a warning so
  extension authors know to update their manifests. Previously only
  rename-map rewrites produced a warning; pure alias-form lifts were
  silent.
- Pluralize "command/commands" in the uninstall confirmation message so
  single-command extensions no longer print "1 commands".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): raise ValidationError for non-dict hook entries

Silently skipping non-dict hook entries left them in manifest.hooks,
causing HookExecutor.register_hooks() to crash with AttributeError
when it called hook_config.get() on a non-mapping value.

Also updates PR description to accurately reflect the implementation
(no separate _try_correct_alias_name helper; aliases use the same
_try_correct_command_name path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): derive remove cmd_count from registry, fix wording

Previously cmd_count used len(ext_manifest.commands) which only counted
primary commands and missed aliases. The registry's registered_commands
already tracks every command name (primaries + aliases) per agent, so
max(len(v) for v in registered_commands.values()) gives the correct
total.

Also changes "from AI agent" → "across AI agents" since remove()
unregisters commands from all detected agents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): distinguish missing vs empty registered_commands in remove prompt

Using get() without a default lets us tell apart:
- key missing (legacy registry entry) → fall back to manifest count
- key present but empty dict (installed with no agent dirs) → show 0

Previously the truthiness check `if registered_commands and ...` treated
both cases the same, so an empty dict fell back to len(manifest.commands)
and overcounted commands that would actually be removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): clarify removal prompt wording to 'per agent'

'across AI agents' implied a total count, but cmd_count uses max()
across agents (per-agent count). Using sum() would double-count since
users think in logical commands, not per-agent files. 'per agent'
accurately describes what the number represents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): clarify cmd_count comment — per-agent max, not total

The comment said 'covers all agents' implying a total, but cmd_count uses
max() across agents (per-agent count). Updated comment to explain the
max() choice and why sum() would double-count.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(extensions): add CLI tests for remove confirmation pluralization

Adds TestExtensionRemoveCLI with two CliRunner tests:
- singular: 1 registered command → '1 command per agent'
- plural:   2 registered commands → '2 commands per agent'

These prevent regressions on the cmd_count pluralization logic
and the 'per agent' wording introduced in this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(agents): remove orphaned SKILL.md parent dirs on unregister

For SKILL.md-based agents (codex, kimi), each command lives in its own
subdirectory (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). The previous
unregister_commands() only unlinked the file, leaving an empty parent dir.

Now attempts rmdir() on the parent when it differs from the agent commands
dir. OSError is silenced so non-empty dirs (e.g. user files) are safely left.

Adds test_unregister_skill_removes_parent_directory to cover this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): drop alias pattern enforcement from _validate()

Aliases are intentionally free-form to preserve community extension
compatibility (e.g. 'speckit.verify' short aliases used by spec-kit-verify
and other existing extensions). This aligns _validate() with the intent of
upstream commit 4deb90f (fix: restore alias compatibility, #2110/#2125).

Only type and None-normalization checks remain for aliases. Pattern
enforcement continues for primary command names only.

Updated tests to verify free-form aliases pass through unchanged with
no warnings instead of being auto-corrected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(extensions): guard against non-dict command entries in _validate()

If provides.commands contains a non-mapping entry (e.g. an int or string),
'name' not in cmd raises TypeError instead of a user-facing ValidationError.
Added isinstance(cmd, dict) check at the top of the loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 07:35:49 -05:00
Manfred Riem
d1b95c2f59 fix: bundled extensions should not have download URLs (#2155)
* fix: bundled extensions should not have download URLs (#2151)

- Remove selftest from default catalog (not a published extension)
- Replace download_url with 'bundled: true' flag for git extension
- Add bundled check in extension add flow with clear error message
  when bundled extension is missing from installed package
- Add bundled check in download_extension() with specific error
- Direct users to reinstall via uv with full GitHub URL
- Add 3 regression tests for bundled extension handling

* refactor: address review - move bundled check up-front, extract reinstall constant

- Move bundled check before download_url inspection in download_extension()
  so bundled extensions can never be downloaded even with a URL present
- Extract REINSTALL_COMMAND constant to avoid duplicated install strings

* fix: allow bundled extensions with download_url to be updated

Bundled extensions should only be blocked from download when they have
no download_url. If a newer version is published to the catalog with a
URL, users should be able to install it to get bug fixes.

Add test for bundled-with-URL download path.
2026-04-10 11:29:18 -05:00
Gabriel Henrique
5732de60d0 feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156)
Use SkillsIntegration so workflows ship as speckit-*/SKILL.md. Update
init next-steps, extension hook invocation, docs, and tests.

Made-with: Cursor
2026-04-10 09:35:19 -05:00
Manfred Riem
4deb90f4f5 fix: restore alias compatibility for community extensions (#2110) (#2125)
Relax alias validation in _collect_manifest_command_names() to only
enforce the 3-part speckit.{ext}.{cmd} pattern on primary command
names. Aliases retain type and duplicate checking but are otherwise
free-form, restoring pre-#1994 behavior.

This unblocks community extensions (e.g. spec-kit-verify) that use
2-part aliases like 'speckit.verify'.

Fixes #2110
2026-04-08 12:03:29 -05:00
Copilot
1a9e4d1d8d feat: Git extension stage 1 — bundled extensions/git with hooks on all core commands (#1941)
* feat: add git extension with hooks on all core commands

- Create extensions/git/ with 5 commands: initialize, feature,
  validate, remote, commit
- 18 hooks covering before/after for all 9 core commands
- Scripts: create-new-feature, initialize-repo, auto-commit,
  git-common (bash + powershell)
- Configurable: branch_numbering, init_commit_message,
  per-command auto-commit with custom messages
- Add hooks to analyze, checklist, clarify, constitution,
  taskstoissues command templates
- Allow hooks-only extensions (no commands required)
- Bundle extension in wheel via pyproject.toml force-include
- Resolve bundled extensions locally before catalog lookup
- Remove planned-but-unimplemented before/after_commit hook refs
- Update extension docs (API ref, dev guide, user guide)
- 37 new tests covering manifest, install, all scripts (bash+pwsh),
  config reading, graceful degradation

Stage 1: opt-in via 'specify extension add git'. No auto-install,
no changes to specify.md or core git init code.

Refs: #841, #1382, #1066, #1791, #1191

* fix: set git identity env vars in extension tests for CI runners

* fix: address PR review comments

- Fix commands property KeyError for hooks-only extensions
- Fix has_git() operator precedence in git-common.sh
- Align default commit message to '[Spec Kit] Initial commit' across
  config-template, extension.yml defaults, and both init scripts
- Update README to reflect all 5 commands and 18 hooks

* fix: address second round of PR review comments

- Add type validation for provides.commands (must be list) and hooks
  (must be dict) in manifest _validate()
- Tighten malformed timestamp detection in git-common.sh to catch
  7-digit dates without trailing slug (e.g. 2026031-143022)
- Pass REPO_ROOT to has_git/Test-HasGit in create-new-feature scripts
- Fix initialize command docs: surface errors on git failures, only
  skip when git is not installed
- Fix commit command docs: 'skips with a warning' not 'silently'
- Add tests for commands:null and hooks:list rejection

* fix: address third round of PR review comments

- Remove scripts frontmatter from command files (CommandRegistrar
  rewrites ../../scripts/ to .specify/scripts/ which points at core
  scripts, not extension scripts)
- Update speckit.git.commit command to derive event name from hook
  context rather than using a static example
- Clarify that hook argument passthrough works via AI agent context
  (the agent carries conversation state including user's original
  feature description)

* fix: address fourth round of PR review comments

- Validate extension_id against ^[a-z0-9-]+$ in _locate_bundled_extension
  to prevent path traversal (security fix)
- Move defaults under config.defaults in extension.yml to match
  ConfigManager._get_extension_defaults() schema
- Ship git-config.yml in extension directory so it's copied during
  install (provides.config template isn't materialized by ExtensionManager)
- Condition handling in hook templates: intentionally matches existing
  pattern from specify/plan/tasks/implement templates (not a new issue)

* fix: add --allow-empty to git commit in initialize-repo scripts

Ensures git init succeeds even on empty repos where nothing has been
staged yet.

* fix: resolve display names to bundled extensions before catalog download

When 'specify extension add "Git Branching Workflow"' is used with a
display name instead of the ID, the catalog resolver now runs first to
map the name to an ID, then checks bundled extensions again with the
resolved ID before falling back to network download.

Also noted: EXECUTE_COMMAND_INVOCATION and condition handling match the
existing pattern in specify/plan/tasks/implement templates (pre-existing,
not introduced by this PR).

* fix: handle before_/after_ prefixes in auto-commit message derivation

- Strip both before_ and after_ prefixes when deriving command name
  (fixes misleading 'Auto-commit after before_plan' messages)
- Include phase (before/after) in default commit messages
- Clarify README config example is an override, not default behavior

* fix: use portable grep -qw for word boundary in create-new-feature.sh

BSD grep (macOS) doesn't support \b as a word boundary. Replace with
grep -qw which is POSIX-portable.

* fix: validate hook values, numeric --number, and PS warning routing

- Validate each hook value is a dict with a 'command' field during
  manifest _validate() (prevents crash at install time)
- Validate --number is a non-negative integer in bash create-new-feature
  (clear error instead of cryptic shell arithmetic failure)
- Route PowerShell no-git warning to stderr in JSON mode so stdout
  stays valid JSON

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-04-07 08:39:35 -05:00
Andrii Furmanets
a858c1d6da Install Claude Code as native skills and align preset/integration flows (#2051)
* Use Claude skills for generated commands

* Fix Claude integration and preset skill flows

* Group Claude tests in integration suite

* Align Claude skill frontmatter across generators

* Fix native skill preset cleanup

* Keep legacy AI skills test on legacy path

* Move Claude here-mode test to CLI suite
2026-04-02 09:44:48 -05:00
Andrii Furmanets
796b4f47c4 fix: prevent extension command shadowing (#1994)
* fix: prevent extension command shadowing

* Validate extension command namespaces

* Reuse extension command name pattern
2026-03-27 10:55:26 -05:00
Hamilton Snow
ccc44dd00a Unify Kimi/Codex skill naming and migrate legacy dotted Kimi dirs (#1971)
* fix: unify hyphenated skills and migrate legacy kimi dotted dirs

* fix: preserve legacy kimi dotted preset skill overrides

* fix: migrate kimi legacy dotted skills without ai-skills flag

* fix: harden kimi migration and cache hook init options

* fix: apply kimi preset skill overrides without ai-skills flag

* fix: keep sequential branch numbering beyond 999

* test: align kimi scaffold skill path with hyphen naming

* chore: align hook typing and preset skill comment

* fix: restore AGENT_SKILLS_DIR_OVERRIDES compatibility export

* refactor: remove AGENT_SKILLS_DIR_OVERRIDES and update callers

* fix(ps1): support sequential branch numbers above 999

* fix: resolve preset skill placeholders for skills agents

* Fix legacy kimi migration safety and preset skill dir checks

* Harden TOML rendering and consolidate preset skill restore parsing

* Fix PowerShell overflow and hook message fallback for empty invocations

* Restore preset skills from extensions

* Refine preset skill restore helpers

* Harden skill path and preset checks

* Guard non-dict init options

* Avoid deleting unmanaged preset skill dirs

* Unify extension skill naming with hooks

* Harden extension native skill registration

* Normalize preset skill titles
2026-03-26 10:53:30 -05:00
Dhilip
36019ebf1b feat: Auto-register ai-skills for extensions whenever applicable (#1840)
* feat: Auto-register ai-skills for extensions whenever applicable

* fix: failing test

* fix: address copilot review comments – path traversal guard and use short_name in title

* fix: address remaining copilot review comments – is_file guard, skills type-validation, and exact extension ownership check on fallback rmtree

* fix: address copilot round-3 comments – align skill naming with presets.py convention, safe rmdir on fail, require SKILL.md for fallback rmtree, normalize skill_count in CLI

* fix: is_dir() guard in fast-path rmtree and fix ghost-skill assertion naming

* fix: path-traversal guard on skill_name in both rmtree paths of _unregister_extension_skills

* fix: add SKILL.md ownership check to fast-path rmtree and alias shadowed _get_skills_dir import
2026-03-25 07:48:36 -05:00
Seiya Kojima
a351c826ee fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936)
None of the yaml.dump() calls specify allow_unicode=True, causing
non-ASCII characters in extension descriptions to be escaped to
\uXXXX sequences in generated .agent.md frontmatter and config files.

Add allow_unicode=True to all 6 yaml.dump() call sites, and
encoding="utf-8" to all corresponding write_text() and read_text()
calls to ensure consistent UTF-8 handling across platforms.
2026-03-23 08:49:10 -05:00
Michal Bachorik
2bf655e261 feat(presets): add enable/disable toggle and update semantics (#1891)
* feat(presets): add enable/disable toggle and update semantics

Add preset enable/disable CLI commands and update semantics to match
the extension system capabilities.

Changes:
- Add `preset enable` and `preset disable` CLI commands
- Add `restore()` method to PresetRegistry for rollback scenarios
- Update `get()` and `list()` to return deep copies (prevents mutation)
- Update `list_by_priority()` to filter disabled presets by default
- Add input validation to `restore()` for defensive programming
- Add 16 new tests covering all functionality and edge cases

Closes #1851
Closes #1852

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review - deep copy and error message accuracy

- Fix error message in restore() to match actual validation ("dict" not "non-empty dict")
- Use copy.deepcopy() in restore() to prevent caller mutation
- Apply same fixes to ExtensionRegistry for parity
- Add /defensive-check command for pre-PR validation
- Add tests for restore() validation and deep copy behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* revert: remove defensive-check command from PR

* fix: address PR review - clarify messaging and add parity

- Add note to enable/disable output clarifying commands/skills remain active
- Add include_disabled parameter to ExtensionRegistry.list_by_priority for parity
- Add tests for extension disabled filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review - disabled extension resolution and corrupted entries

- Fix _get_all_extensions_by_priority to use include_disabled=True for tracking
  registered IDs, preventing disabled extensions from being picked up as
  unregistered directories
- Add corrupted entry handling to get() - returns None for non-dict entries
- Add integration tests for disabled extension template resolution
- Add tests for get() corrupted entry handling in both registries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: handle corrupted registry in list() methods

- Add defensive handling to list() when presets/extensions is not a dict
- Return empty dict instead of crashing on corrupted registry
- Apply same fix to both PresetRegistry and ExtensionRegistry for parity
- Add tests for corrupted registry handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: validate top-level registry structure in get() and restore()

- get() now validates self.data["presets/extensions"] is a dict before accessing
- restore() ensures presets/extensions dict exists before writing
- Prevents crashes when registry JSON is parseable but has corrupted structure
- Applied same fixes to both PresetRegistry and ExtensionRegistry for parity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: validate root-level JSON structure in _load() and is_installed()

- _load() now validates json.load() result is a dict before returning
- is_installed() validates presets/extensions is a dict before checking membership
- Prevents crashes when registry file is valid JSON but wrong type (e.g., array)
- Applied same fixes to both registries for parity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: normalize presets/extensions field in _load()

- _load() now normalizes the presets/extensions field to {} if not a dict
- Makes corrupted registries recoverable for add/update/remove operations
- Applied same fix to both registries for parity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use raw registry keys to track corrupted extensions

- Use registry.list().keys() instead of list_by_priority() for tracking
- Corrupted entries are now treated as tracked, not picked up as unregistered
- Tighten test assertion for disabled preset resolution
- Update test to match new expected behavior for corrupted entries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: handle None metadata in ExtensionManager.remove()

- Add defensive check for corrupted metadata in remove()
- Match existing pattern in PresetManager.remove()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add keys() method and filter corrupted entries in list()

- Add lightweight keys() method that returns IDs without deep copy
- Update list() to filter out non-dict entries (match type contract)
- Use keys() instead of list().keys() for performance
- Fix comment to reflect actual behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address defensive-check findings - deep copy, corruption guards, parity

- Extension enable/disable: use delta pattern matching presets
- add(): use copy.deepcopy(metadata) in both registries
- remove(): guard outer field for corruption in both registries
- update(): guard outer field for corruption in both registries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: deep copy updates in update() to prevent caller mutation

Both PresetRegistry.update() and ExtensionRegistry.update() now deep
copy the input updates/metadata dict to prevent callers from mutating
nested objects after the call.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-19 07:48:48 -05:00
Michal Bachorik
d2ecf6560d feat(extensions,presets): add priority-based resolution ordering (#1855)
* feat(extensions,presets): add priority-based resolution ordering

Add priority field to extension and preset registries for deterministic
template resolution when multiple sources provide the same template.

Extensions:
- Add `list_by_priority()` method to ExtensionRegistry
- Add `--priority` option to `extension add` command
- Add `extension set-priority` command
- Show priority in `extension list` and `extension info`
- Preserve priority during `extension update`
- Update RFC documentation

Presets:
- Add `preset set-priority` command
- Show priority in `preset info` output
- Use priority ordering in PresetResolver for extensions

Both systems:
- Lower priority number = higher precedence (default: 10)
- Backwards compatible with legacy entries (missing priority defaults to 10)
- Comprehensive test coverage including backwards compatibility

Closes #1845
Closes #1854

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address code review feedback

- list_by_priority(): add secondary sort by ID for deterministic ordering,
  return deep copies to prevent mutation
- install_from_directory/zip: validate priority >= 1 early
- extension add CLI: validate --priority >= 1 before install
- PresetRegistry.update(): preserve installed_at timestamp
- Test assertions: use exact source string instead of substring match

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address additional review feedback

- PresetResolver: add fallback to directory scanning when registry is
  empty/corrupted for robustness and backwards compatibility
- PresetRegistry.update(): add guard to prevent injecting installed_at
  when absent in existing entry (mirrors ExtensionRegistry behavior)
- RFC: update extension list example to match actual CLI output format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: restore defensive code and RFC descriptions lost in rebase

- Restore defensive code in list_by_priority() with .get() and isinstance check
- Restore detailed --from URL and --dev option descriptions in RFC

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add defensive code to presets list_by_priority()

- Add .get() and isinstance check for corrupted/empty registry
- Move copy import to module level (remove local import)
- Matches defensive pattern used in extensions.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address reviewer feedback on priority resolution

- Rename _normalize_priority to normalize_priority (public API)
- Add comprehensive tests for normalize_priority function (9 tests)
- Filter non-dict metadata entries in list_by_priority() methods
- Fix extension priority resolution to merge registered and unregistered
  extensions into unified sorted list (unregistered get implicit priority 10)
- Add tests for extension priority resolution ordering (4 tests)

The key fix ensures unregistered extensions with implicit priority 10
correctly beat registered extensions with priority > 10, and vice versa.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: DRY refactor and strengthen test assertions

- Extract _get_all_extensions_by_priority() helper in PresetResolver
  to eliminate duplicated extension list construction
- Add priority=10 assertion to test_legacy_extension_without_priority_field
- Add priority=10 assertion to test_legacy_preset_without_priority_field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add isinstance(dict) checks for corrupted registry entries

Add defensive checks throughout CLI commands and manager methods
to handle cases where registry entries may be corrupted (non-dict
values). This prevents AttributeError when calling .get() on
non-dict metadata.

Locations fixed:
- __init__.py: preset/extension info, set-priority, enable/disable,
  upgrade commands
- extensions.py: list_installed()
- presets.py: list_installed()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: normalize priority display to match resolution behavior

Use normalize_priority() for all priority display in CLI commands
to ensure displayed values match actual resolution behavior when
registry data is corrupted/hand-edited.

Locations fixed:
- extensions.py: list_installed()
- presets.py: list_installed(), PresetResolver
- __init__.py: preset info, extension info, set-priority commands

Also added GraphQL query for unresolved PR comments to CLAUDE.md.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: repair corrupted priority values in set-priority commands

Changed set-priority commands to check if the raw stored value is
already a valid int equal to the requested priority before skipping.
This ensures corrupted values (e.g., "high") get repaired even when
setting to the default priority (10).

Also removed CLAUDE.md that was accidentally added to the repo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: harden registry update methods against corrupted entries

- Normalize priority when restoring during extension update to prevent
  propagating corrupted values (e.g., "high", 0, negative)
- Add isinstance(dict) checks in ExtensionRegistry.update() and
  PresetRegistry.update() to handle corrupted entries (string/list)
  that would cause TypeError on merge

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use safe fallback for version in list_installed()

When registry entry is corrupted (non-dict), metadata becomes {} after
the isinstance check. Use metadata.get("version", manifest.version)
instead of metadata["version"] to avoid KeyError.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:44:34 -05:00
Copilot
69ee7a836e feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)
* Initial plan

* feat(templates): add pluggable template system with packs, catalog, resolver, and CLI commands

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* test(templates): add comprehensive unit tests for template pack system

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* feat(presets): pluggable preset system with template/command overrides, catalog, and resolver

- Rename 'template packs' to 'presets' to avoid naming collision with core templates
- PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetResolver in presets.py
- Extract CommandRegistrar to agents.py as shared infrastructure
- CLI: specify preset list/add/remove/search/resolve/info
- CLI: specify preset catalog list/add/remove
- --preset option on specify init
- Priority-based preset stacking (--priority, lower = higher precedence)
- Command overrides registered into all detected agent directories (17+ agents)
- Extension command safety: skip registration if target extension not installed
- Multi-catalog support: env var, project config, user config, built-in defaults
- resolve_template() / Resolve-Template in bash/PowerShell scripts
- Self-test preset: overrides all 6 core templates + 1 command
- Scaffold with 4 examples: core/extension template and command overrides
- Preset catalog (catalog.json, catalog.community.json)
- Documentation: README.md, ARCHITECTURE.md, PUBLISHING.md
- 110 preset tests, 253 total tests passing

* feat(presets): propagate command overrides to skills via init-options

- Add save_init_options() / load_init_options() helpers that persist
  CLI flags from 'specify init' to .specify/init-options.json
- PresetManager._register_skills() overwrites SKILL.md files when
  --ai-skills was used during init and corresponding skill dirs exist
- PresetManager._unregister_skills() restores core template content
  on preset removal
- registered_skills stored in preset registry metadata
- 8 new tests covering skill override, skip conditions, and restore

* fix: address PR check failures (ruff F541, CodeQL URL substring)

- Remove extraneous f-prefix from two f-strings without placeholders
- Replace substring URL check in test with startswith/endswith assertions
  to satisfy CodeQL incomplete URL substring sanitization rule

* fix: address Copilot PR review comments

- Move save_init_options() before preset install so skills propagation
  works during 'specify init --preset --ai-skills'
- Clean up downloaded ZIP after successful preset install during init
- Validate --from URL scheme (require HTTPS, HTTP only for localhost)
- Expose unregister_commands() on extensions.py CommandRegistrar wrapper
  instead of reaching into private _registrar field
- Use _get_merged_packs() for search() and get_pack_info() so all
  active catalogs are searched, not just the highest-priority one
- Fix fetch_catalog() cache to verify cached URL matches current URL
- Fix PresetResolver: script resolution uses .sh extension, consistent
  file extensions throughout resolve(), and resolve_with_source()
  delegates to resolve() to honor template_type parameter
- Fix bash common.sh: fall through to directory scan when python3
  returns empty preset list
- Fix PowerShell Resolve-Template: filter out dot-folders and sort
  extensions deterministically

* fix: narrow empty except blocks and add explanatory comments

* fix: address Copilot PR review comments (round 2)

- Fix init --preset error masking: distinguish "not found" from real errors
- Fix bash resolve_template: skip hidden dirs in extensions (match Python/PS)
- Fix temp dir leaks in tests: use temp_dir fixture instead of mkdtemp
- Fix self-test catalog entry: add note that it's local-only (no download_url)
- Fix Windows path issue in resolve_with_source: use Path.relative_to()
- Fix skill restore path: use project's .specify/templates/commands/ not source tree
- Add encoding="utf-8" to all file read/write in agents.py
- Update test to set up core command templates for skill restoration

* fix: remove self-test from catalog.json (local-only preset)

* fix: address Copilot PR review comments (round 3)

- Fix PS Resolve-Template fallback to skip dot-prefixed dirs (.cache)
- Rename _catalog to _catalog_name for consistency with extension system
- Enforce install_allowed policy in CLI preset add and download_pack()
- Fix shell injection: pass registry path via env var instead of string interpolation

* fix: correct PresetError docstring from template to preset

* Removed CHANGELOG requirement

* Applying review recommendations

* Applying review recommendations

* Applying review recommendations

* Applying review recommendations

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-03-13 15:09:14 -05:00
fuyongde
7562664fd1 fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730)
* fix: migrate Qwen Code CLI from TOML to Markdown format (#1589)

Qwen Code CLI v0.10.0 deprecated TOML format and fully switched to
Markdown as the core format for configuration and interaction files.

- Update create-release-packages.sh: generate .md files with $ARGUMENTS
  instead of .toml files with {{args}} for qwen agent
- Update create-release-packages.ps1: same change for PowerShell script
- Update AGENTS.md: reflect Qwen's new Markdown format in docs and
  remove Qwen from TOML format section
- Update tests/test_ai_skills.py: add commands_dir_qwen fixture and
  tests covering Markdown-format skills installation for Qwen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update CommandRegistrar qwen config to Markdown format

extensions.py CommandRegistrar.AGENT_CONFIGS['qwen'] was still set to
TOML format, causing `specify extension` to write .toml files into
.qwen/commands, conflicting with Qwen Code CLI v0.10.0+ expectations.

- Change qwen format from toml to markdown
- Change qwen args from {{args}} to $ARGUMENTS
- Change qwen extension from .toml to .md
- Add test to assert qwen config is Markdown format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:43:14 -05:00
Michal Bachorik
58ce653908 feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776)
* feat(extensions): implement automatic updates with atomic backup/restore

- Implement automatic extension updates with download from catalog
- Add comprehensive backup/restore mechanism for failed updates:
  - Backup registry entry before update
  - Backup extension directory
  - Backup command files for all AI agents
  - Backup hooks from extensions.yml
- Add extension ID verification after install
- Add KeyboardInterrupt handling to allow clean cancellation
- Fix enable/disable to preserve installed_at timestamp by using
  direct registry manipulation instead of registry.add()
- Add rollback on any update failure with command file,
  hook, and registry restoration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(extensions): comprehensive name resolution and error handling improvements

- Add shared _resolve_installed_extension helper for ID/display name resolution
  with proper ambiguous name handling (shows table of matches)
- Add _resolve_catalog_extension helper for catalog lookups by ID or display name
- Update enable/disable/update/remove commands to use name resolution helpers
- Fix extension_info to handle catalog errors gracefully:
  - Fallback to local installed info when catalog unavailable
  - Distinguish "catalog unavailable" from "not found in catalog"
  - Support display name lookup for both installed and catalog extensions
- Use resolved display names in all status messages for consistency
- Extract _print_extension_info helper for DRY catalog info printing

Addresses reviewer feedback:
- Ambiguous name handling in enable/disable/update
- Catalog error fallback for installed extensions
- UX message clarity (catalog unavailable vs not found)
- Resolved ID in status messages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(extensions): properly detect ambiguous names in extension_info

The extension_info command was breaking on the first name match without
checking for ambiguity. This fix separates ID matching from name matching
and checks for ambiguity before selecting a match, consistent with the
_resolve_installed_extension() helper used by other commands.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(extensions): add public update() method to ExtensionRegistry

Add a proper public API for updating registry metadata while preserving
installed_at timestamp, instead of directly mutating internal registry
data and calling private _save() method.

Changes:
- Add ExtensionRegistry.update() method that preserves installed_at
- Update enable/disable commands to use registry.update()
- Update rollback logic to use registry.update()

This decouples the CLI from registry internals and maintains proper
encapsulation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(extensions): safely access optional author field in extension_info

ExtensionManifest doesn't expose an author property - the author field
is optional in extension.yml and stored in data["extension"]["author"].
Use safe dict access to avoid AttributeError.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(extensions): address multiple reviewer comments

- ExtensionRegistry.update() now preserves original installed_at timestamp
- Add ExtensionRegistry.restore() for rollback (entry was removed)
- Clean up wrongly installed extension on ID mismatch before rollback
- Remove unused catalog_error parameter from _print_extension_info()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(extensions): check _install_allowed for updates, preserve backup on failed rollback

- Skip automatic updates for extensions from catalogs with install_allowed=false
- Only delete backup directory on successful rollback, preserve it on failure
  for manual recovery

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(extensions): address reviewer feedback on update/rollback logic

- Hook rollback: handle empty backup_hooks by checking `is not None`
  instead of truthiness (falsy empty dict would skip hook cleanup)
- extension_info: use resolved_installed_id for catalog lookup when
  extension was found by display name (prevents wrong catalog match)
- Rollback: always remove extension dir first, then restore if backup
  exists (handles case when no original dir existed before update)
- Validate extension ID from ZIP before installing, not after
  (avoids side effects of installing wrong extension before rollback)
- Preserve enabled state during updates: re-apply disabled state and
  hook enabled flags after successful update
- Optimize _resolve_catalog_extension: pass query to catalog.search()
  instead of fetching all extensions
- update() now merges metadata with existing entry instead of replacing
  (preserves fields like registered_commands when only updating enabled)
- Add tests for ExtensionRegistry.update() and restore() methods:
  - test_update_preserves_installed_at
  - test_update_merges_with_existing
  - test_update_raises_for_missing_extension
  - test_restore_overwrites_completely
  - test_restore_can_recreate_removed_entry

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs(extensions): update RFC to reflect implemented status

- Change status from "Draft" to "Implemented"
- Update all Implementation Phases to show completed items
- Add new features implemented beyond original RFC:
  - Display name resolution for all commands
  - Ambiguous name handling with tables
  - Atomic update with rollback
  - Pre-install ID validation
  - Enabled state preservation
  - Registry update/restore methods
  - Catalog error fallback
  - _install_allowed flag
  - Cache invalidation
- Convert Open Questions to Resolved Questions with decisions
- Add remaining Open Questions (sandboxing, signatures) as future work
- Fix table of contents links

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(extensions): address third round of PR review comments

- Refactor extension_info to use _resolve_installed_extension() helper
  with new allow_not_found parameter instead of duplicating resolution logic
- Fix rollback hook restoration to not create empty hooks: {} in config
  when original config had no hooks section
- Fix ZIP pre-validation to handle nested extension.yml files (GitHub
  auto-generated ZIPs have structure like repo-name-branch/extension.yml)
- Replace unused installed_manifest variable with _ placeholder
- Add display name to update status messages for better UX

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(extensions): address fourth round of PR review comments

Rollback fixes:
- Preserve installed_at timestamp after successful update (was reset by
  install_from_zip calling registry.add)
- Fix rollback to only delete extension_dir if backup exists (avoids
  destroying valid installation when failure happens before modification)
- Fix rollback to remove NEW command files created by failed install
  (files that weren't in original backup are now cleaned up)
- Fix rollback to delete hooks key entirely when backup_hooks is None
  (original config had no hooks key, so restore should remove it)

Cross-command consistency fix:
- Add display name resolution to `extension add` command using
  _resolve_catalog_extension() helper (was only in `extension info`)
- Use resolved extension ID for download_extension() call, not original
  argument which may be a display name

Security fix (fail-closed):
- Malformed catalog config (empty/missing URLs) now raises ValidationError
  instead of silently falling back to built-in catalogs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lint): address ruff linting errors and registry.update() semantics

- Remove unused import ExtensionError in extension_info
- Remove extraneous f-prefix from strings without placeholders
- Use registry.restore() instead of registry.update() for installed_at
  preservation (update() always preserves existing installed_at, ignoring
  our override)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-13 07:23:37 -05:00
Fanch Daniel
e56d37db8c feat: add Kimi Code CLI agent support (#1790)
* feat: add Kimi Code (kimi) CLI agent support

- Register kimi in AGENT_CONFIG with folder `.kimi/`, markdown format, requires_cli=True
- Register kimi in CommandRegistrar.AGENT_CONFIGS
- Add kimi to supported agents table in AGENTS.md and README.md
- Add kimi to release packaging scripts (bash and PowerShell)
- Add kimi CLI installation to devcontainer post-create script
- Add kimi support to update-agent-context scripts (bash and PowerShell)
- Add 4 consistency tests covering all kimi integration surfaces
- Bump version to 0.1.14 and update CHANGELOG

* fix: include .specify/templates/ and real command files in release ZIPs

- Copy real command files from templates/commands/ (with speckit. prefix)
  instead of generating stubs, so slash commands have actual content
- Add .specify/templates/ to every ZIP so ensure_constitution_from_template
  can find constitution-template.md on init
- Add .vscode/settings.json to every ZIP
- Having 3 top-level dirs prevents the extraction flatten heuristic from
  incorrectly stripping the agent config folder (.kimi/, .claude/, etc.)
- Bump version to 0.1.14.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(kimi): use .kimi/skills/<name>/SKILL.md structure for Kimi Code CLI

Kimi Code CLI uses a skills system, not flat command files:
- Skills live in .kimi/skills/<name>/SKILL.md (project-level)
- Invoked with /skill:<name> (e.g. /skill:speckit.specify)
- Each skill is a directory containing SKILL.md with YAML frontmatter

Changes:
- AGENT_CONFIG["kimi"]["commands_subdir"] = "skills" (was "commands")
- create-release-packages.sh: new create_kimi_skills() function creates
  skill directories with SKILL.md from real template content
- Bump version to 0.1.14.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): align kimi commands_subdir assertion with skills structure

* fix: use forward slashes for tabnine path in create-release-packages.ps1

* fix: align kimi to .kimi/skills convention and fix ARGUMENTS unbound variable

* fix: address PR review comments for kimi agent support

  - Fix VERSION_NO_V undefined variable in create-github-release.sh
  - Restore version $1 argument handling in create-release-packages.sh
  - Fix tabnine/vibe/generic cases calling undefined generate_commands
  - Align roo path .roo/rules -> .roo/commands with AGENT_CONFIG
  - Fix kimi extension to use per-skill SKILL.md directory structure
  - Add parent mkdir before dest_file.write_text for nested paths
  - Restore devcontainer tools removed by regression + add Kimi CLI
  - Strengthen test_kimi_in_powershell_validate_set assertion

* fix: restore release scripts and address all PR review comments

  - Restore create-release-packages.sh to original with full generate_commands/
    rewrite_paths logic; add kimi case using create_kimi_skills function
  - Restore create-release-packages.ps1 to original with full Generate-Commands/
    Rewrite-Paths logic; add kimi case using New-KimiSkills function
  - Restore create-github-release.sh to original with proper $1 argument
    handling and VERSION_NO_V; add kimi zip entries
  - Add test_ai_help_includes_kimi for consistency with other agents
  - Strengthen test_kimi_in_powershell_validate_set to check ValidateSet

* fix: address second round of PR review comments

  - Add __AGENT__ and {AGENT_SCRIPT} substitutions in create_kimi_skills (bash)
  - Add __AGENT__ and {AGENT_SCRIPT} substitutions in New-KimiSkills (PowerShell)
  - Replace curl|bash Kimi installer with pipx install kimi-cli in post-create.sh

* fix: align kimi skill naming and add extension registrar test

  - Fix install_ai_skills() to use speckit.<cmd> naming for kimi (dot
    separator) instead of speckit-<cmd>, matching /skill:speckit.<cmd>
    invocation convention and packaging scripts
  - Add test_kimi_in_extension_registrar to verify CommandRegistrar.AGENT_CONFIGS
    includes kimi with correct dir and SKILL.md extension

* fix(test): align kimi skill name assertion with dot-separator convention

  test_skills_install_for_all_agents now expects speckit.specify (dot) for
  kimi and speckit-specify (hyphen) for all other agents, matching the
  install_ai_skills() implementation added in the previous commit.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 07:57:18 -05:00
Ben Lawson
2632a0f52d feat(extensions): support .extensionignore to exclude files during install (#1781)
* feat(extensions): support .extensionignore to exclude files during install

Add .extensionignore support so extension authors can exclude files and
folders from being copied when users run 'specify extension add'.

The file uses glob-style patterns (one per line), supports comments (#),
blank lines, trailing-slash directory patterns, and relative path matching.
The .extensionignore file itself is always excluded from the copy.

- Add _load_extensionignore() to ExtensionManager
- Integrate ignore function into shutil.copytree in install_from_directory
- Document .extensionignore in EXTENSION-DEVELOPMENT-GUIDE.md
- Add 6 tests covering all pattern matching scenarios
- Bump version to 0.1.14

* fix(extensions): use pathspec for gitignore-compatible .extensionignore matching

Replace fnmatch with pathspec.GitIgnoreSpec to get proper .gitignore
semantics where * does not cross directory boundaries. This addresses
review feedback on #1781.

Changes:
- Switch from fnmatch to pathspec>=0.12.0 (GitIgnoreSpec.from_lines)
- Normalize backslashes in patterns for cross-platform compatibility
- Distinguish directories from files for trailing-slash patterns
- Update docs to accurately describe supported pattern semantics
- Add edge-case tests: .., absolute paths, empty file, backslashes,
  * vs ** boundary behavior, and ! negation
- Move changelog entry to [Unreleased] section
2026-03-10 12:02:04 -05:00
Adrián Luján Muñoz
4ab91fbadf feat: add Codex support for extension command registration (#1767)
* feat: add Codex support for extension command registration

* test: add codex command registrar mapping check

* test: add codex consistency check to test_agent_config_consistency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 09:50:42 -05:00
Copilot
ee922cbde9 feat(extensions): support multiple active catalogs simultaneously (#1720)
* Initial plan

* feat(extensions): implement multi-catalog stack support

- Add CatalogEntry dataclass to represent catalog entries
- Add get_active_catalogs() reading SPECKIT_CATALOG_URL, project config,
  user config, or built-in default stack (org-approved + community)
- Add _load_catalog_config() to parse .specify/extension-catalogs.yml
- Add _validate_catalog_url() HTTPS validation helper
- Add _fetch_single_catalog() with per-URL caching, backward-compat for DEFAULT_CATALOG_URL
- Add _get_merged_extensions() that merges all catalogs (priority wins on conflict)
- Update search() and get_extension_info() to use merged results
  annotated with _catalog_name and _install_allowed
- Update clear_cache() to also remove per-URL hash cache files
- Add extension_catalogs CLI command to list active catalogs
- Add catalog add/remove sub-commands for .specify/extension-catalogs.yml
- Update extension_add to enforce install_allowed=false policy
- Update extension_search to show source catalog per result
- Update extension_info to show source catalog with install_allowed status
- Add 13 new tests covering catalog stack, merge conflict resolution,
  install_allowed enforcement, and catalog metadata

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* docs: update RFC, user guide, and API reference for multi-catalog support

- RFC: replace FUTURE FEATURE section with full implementation docs,
  add catalog stack resolution order, config file examples, merge
  conflict resolution, and install_allowed behavior
- EXTENSION-USER-GUIDE.md: add multi-catalog section with CLI examples
  for catalogs/catalog-add/catalog-remove, update catalog config docs
- EXTENSION-API-REFERENCE.md: add CatalogEntry class docs, update
  ExtensionCatalog docs with new methods and result annotations,
  add catalog CLI commands (catalogs, catalog add, catalog remove)

Also fix extension_catalogs command to correctly show "Using built-in
default catalog stack" when config file exists but has empty catalogs

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* Potential fix for pull request finding 'Empty except'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix: remove extraneous f-string prefixes (ruff F541)

Remove f-prefix from strings with no placeholders in catalog_remove
and extension_search commands.

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: address PR review feedback for multi-catalog support

- Rename 'org-approved' catalog to 'default'
- Move 'catalogs' command to 'catalog list' for consistency
- Add 'description' field to CatalogEntry dataclass
- Add --description option to 'catalog add' CLI command
- Align install_allowed default to False in _load_catalog_config
- Add user-level config detection in catalog list footer
- Fix _load_catalog_config docstring (document ValidationError)
- Fix test isolation for test_search_by_tag, test_search_by_query,
  test_search_verified_only, test_get_extension_info
- Update version to 0.1.14 and CHANGELOG
- Update all docs (RFC, User Guide, API Reference)

* fix: wrap _load_catalog_config() calls in catalog_list with try/except

- Check SPECKIT_CATALOG_URL first (matching get_active_catalogs() resolution order)
- Wrap both _load_catalog_config() calls in try/except ValidationError so a
  malformed config file cannot crash `specify extension catalog list` after
  the active catalogs have already been printed successfully

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-03-09 14:30:27 -05:00
Pavel-tabnine
1df24f1953 Pavel/add tabnine cli support (#1503)
* feat: add Tabnine CLI agent support

Tabnine CLI is a Gemini fork that uses TOML commands with the
.tabnine/agent/ directory structure and TABNINE.md context files.

Changes:
- Add 'tabnine' to AGENT_CONFIG in __init__.py
- Update release scripts (bash + PowerShell) for TOML command generation
- Update agent context scripts (bash + PowerShell)
- Add to GitHub release packages
- Update README.md and AGENTS.md documentation
- Bump version to 0.1.14
- Add 8 new tests for cross-file consistency

* fix: add missing generic to agent-context script usage string
2026-03-09 14:04:02 -05:00
Medhat Galal
32c6e7f40c feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
* feat: add kiro-cli and AGENT_CONFIG consistency coverage

* fix: address PR feedback for kiro-cli migration

* test: assert init invocation result in --here mode test

* test: capture init result in here-mode command test

* chore: save local unapproved work in progress

* fix: resolve remaining PR1690 review threads

* Update src/specify_cli/__init__.py

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

* test: reduce brittleness in ai help alias assertion

* fix: resolve PR1690 ruff syntax regression

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-03 12:04:46 -06:00
Ismael
f6264d4ef4 fix: correct Copilot extension command registration (#1724)
* fix: correct Copilot extension command registration (#copilot)

- Use .agent.md extension for commands in .github/agents/
- Generate companion .prompt.md files in .github/prompts/
- Clean up .prompt.md files on extension removal
- Add tests for Copilot-specific registration behavior

Bumps version to 0.1.7.

* fix: test copilot prompt cleanup via ExtensionManager.remove() instead of manual unlink

---------

Co-authored-by: Ismael <ismael.jimenez-martinez@bmw.de>
2026-03-03 09:03:38 -06:00
Manfred Riem
fc3b98ea09 fix: rename Qoder AGENT_CONFIG key from 'qoder' to 'qodercli' to match actual CLI executable (#1651)
* fix: rename Qoder CLI to QoderCLI across scripts and documentation

* Apply suggestion from @Copilot

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

* Apply suggestion from @Copilot

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

* Update .github/workflows/scripts/create-release-packages.ps1

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 13:30:16 -06:00
Michal Bachorik
f14a47ea7d Add modular extension system (#1551)
* Add modular extension system for Spec Kit

Implement a complete extension system that allows third-party developers
to extend Spec Kit functionality through plugins.

## Core Features
- Extension discovery and loading from local and global directories
- YAML-based extension manifest (extension.yml) with metadata and capabilities
- Command extensions: custom slash commands with markdown templates
- Hook system: pre/post hooks for generate, task, and sync operations
- Extension catalog for discovering and installing community extensions
- SPECKIT_CATALOG_URL environment variable for catalog URL override

## Installation Methods
- Catalog install: `specify extension add <name>`
- URL install: `specify extension add <name> --from <url>`
- Dev install: `specify extension add --dev <path>`

## Implementation
- ExtensionManager class for lifecycle management (load, enable, disable)
- Support for extension dependencies and version constraints
- Configuration layering (global → project → extension)
- Hook conditions for conditional execution

## Documentation
- RFC with design rationale and architecture decisions
- API reference for extension developers
- Development guide with examples
- User guide for installing and managing extensions
- Publishing guide for the extension catalog

## Included
- Extension template for bootstrapping new extensions
- Comprehensive test suite
- Example catalog.json structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Jira extension to v2.1.0 in catalog

Adds 2-level mode support (Epic → Stories only).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Address PR review feedback

- Fix Zip Slip vulnerability in ZIP extraction with path validation
- Fix keep_config option to actually preserve config files on removal
- Add URL validation for SPECKIT_CATALOG_URL (HTTPS required, localhost exception)
- Add security warning when installing from custom URLs (--from flag)
- Empty catalog.json so organizations can ship their own catalogs
- Fix markdown linter errors (MD040: add language to code blocks)
- Remove redundant import and fix unused variables in tests
- Add comment explaining empty except clause for backwards compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add comprehensive organization catalog customization docs

- Explain why default catalog is empty (org control)
- Document how to create and host custom catalogs
- Add catalog JSON schema reference
- Include use cases: private extensions, curated catalogs, air-gapped environments
- Add examples for combining catalog with direct installation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix test assertions for extension system data structures

- Update test_config_backup_on_remove to use new subdirectory structure
  (.backup/test-ext/file.yml instead of .backup/test-ext-file.yml)
- Update test_full_install_and_remove_workflow to handle registered_commands
  being a dict keyed by agent name instead of a flat list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Address Copilot review feedback

- Fix localhost URL check to use parsed.hostname instead of netloc.startswith()
  This correctly handles URLs with ports like localhost:8080
- Fix YAML indentation error in config-template.yml (line 57)
- Fix double space typo in example.md (line 172)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add catalog.example.json as reference for organizations

The main catalog.json is intentionally empty so organizations can ship
their own curated catalogs. This example file shows the expected schema
and structure for creating organization-specific catalogs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Address remaining Copilot security and logic review feedback

- Fix Zip Slip vulnerability by using relative_to() for safe path validation
- Add HTTPS validation for extension download URLs
- Backup both *-config.yml and *-config.local.yml files on remove
- Normalize boolean values to lowercase for hook condition comparisons
- Show non-default catalog warning only once per instance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Ignoring linter for extensions directory

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Manfred Riem <manfred.riem@microsoft.com>
2026-02-10 14:27:20 -06:00