Compare commits

..
Author SHA1 Message Date
ae61b31aa5 Add Spec Switcher extension to community catalog
Add switch extension submitted by @wagner-sousa to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3932

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 14:50:28 +00:00
12 changed files with 42 additions and 270 deletions
+2 -2
View File
@@ -22,11 +22,11 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
with:
globs: |
'**/*.md'
-26
View File
@@ -2,32 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.16.1] - 2026-08-07
### Changed
- fix(integrations): wrap a non-UTF-8 catalog response (#4011)
- fix(events): skip an unreadable command template (#3956)
- fix(bundle): wrap malformed YAML in a local .zip bundle manifest (#4013)
- chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#4005)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#4006)
- fix(agent-context): recurse for nested plans in Python mtime fallback (#3757)
- fix: add utf-8 encoding to extension and preset registry file I/O (#3834)
- fix(init): escape user-supplied values in `specify init` output (#3787)
- fix: bound response read in integration catalog fetch (#3812)
- fix: use missing_ok for temp file cleanup to avoid masking errors (#3803)
- fix(workflows): handle an unreadable run state in `workflow status` (#3999)
- feat(init): scaffold managed .specify/.gitignore (#4000)
- fix(scripts): stop check-prerequisites text mode crashing on a legacy stdout code page (#3890)
- fix(presets): return None for an unreadable layer in resolve_content (#3959)
- fix(extensions): start fresh on a non-UTF-8 extension registry (#3998)
- Fix init-force-preset-desync: reapply presets/extensions on init --here --force (#3995)
- fix(skills): apply the line-anchored delimiter scan to hermes and kimi (#3739)
- fix(archives): wrap the bare EOFError a truncated tar.gz raises (#3938)
- test(integrations): guard multiline/control-char SKILL.md frontmatter escaping (#3392)
- fix(scripts): stop setup-tasks text mode crashing on a legacy code page (#3892)
- chore: release 0.16.0, begin 0.16.1.dev0 development (#3992)
## [0.16.0] - 2026-08-05
### Changed
+1
View File
@@ -139,6 +139,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
| Spec Roadmap | Capture a durable spec roadmap after the constitution, then review specs against it before and after implementation so spec-specific decisions, outcomes, and constraints are never lost. | `process` | Read+Write | [speckit-roadmap](https://github.com/srobroek/speckit-roadmap) |
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
| Spec Switcher | Navigate between specs — list available specs and switch active feature with git checkout | `process` | Read+Write | [spec-kit-switch](https://github.com/wagner-sousa/spec-kit-switch) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
+35 -1
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-08-04T00:00:00Z",
"updated_at": "2026-08-07T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"adrkit": {
@@ -4417,6 +4417,40 @@
"created_at": "2026-04-22T00:00:00Z",
"updated_at": "2026-05-30T00:00:00Z"
},
"switch": {
"name": "Spec Switcher",
"id": "switch",
"description": "Navigate between specs — list available specs and switch active feature with git checkout",
"author": "Wagner Sousa",
"version": "1.0.0",
"download_url": "https://github.com/wagner-sousa/spec-kit-switch/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/wagner-sousa/spec-kit-switch",
"homepage": "https://github.com/wagner-sousa/spec-kit-switch",
"documentation": "https://github.com/wagner-sousa/spec-kit-switch#readme",
"changelog": "https://github.com/wagner-sousa/spec-kit-switch/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 2,
"hooks": 0
},
"tags": [
"navigation",
"workflow",
"specs",
"sdd",
"git"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-08-07T00:00:00Z",
"updated_at": "2026-08-07T00:00:00Z"
},
"sync": {
"name": "Spec Sync",
"id": "sync",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.16.1"
version = "0.16.1.dev0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
+1 -11
View File
@@ -771,17 +771,7 @@ def _local_manifest_source(arg: str):
error_type=BundlerError,
label="bundle manifest",
)
try:
data = _yaml.safe_load(io.BytesIO(raw))
except _yaml.YAMLError as exc:
# The sibling directory/bundle.yml branches reach YAML through
# load_yaml(), which turns a parse failure into a BundlerError. This
# branch parses inline, so without this it raises a raw YAMLError --
# neither a ValueError nor an OSError -- which escapes
# bundle_install()'s `except BundlerError` as a traceback.
raise BundlerError(
f"Invalid YAML in bundle.yml inside '{candidate}': {exc}"
) from exc
data = _yaml.safe_load(io.BytesIO(raw))
return BundleManifest.from_dict(data)
if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"):
+1 -9
View File
@@ -548,15 +548,7 @@ def _resolve_event_command_argv(
"""
from .integrations.base import IntegrationBase
try:
content = template_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# An unreadable or undecodable template cannot declare a runnable
# script. Degrade to "no argv" like every other failure in this
# resolver (missing frontmatter, malformed YAML, absent scripts)
# instead of leaking a raw traceback through
# resolve_and_run_event_command.
return None
content = template_path.read_text(encoding="utf-8")
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not m:
return None
-9
View File
@@ -237,15 +237,6 @@ class IntegrationCatalog(CatalogStackBase):
raise IntegrationCatalogError(
f"Failed to fetch catalog from {entry.url}: {exc}"
)
except UnicodeDecodeError as exc:
# A non-UTF-8 response body fails at .decode() before json.loads()
# ever runs, so JSONDecodeError below does not cover it (the two are
# sibling ValueError subclasses, not parent/child). Without this the
# raw UnicodeDecodeError escapes _get_merged_integrations()'s
# "warn and skip this catalog" handler and kills the whole command.
raise IntegrationCatalogError(
f"Catalog from {entry.url} is not valid UTF-8: {exc}"
)
except json.JSONDecodeError as exc:
raise IntegrationCatalogError(
f"Invalid JSON in catalog from {entry.url}: {exc}"
@@ -186,50 +186,6 @@ def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
_local_manifest_source(str(artifact))
def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path):
"""A malformed bundle.yml inside a .zip must raise BundlerError.
The zip branch parses YAML inline rather than through load_yaml(), so the
raw yaml.YAMLError used to escape. It is neither a ValueError nor an
OSError, so nothing upstream caught it.
"""
artifact = tmp_path / "bad-manifest.zip"
with zipfile.ZipFile(artifact, "w") as archive:
archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n")
with pytest.raises(BundlerError, match="Invalid YAML"):
_local_manifest_source(str(artifact))
def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path):
"""`bundle install` reports malformed YAML the same way for all 3 sources.
Directory and bundle.yml sources already exited 1 with an "Invalid YAML"
message; the .zip source dumped a yaml.parser.ParserError traceback.
"""
bad_yaml = "bundle: [unclosed\n id: demo\n"
directory = tmp_path / "dir-src"
directory.mkdir()
(directory / "bundle.yml").write_text(bad_yaml, encoding="utf-8")
manifest_file = tmp_path / "standalone.yml"
manifest_file.write_text(bad_yaml, encoding="utf-8")
artifact = tmp_path / "artifact.zip"
with zipfile.ZipFile(artifact, "w") as archive:
archive.writestr("bundle.yml", bad_yaml)
runner = CliRunner()
for source in (directory, manifest_file, artifact):
result = runner.invoke(app, ["bundle", "install", str(source)])
assert result.exit_code == 1, f"{source.name}: {result.output}"
assert result.exception is None or isinstance(
result.exception, SystemExit
), f"{source.name} leaked {type(result.exception).__name__}"
assert "Invalid YAML" in result.output, f"{source.name}: {result.output}"
def test_invalid_local_manifest_is_rejected_before_project_init(
tmp_path: Path,
monkeypatch,
-47
View File
@@ -1263,53 +1263,6 @@ class TestCommandRunner:
)
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
assert argv is None
def test_unreadable_template_returns_none(self, tmp_path):
"""A command template that cannot be read must resolve to no argv.
Every other failure inside ``_resolve_event_command_argv`` — missing
frontmatter, malformed YAML, absent scripts — degrades to ``None`` so
the dispatcher treats the command as declaring no runnable script.
The initial ``read_text`` was the one step outside that boundary: a
non-UTF-8 template raised a raw ``UnicodeDecodeError`` through
``resolve_and_run_event_command`` and out of ``specify event run``.
"""
from specify_cli.events import _resolve_event_command_argv
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "boot.md").write_bytes(
b"---\ndescription: \"B\xff\xfeoot\"\n---\nBody\n"
)
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
assert argv is None
def test_permission_denied_template_returns_none(self, tmp_path, monkeypatch):
"""The same boundary must cover ``OSError`` (e.g. permission denied).
Mocked rather than chmod-based so the case also holds under
privileged CI where permission bits are not enforced.
"""
from specify_cli.events import _resolve_event_command_argv
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
template = cmd_dir / "boot.md"
template.write_text("---\ndescription: Boot\n---\nBody\n")
original_read_text = Path.read_text
def failing_read_text(self_path, *args, **kwargs):
if self_path == template:
raise PermissionError(13, "Permission denied")
return original_read_text(self_path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", failing_read_text)
argv = _resolve_event_command_argv(template, tmp_path, None)
assert argv is None
def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path):
@@ -392,125 +392,6 @@ class TestCatalogFetch:
with pytest.raises(IntegrationCatalogError, match="exceeds maximum size"):
cat._fetch_single_catalog(entry, force_refresh=True)
def _patch_urlopen_bytes(self, monkeypatch, bodies):
"""Patch urlopen to serve raw *bodies* keyed by URL substring.
Mirrors ``_patch_urlopen`` but passes the bytes through verbatim: these
tests need a body that is not valid UTF-8, which ``json.dumps`` cannot
produce.
"""
class _RawResponse:
def __init__(self, data, url):
self._data = data
self._url = url
self._offset = 0
def read(self, size=-1):
if size == -1:
chunk = self._data[self._offset:]
self._offset = len(self._data)
else:
chunk = self._data[self._offset:self._offset + size]
self._offset += len(chunk)
return chunk
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
for marker, body in bodies.items():
if marker in url:
return _RawResponse(body, url)
raise AssertionError(f"unexpected URL requested: {url}")
import specify_cli.authentication.http as _auth_http
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
def test_fetch_wraps_non_utf8_catalog_response(self, tmp_path, monkeypatch):
"""Regression: a non-UTF-8 response body must raise IntegrationCatalogError.
``.decode("utf-8")`` runs before ``json.loads``, so the resulting
UnicodeDecodeError is not a JSONDecodeError and slipped past both
handlers as a raw traceback.
"""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir(exist_ok=True)
cat = IntegrationCatalog(tmp_path)
self._patch_urlopen_bytes(
monkeypatch,
{"catalog.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}'},
)
entry = IntegrationCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
)
with pytest.raises(IntegrationCatalogError, match="not valid UTF-8"):
cat._fetch_single_catalog(entry, force_refresh=True)
def test_search_skips_non_utf8_catalog(self, tmp_path, monkeypatch, capsys):
"""A single non-UTF-8 catalog must not take down the whole search.
``_get_merged_integrations`` is built to warn and continue on a bad
catalog; an unwrapped UnicodeDecodeError defeated that entirely.
"""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
specify = tmp_path / ".specify"
specify.mkdir(exist_ok=True)
(specify / "integration-catalogs.yml").write_text(
"catalogs:\n"
" - name: broken\n"
" url: https://example.com/broken.json\n"
" priority: 1\n"
" - name: healthy\n"
" url: https://example.com/healthy.json\n"
" priority: 2\n",
encoding="utf-8",
)
healthy = json.dumps(
{
"schema_version": "1.0",
"integrations": {
"acme-coder": {
"name": "Acme Coder",
"version": "1.0.0",
"description": "Acme integration",
}
},
}
).encode("utf-8")
self._patch_urlopen_bytes(
monkeypatch,
{
"broken.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}',
"healthy.json": healthy,
},
)
cat = IntegrationCatalog(tmp_path)
results = cat.search()
assert "acme-coder" in [r["id"] for r in results]
assert "broken" in capsys.readouterr().err
def test_search_by_tag(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))