mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587feaac13 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,21 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.8.11] - 2026-05-15
|
||||
|
||||
### Changed
|
||||
|
||||
- refactor: extract _version.py from __init__.py (PR-3/8) (#2550)
|
||||
- Add Time Machine extension to community catalog (#2580)
|
||||
- fix(powershell): ensure UTF-8 templates are written without BOM (#2280)
|
||||
- docs: document high-assurance spec workflow (#2518)
|
||||
- docs: fix script name in directory tree examples (#2555)
|
||||
- Fix preset skill description precedence (#2538)
|
||||
- fix(integration): clarify multi-install guidance (#2549)
|
||||
- feat: add version feature reporting (#2548)
|
||||
- Add Architecture Workflow extension to community catalog (#2565)
|
||||
- chore: release 0.8.10, begin 0.8.11.dev0 development (#2562)
|
||||
|
||||
## [0.8.10] - 2026-05-14
|
||||
|
||||
### Changed
|
||||
|
||||
22
README.md
22
README.md
@@ -404,12 +404,11 @@ At this stage, your project folder contents should resemble the following:
|
||||
├── memory
|
||||
│ └── constitution.md
|
||||
├── scripts
|
||||
│ └── bash
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── setup-tasks.sh
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── update-claude-md.sh
|
||||
├── specs
|
||||
│ └── 001-create-taskify
|
||||
│ └── spec.md
|
||||
@@ -466,12 +465,11 @@ The output of this step will include a number of implementation detail documents
|
||||
├── memory
|
||||
│ └── constitution.md
|
||||
├── scripts
|
||||
│ └── bash
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── setup-tasks.sh
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── update-claude-md.sh
|
||||
├── specs
|
||||
│ └── 001-create-taskify
|
||||
│ ├── contracts
|
||||
|
||||
@@ -28,7 +28,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Architecture Workflow | Generate project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
@@ -110,7 +109,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
|
||||
@@ -94,10 +94,7 @@ After initialization, you should see the following commands available in your co
|
||||
- `/speckit.plan` - Generate implementation plans
|
||||
- `/speckit.tasks` - Break down into actionable tasks
|
||||
|
||||
Scripts are installed into a variant subdirectory matching the chosen script type:
|
||||
|
||||
- `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS)
|
||||
- `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows)
|
||||
The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -5,19 +5,11 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
|
||||
> [!NOTE]
|
||||
> All automation scripts now provide both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on OS unless you pass `--script sh|ps`.
|
||||
|
||||
## Recommended Workflow
|
||||
## The 6-Step Process
|
||||
|
||||
> [!TIP]
|
||||
> **Context Awareness**: Spec Kit commands automatically detect the active feature based on your current Git branch (e.g., `001-feature-name`). To switch between different specifications, simply switch Git branches.
|
||||
|
||||
After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates:
|
||||
|
||||
```text
|
||||
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.checklist -> /speckit.plan -> /speckit.tasks -> /speckit.analyze -> /speckit.implement
|
||||
```
|
||||
|
||||
Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` to validate requirements quality before planning, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted.
|
||||
|
||||
### Step 1: Install Specify
|
||||
|
||||
**In your terminal**, run the `specify` CLI command to initialize your project:
|
||||
@@ -32,13 +24,10 @@ uvx --from git+https://github.com/github/spec-kit.git specify init .
|
||||
|
||||
> [!NOTE]
|
||||
> You can also install the CLI persistently with `pipx`:
|
||||
>
|
||||
> ```bash
|
||||
> pipx install git+https://github.com/github/spec-kit.git
|
||||
> ```
|
||||
>
|
||||
> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example:
|
||||
>
|
||||
> ```bash
|
||||
> specify init <PROJECT_NAME>
|
||||
> specify init .
|
||||
@@ -67,7 +56,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
|
||||
```
|
||||
|
||||
### Step 4: Refine and Validate the Spec
|
||||
### Step 4: Refine the Spec
|
||||
|
||||
**In the chat**, use the `/speckit.clarify` slash command to identify and resolve ambiguities in your specification. You can provide specific focus areas as arguments.
|
||||
|
||||
@@ -75,12 +64,6 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.clarify Focus on security and performance requirements.
|
||||
```
|
||||
|
||||
Then validate the requirements with `/speckit.checklist` before creating the technical plan:
|
||||
|
||||
```bash
|
||||
/speckit.checklist
|
||||
```
|
||||
|
||||
### Step 5: Create a Technical Implementation Plan
|
||||
|
||||
**In the chat**, use the `/speckit.plan` slash command to provide your tech stack and architecture choices.
|
||||
@@ -89,7 +72,7 @@ Then validate the requirements with `/speckit.checklist` before creating the tec
|
||||
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
|
||||
```
|
||||
|
||||
### Step 6: Break Down, Analyze, and Implement
|
||||
### Step 6: Break Down and Implement
|
||||
|
||||
**In the chat**, use the `/speckit.tasks` slash command to create an actionable task list.
|
||||
|
||||
@@ -97,13 +80,13 @@ Then validate the requirements with `/speckit.checklist` before creating the tec
|
||||
/speckit.tasks
|
||||
```
|
||||
|
||||
Validate cross-artifact consistency with `/speckit.analyze` before implementation:
|
||||
Optionally, validate the plan with `/speckit.analyze`:
|
||||
|
||||
```markdown
|
||||
/speckit.analyze
|
||||
```
|
||||
|
||||
Use the `/speckit.implement` slash command to execute the plan.
|
||||
Then, use the `/speckit.implement` slash command to execute the plan.
|
||||
|
||||
```markdown
|
||||
/speckit.implement
|
||||
@@ -176,7 +159,7 @@ Generate an actionable task list using the `/speckit.tasks` command:
|
||||
|
||||
### Step 7: Validate and Implement
|
||||
|
||||
Have your coding agent audit the spec, plan, and tasks with `/speckit.analyze` before implementation:
|
||||
Have your coding agent audit the implementation plan using `/speckit.analyze`:
|
||||
|
||||
```bash
|
||||
/speckit.analyze
|
||||
@@ -196,7 +179,7 @@ Finally, implement the solution:
|
||||
- **Be explicit** about what you're building and why
|
||||
- **Don't focus on tech stack** during specification phase
|
||||
- **Iterate and refine** your specifications before implementation
|
||||
- **Validate** requirements and plans before coding begins
|
||||
- **Validate** the plan before coding begins
|
||||
- **Let the coding agent handle** the implementation details
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -77,16 +77,6 @@ specify version
|
||||
|
||||
Displays the Spec Kit CLI version, Python version, platform, and architecture.
|
||||
|
||||
To inspect local CLI capabilities without checking the network:
|
||||
|
||||
```bash
|
||||
specify version --features
|
||||
specify version --features --json
|
||||
```
|
||||
|
||||
The JSON form is intended for scripts and coding agents that need to choose a
|
||||
workflow based on the installed CLI's supported features.
|
||||
|
||||
A quick version check is also available via:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-14T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -174,37 +174,6 @@
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"updated_at": "2026-05-07T00:00:00Z"
|
||||
},
|
||||
"arch": {
|
||||
"name": "Architecture Workflow",
|
||||
"id": "arch",
|
||||
"description": "Generate project-level 4+1 architecture view artifacts and synthesis",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-arch/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"4plus1",
|
||||
"workflow",
|
||||
"design"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-05-14T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
"id": "architect-preview",
|
||||
@@ -2885,43 +2854,6 @@
|
||||
"created_at": "2026-03-02T00:00:00Z",
|
||||
"updated_at": "2026-03-02T00:00:00Z"
|
||||
},
|
||||
"time-machine": {
|
||||
"name": "Time Machine",
|
||||
"id": "time-machine",
|
||||
"description": "Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature",
|
||||
"author": "te3yo",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/teeyo/spec-kit-time-machine/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"homepage": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"documentation": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"changelog": "https://github.com/teeyo/spec-kit-time-machine/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"brownfield",
|
||||
"automation",
|
||||
"workflow",
|
||||
"process"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"tinyspec": {
|
||||
"name": "TinySpec",
|
||||
"id": "tinyspec",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.11"
|
||||
version = "0.8.10"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -350,10 +350,7 @@ if (-not $DryRun) {
|
||||
if (-not (Test-Path -PathType Leaf $specFile)) {
|
||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
if ($template -and (Test-Path $template)) {
|
||||
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
|
||||
Copy-Item $template $specFile -Force
|
||||
} else {
|
||||
New-Item -ItemType File -Path $specFile -Force | Out-Null
|
||||
}
|
||||
|
||||
@@ -36,10 +36,8 @@ New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
# Copy plan template if it exists, otherwise note it or create empty file
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (Test-Path $template)) {
|
||||
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
|
||||
Copy-Item $template $paths.IMPL_PLAN -Force
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
} else {
|
||||
Write-Warning "Plan template not found"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
|
||||
@@ -32,9 +32,12 @@ import zipfile
|
||||
import shutil
|
||||
import json
|
||||
import shlex
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
@@ -92,12 +95,8 @@ from ._utils import (
|
||||
merge_json_files as merge_json_files,
|
||||
run_command as run_command,
|
||||
)
|
||||
from ._version import (
|
||||
GITHUB_API_LATEST as GITHUB_API_LATEST,
|
||||
self_app as _self_app,
|
||||
self_check as self_check,
|
||||
self_upgrade as self_upgrade,
|
||||
)
|
||||
|
||||
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
||||
|
||||
def _build_agent_config() -> dict[str, dict[str, Any]]:
|
||||
"""Derive AGENT_CONFIG from INTEGRATION_REGISTRY."""
|
||||
@@ -230,10 +229,9 @@ def _install_shared_infra(
|
||||
) -> bool:
|
||||
"""Install shared infrastructure files into *project_path*.
|
||||
|
||||
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
||||
the bundled core_pack or source checkout, where ``<variant>`` is
|
||||
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is
|
||||
``"ps"``. Tracks all installed files in ``speckit.manifest.json``.
|
||||
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
|
||||
bundled core_pack or source checkout. Tracks all installed files
|
||||
in ``speckit.manifest.json``.
|
||||
|
||||
Page templates are processed to resolve ``__SPECKIT_COMMAND_<NAME>__``
|
||||
placeholders using *invoke_separator* (``"."`` for markdown agents,
|
||||
@@ -1157,59 +1155,15 @@ def check():
|
||||
if not any(agent_results.values()):
|
||||
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
|
||||
|
||||
|
||||
def _feature_capabilities() -> dict[str, bool]:
|
||||
"""Return stable local CLI capability flags for humans and agents."""
|
||||
return {
|
||||
"controlled_multi_install_integrations": True,
|
||||
"integration_use_command": True,
|
||||
"multi_install_safe_registry_metadata": True,
|
||||
"integration_upgrade_command": True,
|
||||
"self_check_command": True,
|
||||
"workflow_catalog": True,
|
||||
"bundled_templates": True,
|
||||
}
|
||||
|
||||
|
||||
@app.command()
|
||||
def version(
|
||||
features: bool = typer.Option(
|
||||
False,
|
||||
"--features",
|
||||
help="Show local CLI feature capabilities.",
|
||||
),
|
||||
json_output: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
help="Emit feature capabilities as JSON. Requires --features.",
|
||||
),
|
||||
):
|
||||
def version():
|
||||
"""Display version and system information."""
|
||||
import platform
|
||||
|
||||
cli_version = get_speckit_version()
|
||||
|
||||
if json_output and not features:
|
||||
console.print("[red]Error:[/red] --json requires --features.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if features:
|
||||
capabilities = _feature_capabilities()
|
||||
if json_output:
|
||||
payload = {"version": cli_version, "features": capabilities}
|
||||
console.print(json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
console.print(f"Spec Kit CLI: {cli_version}")
|
||||
console.print()
|
||||
console.print("Features:")
|
||||
for key, enabled in capabilities.items():
|
||||
label = key.replace("_", " ")
|
||||
console.print(f"- {label}: {'yes' if enabled else 'no'}")
|
||||
return
|
||||
|
||||
show_banner()
|
||||
|
||||
cli_version = get_speckit_version()
|
||||
|
||||
info_table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
info_table.add_column("Key", style="cyan", justify="right")
|
||||
info_table.add_column("Value", style="white")
|
||||
@@ -1231,7 +1185,156 @@ def version(
|
||||
console.print(panel)
|
||||
console.print()
|
||||
|
||||
app.add_typer(_self_app, name="self")
|
||||
def _get_installed_version() -> str:
|
||||
"""Return the installed specify-cli distribution version or 'unknown'.
|
||||
|
||||
Uses importlib.metadata so the value reflects what was actually installed
|
||||
by pip/uv/pipx — not a value read from pyproject.toml. This is
|
||||
intentional for `specify self check`, which should reason about the
|
||||
installed distribution rather than a source-tree fallback. Callers must
|
||||
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
metadata_errors = [importlib.metadata.PackageNotFoundError]
|
||||
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
||||
if invalid_metadata_error is not None:
|
||||
metadata_errors.append(invalid_metadata_error)
|
||||
|
||||
try:
|
||||
return importlib.metadata.version("specify-cli")
|
||||
except tuple(metadata_errors):
|
||||
return "unknown"
|
||||
|
||||
def _normalize_tag(tag: str) -> str:
|
||||
"""Strip exactly one leading 'v' from a release tag.
|
||||
|
||||
Returns the rest of the string unchanged. This handles the common
|
||||
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
|
||||
aggressively (e.g., two leading 'v's keeps one).
|
||||
"""
|
||||
return tag[1:] if tag.startswith("v") else tag
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
|
||||
|
||||
Returns False whenever either side is 'unknown' or fails to parse; this
|
||||
keeps the comparison indeterminate (rather than crashing or falsely
|
||||
recommending a downgrade) on edge inputs.
|
||||
"""
|
||||
if latest == "unknown" or current == "unknown":
|
||||
return False
|
||||
try:
|
||||
return Version(latest) > Version(current)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
|
||||
|
||||
On success: (tag_name, None).
|
||||
On a documented network/HTTP failure (added in T029/T030): (None, category).
|
||||
On anything else — including a malformed response body — the exception
|
||||
propagates; there is no catch-all (research D-006).
|
||||
"""
|
||||
from .authentication.http import open_url
|
||||
|
||||
try:
|
||||
with open_url(
|
||||
GITHUB_API_LATEST,
|
||||
timeout=5,
|
||||
extra_headers={"Accept": "application/vnd.github+json"},
|
||||
) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
raise ValueError("GitHub API response missing valid tag_name")
|
||||
return tag, None
|
||||
except urllib.error.HTTPError as e:
|
||||
# Order matters: HTTPError is a subclass of URLError.
|
||||
if e.code == 403:
|
||||
return None, (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
return None, f"HTTP {e.code}"
|
||||
except (urllib.error.URLError, OSError):
|
||||
return None, "offline or timeout"
|
||||
|
||||
|
||||
# ===== Self Commands =====
|
||||
self_app = typer.Typer(
|
||||
name="self",
|
||||
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
|
||||
add_completion=False,
|
||||
)
|
||||
app.add_typer(self_app, name="self")
|
||||
|
||||
@self_app.command("check")
|
||||
def self_check() -> None:
|
||||
"""Check whether a newer specify-cli release is available. Read-only.
|
||||
|
||||
This command only checks for updates; it does not modify your installation.
|
||||
The reserved (and currently non-destructive) `specify self upgrade` command
|
||||
is the name that a future release will use for actual self-upgrade — its
|
||||
behavior is not implemented in this release and is intentionally out of
|
||||
scope here. See `specify self upgrade --help` for its current status.
|
||||
"""
|
||||
|
||||
installed = _get_installed_version()
|
||||
tag, failure_reason = _fetch_latest_release_tag()
|
||||
|
||||
if tag is None:
|
||||
# Graceful-failure path (FR-008). `failure_reason` is one of the
|
||||
# enumerated strings produced by _fetch_latest_release_tag() — it
|
||||
# never contains a URL, headers, response body, or traceback.
|
||||
assert failure_reason is not None
|
||||
console.print(f"Installed: {installed}")
|
||||
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
|
||||
return
|
||||
|
||||
latest_normalized = _normalize_tag(tag)
|
||||
|
||||
if installed == "unknown":
|
||||
# FR-020: surface the latest release and the recovery action even
|
||||
# when the local distribution metadata is unavailable.
|
||||
console.print("Current version could not be determined.")
|
||||
console.print(f"Latest release: {latest_normalized}")
|
||||
console.print("\nTo reinstall:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
if _is_newer(latest_normalized, installed):
|
||||
console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}")
|
||||
console.print("\nTo upgrade:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
# Installed is parseable AND is >= latest → "up to date" (FR-006).
|
||||
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
|
||||
# returns False, and the up-to-date branch is the safer default per
|
||||
# FR-004 / test T016.
|
||||
console.print(f"[green]Up to date:[/green] {installed}")
|
||||
|
||||
|
||||
@self_app.command("upgrade")
|
||||
def self_upgrade() -> None:
|
||||
"""Reserved command surface for self-upgrade; not implemented in this release.
|
||||
|
||||
This command is a documented non-destructive stub in this release: it
|
||||
performs no outbound network request, no install-method detection, and
|
||||
invokes no installer. It prints a three-line guidance message and exits 0.
|
||||
Actual self-upgrade is planned as follow-up work.
|
||||
|
||||
Use `specify self check` today to see whether a newer release is available
|
||||
and to get a copy-pasteable reinstall command.
|
||||
"""
|
||||
console.print("specify self upgrade is not implemented yet.")
|
||||
console.print("Run 'specify self check' to see whether a newer release is available.")
|
||||
console.print("Actual self-upgrade is planned as follow-up work.")
|
||||
|
||||
|
||||
# ===== Extension Commands =====
|
||||
@@ -1593,18 +1696,10 @@ def integration_install(
|
||||
|
||||
if key in installed_keys:
|
||||
console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]")
|
||||
if default_key == key:
|
||||
console.print("It is already the default integration.")
|
||||
else:
|
||||
console.print(
|
||||
f"To make it the default integration, run "
|
||||
f"[cyan]specify integration use {key}[/cyan]."
|
||||
)
|
||||
console.print(
|
||||
f"To refresh its managed files or options, run "
|
||||
f"[cyan]specify integration upgrade {key}[/cyan]."
|
||||
f"Run [cyan]specify integration upgrade {key}[/cyan] to reinstall managed files, "
|
||||
f"or [cyan]specify integration uninstall {key}[/cyan] first."
|
||||
)
|
||||
console.print("No files were changed.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
if installed_keys and not force:
|
||||
@@ -1624,12 +1719,8 @@ def integration_install(
|
||||
"integrations are declared multi-install safe."
|
||||
)
|
||||
console.print(
|
||||
f"To replace the default integration, run "
|
||||
f"[cyan]specify integration switch {key}[/cyan]."
|
||||
)
|
||||
console.print(
|
||||
f"To install '{key}' alongside the existing integrations anyway, "
|
||||
"retry the same install command with [cyan]--force[/cyan]."
|
||||
f"Run [cyan]specify integration switch {key}[/cyan] to replace the default "
|
||||
f"integration, or retry with [cyan]--force[/cyan] to opt in."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Version checking and self-update commands for specify_cli.
|
||||
|
||||
Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
|
||||
release tag. The ``self_app`` Typer sub-command group is co-located here so
|
||||
all version-related logic lives in one place.
|
||||
|
||||
Dependencies: stdlib + packaging + ._console only (no other internal imports
|
||||
at module level, keeping this layer thin and circular-import-safe).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
import typer
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from ._console import console
|
||||
|
||||
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
||||
|
||||
|
||||
def _get_installed_version() -> str:
|
||||
"""Return the installed specify-cli distribution version or 'unknown'.
|
||||
|
||||
Uses importlib.metadata so the value reflects what was actually installed
|
||||
by pip/uv/pipx — not a value read from pyproject.toml. This is
|
||||
intentional for `specify self check`, which should reason about the
|
||||
installed distribution rather than a source-tree fallback. Callers must
|
||||
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
|
||||
"""
|
||||
import importlib.metadata
|
||||
|
||||
metadata_errors = [importlib.metadata.PackageNotFoundError]
|
||||
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
||||
if invalid_metadata_error is not None:
|
||||
metadata_errors.append(invalid_metadata_error)
|
||||
|
||||
try:
|
||||
return importlib.metadata.version("specify-cli")
|
||||
except tuple(metadata_errors):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _normalize_tag(tag: str) -> str:
|
||||
"""Strip exactly one leading 'v' from a release tag.
|
||||
|
||||
Returns the rest of the string unchanged. This handles the common
|
||||
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
|
||||
aggressively (e.g., two leading 'v's keeps one).
|
||||
"""
|
||||
return tag[1:] if tag.startswith("v") else tag
|
||||
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
|
||||
|
||||
Returns False whenever either side is 'unknown' or fails to parse; this
|
||||
keeps the comparison indeterminate (rather than crashing or falsely
|
||||
recommending a downgrade) on edge inputs.
|
||||
"""
|
||||
if latest == "unknown" or current == "unknown":
|
||||
return False
|
||||
try:
|
||||
return Version(latest) > Version(current)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
|
||||
|
||||
On success: (tag_name, None).
|
||||
On a documented network/HTTP failure (added in T029/T030): (None, category).
|
||||
On anything else — including a malformed response body — the exception
|
||||
propagates; there is no catch-all (research D-006).
|
||||
"""
|
||||
from .authentication.http import open_url
|
||||
|
||||
try:
|
||||
with open_url(
|
||||
GITHUB_API_LATEST,
|
||||
timeout=5,
|
||||
extra_headers={"Accept": "application/vnd.github+json"},
|
||||
) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
raise ValueError("GitHub API response missing valid tag_name")
|
||||
return tag, None
|
||||
except urllib.error.HTTPError as e:
|
||||
# Order matters: HTTPError is a subclass of URLError.
|
||||
if e.code == 403:
|
||||
return None, (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
return None, f"HTTP {e.code}"
|
||||
except (urllib.error.URLError, OSError):
|
||||
return None, "offline or timeout"
|
||||
|
||||
|
||||
# ===== Self Commands =====
|
||||
|
||||
self_app = typer.Typer(
|
||||
name="self",
|
||||
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
|
||||
@self_app.command("check")
|
||||
def self_check() -> None:
|
||||
"""Check whether a newer specify-cli release is available. Read-only.
|
||||
|
||||
This command only checks for updates; it does not modify your installation.
|
||||
The reserved (and currently non-destructive) `specify self upgrade` command
|
||||
is the name that a future release will use for actual self-upgrade — its
|
||||
behavior is not implemented in this release and is intentionally out of
|
||||
scope here. See `specify self upgrade --help` for its current status.
|
||||
"""
|
||||
installed = _get_installed_version()
|
||||
tag, failure_reason = _fetch_latest_release_tag()
|
||||
|
||||
if tag is None:
|
||||
# Graceful-failure path (FR-008). `failure_reason` is one of the
|
||||
# enumerated strings produced by _fetch_latest_release_tag() — it
|
||||
# never contains a URL, headers, response body, or traceback.
|
||||
assert failure_reason is not None
|
||||
console.print(f"Installed: {installed}")
|
||||
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
|
||||
return
|
||||
|
||||
latest_normalized = _normalize_tag(tag)
|
||||
|
||||
if installed == "unknown":
|
||||
# FR-020: surface the latest release and the recovery action even
|
||||
# when the local distribution metadata is unavailable.
|
||||
console.print("Current version could not be determined.")
|
||||
console.print(f"Latest release: {latest_normalized}")
|
||||
console.print("\nTo reinstall:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
if _is_newer(latest_normalized, installed):
|
||||
console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}")
|
||||
console.print("\nTo upgrade:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
# Installed is parseable AND is >= latest → "up to date" (FR-006).
|
||||
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
|
||||
# returns False, and the up-to-date branch is the safer default per
|
||||
# FR-004 / test T016.
|
||||
console.print(f"[green]Up to date:[/green] {installed}")
|
||||
|
||||
|
||||
@self_app.command("upgrade")
|
||||
def self_upgrade() -> None:
|
||||
"""Reserved command surface for self-upgrade; not implemented in this release.
|
||||
|
||||
This command is a documented non-destructive stub in this release: it
|
||||
performs no outbound network request, no install-method detection, and
|
||||
invokes no installer. It prints a three-line guidance message and exits 0.
|
||||
Actual self-upgrade is planned as follow-up work.
|
||||
|
||||
Use `specify self check` today to see whether a newer release is available
|
||||
and to get a copy-pasteable reinstall command.
|
||||
"""
|
||||
console.print("specify self upgrade is not implemented yet.")
|
||||
console.print("Run 'specify self check' to see whether a newer release is available.")
|
||||
console.print("Actual self-upgrade is planned as follow-up work.")
|
||||
@@ -1048,9 +1048,9 @@ class PresetManager:
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
desc = fm.get("description", "") or SKILL_DESCRIPTIONS.get(
|
||||
desc = SKILL_DESCRIPTIONS.get(
|
||||
short_name.replace(".", "-"),
|
||||
f"Command: {short_name}",
|
||||
fm.get("description", f"Command: {short_name}"),
|
||||
)
|
||||
init_opts = load_init_options(self.project_root)
|
||||
selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else ""
|
||||
@@ -1314,9 +1314,9 @@ class PresetManager:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
f"Spec-kit workflow command: {short_name}",
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
frontmatter = dict(frontmatter)
|
||||
frontmatter["description"] = enhanced_desc
|
||||
@@ -1417,9 +1417,9 @@ class PresetManager:
|
||||
)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
f"Spec-kit workflow command: {short_name}",
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
|
||||
frontmatter_data = registrar.build_skill_frontmatter(
|
||||
|
||||
@@ -163,30 +163,7 @@ class TestIntegrationInstall:
|
||||
assert "already installed" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "specify integration upgrade copilot" in normalized
|
||||
assert "already the default integration" in normalized
|
||||
assert "No files were changed" in normalized
|
||||
assert "specify integration uninstall copilot" not in normalized
|
||||
|
||||
def test_install_already_installed_non_default_guides_use(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, ["integration", "install", "codex"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "already installed" in normalized
|
||||
assert "specify integration use codex" in normalized
|
||||
assert "specify integration upgrade codex" in normalized
|
||||
assert "specify integration uninstall codex" not in normalized
|
||||
assert "specify integration uninstall copilot" in normalized
|
||||
|
||||
def test_install_different_when_one_exists(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -199,11 +176,7 @@ class TestIntegrationInstall:
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "Default integration: copilot" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "To replace the default integration" in normalized
|
||||
assert "specify integration switch claude" in normalized
|
||||
assert "To install 'claude' alongside" in normalized
|
||||
assert "retry the same install command with --force" in normalized
|
||||
assert "--force" in result.output
|
||||
|
||||
def test_install_multi_safe_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -288,11 +261,7 @@ class TestIntegrationInstall:
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "multi-install safe" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "To replace the default integration" in normalized
|
||||
assert "specify integration switch claude" in normalized
|
||||
assert "To install 'claude' alongside" in normalized
|
||||
assert "retry the same install command with --force" in normalized
|
||||
assert "--force" in result.output
|
||||
|
||||
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
|
||||
@@ -832,7 +832,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_gh_token_forwarded_when_configured(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
@@ -843,7 +843,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_no_config_means_no_auth(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
@@ -852,7 +852,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_accept_header_present(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for CLI version reporting."""
|
||||
"""Tests for the --version CLI flag."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
@@ -34,46 +33,3 @@ class TestVersionFlag:
|
||||
result = runner.invoke(app, ["--version", "init"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 0.7.2" in result.output
|
||||
|
||||
|
||||
class TestVersionCommand:
|
||||
"""Test the `specify version` subcommand."""
|
||||
|
||||
def test_version_features_text(self):
|
||||
"""specify version --features prints local capability flags."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Spec Kit CLI: 1.2.3" in result.output
|
||||
assert "Features:" in result.output
|
||||
assert "- controlled multi install integrations: yes" in result.output
|
||||
assert "- integration use command: yes" in result.output
|
||||
assert "- self check command: yes" in result.output
|
||||
|
||||
def test_version_features_json(self):
|
||||
"""specify version --features --json prints machine-readable capabilities."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload == {
|
||||
"version": "1.2.3",
|
||||
"features": {
|
||||
"controlled_multi_install_integrations": True,
|
||||
"integration_use_command": True,
|
||||
"multi_install_safe_registry_metadata": True,
|
||||
"integration_upgrade_command": True,
|
||||
"self_check_command": True,
|
||||
"workflow_catalog": True,
|
||||
"bundled_templates": True,
|
||||
},
|
||||
}
|
||||
|
||||
def test_version_json_requires_features(self):
|
||||
"""specify version --json is rejected until a JSON surface exists."""
|
||||
result = runner.invoke(app, ["version", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "--json requires --features" in result.output
|
||||
|
||||
@@ -2266,37 +2266,6 @@ class TestPresetSkills:
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
def _create_command_preset(self, temp_dir, preset_id, command_name, description, body):
|
||||
preset_dir = temp_dir / preset_id
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
command_file = f"{command_name}.md"
|
||||
(preset_dir / "commands" / command_file).write_text(
|
||||
f"---\ndescription: {description}\n---\n\n{body}\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": preset_id,
|
||||
"name": preset_id,
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": command_name,
|
||||
"file": f"commands/{command_file}",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
return preset_dir
|
||||
|
||||
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
|
||||
"""When --ai-skills was used, a preset command override should update the skill."""
|
||||
# Simulate --ai-skills having been used: write init-options + create skill
|
||||
@@ -2321,120 +2290,6 @@ class TestPresetSkills:
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit-specify" in metadata.get("registered_skills", [])
|
||||
|
||||
def test_core_command_override_skill_uses_preset_command_description(self, project_dir, temp_dir):
|
||||
"""Preset skill overrides for core commands should keep preset frontmatter descriptions."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-taskstoissues")
|
||||
|
||||
preset_dir = temp_dir / "taskstoissues-description"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.repro.taskstoissues.md").write_text(
|
||||
"---\n"
|
||||
"description: COMMAND-FRONTMATTER-DESCRIPTION\n"
|
||||
"---\n\n"
|
||||
"# Repro command body\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "taskstoissues-description",
|
||||
"name": "Taskstoissues Description",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.taskstoissues",
|
||||
"file": "commands/speckit.repro.taskstoissues.md",
|
||||
"description": "MANIFEST-DESCRIPTION",
|
||||
"replaces": "speckit.taskstoissues",
|
||||
"strategy": "replace",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert "description: COMMAND-FRONTMATTER-DESCRIPTION" in content
|
||||
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
||||
assert "source: preset:taskstoissues-description" in content
|
||||
|
||||
def test_core_skill_restore_uses_core_command_description(self, project_dir, temp_dir):
|
||||
"""Core skill restore should keep core command frontmatter descriptions."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-taskstoissues")
|
||||
|
||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||
core_cmds.mkdir(parents=True, exist_ok=True)
|
||||
(core_cmds / "taskstoissues.md").write_text(
|
||||
"---\n"
|
||||
"description: CORE-FRONTMATTER-DESCRIPTION\n"
|
||||
"---\n\n"
|
||||
"core taskstoissues body\n"
|
||||
)
|
||||
preset_dir = self._create_command_preset(
|
||||
temp_dir,
|
||||
"taskstoissues-restore",
|
||||
"speckit.taskstoissues",
|
||||
"PRESET-FRONTMATTER-DESCRIPTION",
|
||||
"preset taskstoissues body\n",
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
manager.remove("taskstoissues-restore")
|
||||
|
||||
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert "description: CORE-FRONTMATTER-DESCRIPTION" in content
|
||||
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
||||
assert "source: templates/commands/taskstoissues.md" in content
|
||||
assert "core taskstoissues body" in content
|
||||
|
||||
def test_override_skill_reconcile_uses_override_command_description(self, project_dir, temp_dir):
|
||||
"""Override skill reconciliation should keep override frontmatter descriptions."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-taskstoissues")
|
||||
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
(overrides_dir / "speckit.taskstoissues.md").write_text(
|
||||
"---\n"
|
||||
"description: OVERRIDE-FRONTMATTER-DESCRIPTION\n"
|
||||
"---\n\n"
|
||||
"override taskstoissues body\n"
|
||||
)
|
||||
preset_dir = self._create_command_preset(
|
||||
temp_dir,
|
||||
"taskstoissues-reconcile",
|
||||
"speckit.taskstoissues",
|
||||
"PRESET-FRONTMATTER-DESCRIPTION",
|
||||
"preset taskstoissues body\n",
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert "description: OVERRIDE-FRONTMATTER-DESCRIPTION" in content
|
||||
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
||||
assert "source: override:speckit.taskstoissues" in content
|
||||
assert "override taskstoissues body" in content
|
||||
|
||||
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||
"""When --ai-skills was NOT used, preset install should not touch skills."""
|
||||
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
|
||||
|
||||
@@ -115,36 +115,6 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ps_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with PowerShell scripts and a BOM-prefixed template."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"],
|
||||
cwd=tmp_path,
|
||||
check=True,
|
||||
)
|
||||
ps_dir = tmp_path / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True)
|
||||
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
shutil.copy(common_ps, ps_dir / "common.ps1")
|
||||
templates_dir = tmp_path / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
# Write a BOM-prefixed template to ensure the WriteAllText fix is actually exercised.
|
||||
# If WriteAllText regresses, the output file will contain the BOM.
|
||||
bom = b"\xef\xbb\xbf"
|
||||
template_content = "# Feature Spec\n\nDescribe the feature here.\n"
|
||||
(templates_dir / "spec-template.md").write_bytes(bom + template_content.encode("utf-8"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_git_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temp directory without git, but with scripts."""
|
||||
@@ -411,7 +381,6 @@ class TestGetFeaturePathsSinglePrefix:
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
|
||||
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
|
||||
@@ -681,45 +650,6 @@ class TestAllowExistingBranchPowerShell:
|
||||
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
|
||||
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
@pytest.mark.skipif(
|
||||
os.name != "nt" or shutil.which("powershell.exe") is None,
|
||||
reason="Windows PowerShell not installed",
|
||||
)
|
||||
def test_ps_spec_file_written_without_bom(self, ps_git_repo: Path):
|
||||
"""spec.md generated from a BOM-prefixed template must not contain a UTF-8 BOM."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(CREATE_FEATURE_PS),
|
||||
"-ShortName",
|
||||
"bom-check",
|
||||
"BOM check feature",
|
||||
],
|
||||
cwd=ps_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
spec_file = next((ps_git_repo / "specs").rglob("spec.md"), None)
|
||||
assert spec_file is not None, (
|
||||
f"spec.md was not created.\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
)
|
||||
|
||||
raw = spec_file.read_bytes()
|
||||
assert not raw.startswith(b"\xef\xbb\xbf"), (
|
||||
f"spec.md must not start with a UTF-8 BOM — got first 3 bytes: {raw[:3]!r}"
|
||||
)
|
||||
# Verify template content was copied (not just an empty New-Item fallback)
|
||||
assert "Feature Spec" in raw.decode("utf-8"), (
|
||||
"spec.md does not contain template content — WriteAllText path was not exercised"
|
||||
)
|
||||
|
||||
|
||||
class TestGitExtensionParity:
|
||||
def test_bash_extension_surfaces_checkout_errors(self):
|
||||
@@ -974,6 +904,30 @@ def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ps_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with PowerShell scripts and .specify dir."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"],
|
||||
cwd=tmp_path,
|
||||
check=True,
|
||||
)
|
||||
ps_dir = tmp_path / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True)
|
||||
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
shutil.copy(common_ps, ps_dir / "common.ps1")
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
|
||||
class TestPowerShellDryRun:
|
||||
def test_ps_dry_run_outputs_name(self, ps_git_repo: Path):
|
||||
@@ -1305,13 +1259,13 @@ class TestFeatureDirectoryResolution:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
|
||||
|
||||
|
||||
# ── Description Quoting Tests (issue #2339) ──────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestDescriptionQuoting:
|
||||
"""Descriptions with quotes, apostrophes, and backslashes must not break the script.
|
||||
|
||||
Regression tests for https://github.com/github/spec-kit/issues/2339
|
||||
"""
|
||||
|
||||
@@ -1319,9 +1273,9 @@ class TestDescriptionQuoting:
|
||||
"description",
|
||||
[
|
||||
"Add user's profile page",
|
||||
'Fix the "login" bug',
|
||||
"Fix the \"login\" bug",
|
||||
"Handle path\\with\\backslashes",
|
||||
'It\'s a "complex" feature\\here',
|
||||
"It's a \"complex\" feature\\here",
|
||||
],
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
@@ -1336,22 +1290,16 @@ class TestDescriptionQuoting:
|
||||
"description",
|
||||
[
|
||||
"Add user's profile page",
|
||||
'Fix the "login" bug',
|
||||
"Fix the \"login\" bug",
|
||||
"Handle path\\with\\backslashes",
|
||||
'It\'s a "complex" feature\\here',
|
||||
"It's a \"complex\" feature\\here",
|
||||
],
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
|
||||
"""Extension create-new-feature.sh succeeds with special characters in description."""
|
||||
script = (
|
||||
ext_git_repo
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "git"
|
||||
/ "scripts"
|
||||
/ "bash"
|
||||
/ "create-new-feature.sh"
|
||||
ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--dry-run", "--short-name", "feat", description],
|
||||
@@ -1373,4 +1321,3 @@ class TestDescriptionQuoting:
|
||||
"""Plain description without special characters continues to work."""
|
||||
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -16,12 +16,12 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli._version import (
|
||||
_fetch_latest_release_tag,
|
||||
from specify_cli import (
|
||||
_get_installed_version,
|
||||
_fetch_latest_release_tag,
|
||||
_is_newer,
|
||||
_normalize_tag,
|
||||
app,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestNormalizeTag:
|
||||
|
||||
class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
@@ -162,7 +162,7 @@ class TestUserStory1:
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output
|
||||
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
@@ -174,7 +174,7 @@ class TestUserStory1:
|
||||
assert "git+https://" not in output
|
||||
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
@@ -185,7 +185,7 @@ class TestUserStory1:
|
||||
assert "Up to date" in output
|
||||
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
@@ -197,7 +197,7 @@ class TestUserStory1:
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
@@ -269,7 +269,7 @@ class TestUserStory2:
|
||||
def test_failure_prints_installed_plus_one_line_reason(
|
||||
self, expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -283,7 +283,7 @@ class TestUserStory2:
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -293,7 +293,7 @@ class TestUserStory2:
|
||||
def test_failure_output_contains_no_traceback_no_url(
|
||||
self, _expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -390,7 +390,7 @@ class TestUserStory3:
|
||||
):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -403,7 +403,7 @@ class TestUserStory3:
|
||||
):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Regression guard: version symbols must remain importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
GITHUB_API_LATEST,
|
||||
self_check,
|
||||
self_upgrade,
|
||||
)
|
||||
|
||||
|
||||
def test_version_symbols_importable():
|
||||
assert isinstance(GITHUB_API_LATEST, str)
|
||||
assert GITHUB_API_LATEST.startswith("https://")
|
||||
assert callable(self_check)
|
||||
assert callable(self_upgrade)
|
||||
|
||||
|
||||
def test_version_symbols_available_from_star_import():
|
||||
namespace = {}
|
||||
exec("from specify_cli import *", namespace)
|
||||
|
||||
for symbol in ("GITHUB_API_LATEST", "self_check", "self_upgrade"):
|
||||
assert symbol in namespace
|
||||
|
||||
|
||||
def test_version_module_symbols_directly_importable():
|
||||
from specify_cli._version import (
|
||||
GITHUB_API_LATEST,
|
||||
_fetch_latest_release_tag,
|
||||
_get_installed_version,
|
||||
_is_newer,
|
||||
_normalize_tag,
|
||||
self_app,
|
||||
self_check,
|
||||
self_upgrade,
|
||||
)
|
||||
assert callable(_get_installed_version)
|
||||
assert callable(_normalize_tag)
|
||||
assert callable(_is_newer)
|
||||
assert callable(_fetch_latest_release_tag)
|
||||
assert callable(self_check)
|
||||
assert callable(self_upgrade)
|
||||
assert self_app is not None
|
||||
Reference in New Issue
Block a user