mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 23:59:55 +08:00
Compare commits
1 Commits
add-figma-
...
v0.12.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14ccb8641 |
@@ -116,7 +116,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
|
||||
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
|
||||
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-08T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1213,47 +1213,6 @@
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-04-23T00:00:00Z"
|
||||
},
|
||||
"figma": {
|
||||
"name": "Spec Kit Figma",
|
||||
"id": "figma",
|
||||
"description": "Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows.",
|
||||
"author": "Fyloss",
|
||||
"version": "1.6.0",
|
||||
"download_url": "https://github.com/Fyloss/spec-kit-figma/archive/refs/tags/v1.6.0.zip",
|
||||
"repository": "https://github.com/Fyloss/spec-kit-figma",
|
||||
"homepage": "https://github.com/Fyloss/spec-kit-figma",
|
||||
"documentation": "https://github.com/Fyloss/spec-kit-figma/blob/main/docs/INSTALL.md",
|
||||
"changelog": "https://github.com/Fyloss/spec-kit-figma/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "integration",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{ "name": "git", "required": true },
|
||||
{ "name": "bash", "required": false },
|
||||
{ "name": "curl", "required": false },
|
||||
{ "name": "jq", "required": false },
|
||||
{ "name": "pwsh", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 6
|
||||
},
|
||||
"tags": [
|
||||
"figma",
|
||||
"design",
|
||||
"frontend",
|
||||
"ui",
|
||||
"design-system"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-08T00:00:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z"
|
||||
},
|
||||
"fix-findings": {
|
||||
"name": "Fix Findings",
|
||||
"id": "fix-findings",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.8.dev0"
|
||||
version = "0.12.7"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -127,14 +127,7 @@ def resolve_github_release_asset_api_url(
|
||||
if hostname == "github.com":
|
||||
api_base = "https://api.github.com"
|
||||
elif is_ghes:
|
||||
# ``parsed.port`` raises ValueError on a malformed port (e.g.
|
||||
# ``host:notaport``); the function's contract is to return None for
|
||||
# anything it can't resolve, not to raise.
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
authority = hostname if port is None else f"{hostname}:{port}"
|
||||
authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
|
||||
api_base = f"{parsed.scheme}://{authority}/api/v3"
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -73,13 +73,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
self._redirect_validator = redirect_validator
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
try:
|
||||
new_parsed = urlparse(newurl)
|
||||
except ValueError as exc:
|
||||
# Malformed redirect target (e.g. unterminated IPv6 bracket).
|
||||
# Surface as URLError so callers' download error handling applies.
|
||||
raise urllib.error.URLError(f"malformed redirect URL: {exc}") from exc
|
||||
|
||||
if self._redirect_validator is not None:
|
||||
self._redirect_validator(req.full_url, newurl)
|
||||
|
||||
@@ -90,6 +83,7 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_req is not None:
|
||||
old_scheme = urlparse(req.full_url).scheme
|
||||
new_parsed = urlparse(newurl)
|
||||
hostname = (new_parsed.hostname or "").lower()
|
||||
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
|
||||
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
|
||||
|
||||
@@ -426,11 +426,7 @@ def extension_add(
|
||||
if from_url and not dev:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
parsed = urlparse(from_url)
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
parsed = urlparse(from_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
|
||||
@@ -309,14 +309,7 @@ class IntegrationManifest:
|
||||
if abs_path.is_symlink() or not abs_path.is_file():
|
||||
modified.append(rel)
|
||||
continue
|
||||
try:
|
||||
changed = _sha256(abs_path) != expected_hash
|
||||
except OSError:
|
||||
# Unreadable regular file (e.g. permission denied): treat as
|
||||
# modified, consistent with the symlink / non-regular-file
|
||||
# handling above, rather than letting the OSError escape.
|
||||
changed = True
|
||||
if changed:
|
||||
if _sha256(abs_path) != expected_hash:
|
||||
modified.append(rel)
|
||||
return modified
|
||||
|
||||
@@ -365,17 +358,9 @@ class IntegrationManifest:
|
||||
skipped.append(path)
|
||||
continue
|
||||
else:
|
||||
if not force:
|
||||
try:
|
||||
matches = _sha256(path) == expected_hash
|
||||
except OSError:
|
||||
# Unreadable: can't verify it's ours, so preserve it
|
||||
# (mirrors the path.unlink() OSError guard below).
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not matches:
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not force and _sha256(path) != expected_hash:
|
||||
skipped.append(path)
|
||||
continue
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
|
||||
@@ -104,13 +104,7 @@ def preset_add(
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
try:
|
||||
_parsed = _urlparse(from_url)
|
||||
except ValueError:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
_parsed = _urlparse(from_url)
|
||||
|
||||
def _is_allowed_download_url(parsed_url):
|
||||
host = parsed_url.hostname
|
||||
@@ -141,9 +135,7 @@ def preset_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
from rich.markup import escape as _esc
|
||||
|
||||
console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
|
||||
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
||||
import urllib.error
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
@@ -631,11 +631,7 @@ def workflow_add(
|
||||
from urllib.parse import urlparse
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
try:
|
||||
parsed_src = urlparse(source)
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(source)}")
|
||||
raise typer.Exit(1)
|
||||
parsed_src = urlparse(source)
|
||||
src_host = parsed_src.hostname or ""
|
||||
src_loopback = src_host == "localhost"
|
||||
if not src_loopback:
|
||||
|
||||
@@ -481,40 +481,3 @@ class TestRecordExistingNewGuards:
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
|
||||
m.record_existing("dir/../file.txt")
|
||||
|
||||
|
||||
class TestManifestUnreadableFile:
|
||||
"""A managed file that is unreadable (e.g. PermissionError) must not crash
|
||||
check_modified()/uninstall() — the CLI handlers surfaced a raw traceback."""
|
||||
|
||||
def _mk(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("sub/f.md", "content")
|
||||
return m
|
||||
|
||||
def test_check_modified_treats_unreadable_as_modified(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
# Before the fix this raised PermissionError.
|
||||
assert m.check_modified() == ["sub/f.md"]
|
||||
|
||||
def test_uninstall_preserves_unreadable_file(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
removed, skipped = m.uninstall(force=False)
|
||||
# Can't verify ownership => preserve, don't crash and don't delete.
|
||||
assert removed == []
|
||||
assert (tmp_path / "sub" / "f.md") in skipped
|
||||
assert (tmp_path / "sub" / "f.md").exists()
|
||||
|
||||
@@ -845,22 +845,6 @@ class TestRedirectStripping:
|
||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
||||
assert auth3 == "Bearer tok"
|
||||
|
||||
def test_malformed_redirect_url_raises_urlerror_not_valueerror(self):
|
||||
"""A redirect to a malformed URL (unterminated IPv6 bracket) surfaces
|
||||
as URLError, which download paths already handle, rather than an
|
||||
unhandled ValueError traceback."""
|
||||
import urllib.error
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo")
|
||||
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://[::1/asset")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_latest_release_tag delegation
|
||||
|
||||
@@ -5441,29 +5441,6 @@ class TestExtensionAddCLI:
|
||||
f"confirm must precede spinner, got: {call_order}"
|
||||
assert result.exit_code == 0 # user declined → clean exit
|
||||
|
||||
def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://[::1/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Invalid URL" in plain
|
||||
|
||||
def test_add_status_escapes_extension_markup(self, tmp_path):
|
||||
"""User-controlled extension names must not be parsed as Rich markup."""
|
||||
from rich.markup import escape as escape_markup
|
||||
|
||||
@@ -233,23 +233,6 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_returns_none_on_malformed_ghes_port(self):
|
||||
"""A malformed port on an allowlisted GHES host returns None, not a
|
||||
ValueError (contract: resolve or return None, never raise)."""
|
||||
called = []
|
||||
|
||||
def open_never(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
raise AssertionError("open_url_fn must not be called")
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example:notaport/o/r/releases/download/v1/ext.zip",
|
||||
open_never,
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
||||
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
||||
not allowlisted: passthrough issues no API request, and the download
|
||||
|
||||
@@ -4538,27 +4538,6 @@ class TestBundledPresetLocator:
|
||||
assert "got https://" not in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url") as open_url:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["preset", "add", "--from", "https://[::1/preset.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid URL" in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
|
||||
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
|
||||
import typer
|
||||
|
||||
@@ -5593,23 +5593,6 @@ class TestWorkflowRemoveGuard:
|
||||
|
||||
|
||||
class TestWorkflowAddSymlinkGuard:
|
||||
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify").mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["workflow", "add", "https://[::1/wf.yaml"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Invalid URL" in result.output
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
|
||||
"""workflow add must refuse a symlinked .specify (writes could escape root)."""
|
||||
|
||||
Reference in New Issue
Block a user