spectask-init 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spectask_init-0.1.0/PKG-INFO +6 -0
- spectask_init-0.1.0/README.md +84 -0
- spectask_init-0.1.0/pyproject.toml +18 -0
- spectask_init-0.1.0/setup.cfg +4 -0
- spectask_init-0.1.0/spectask_init/__init__.py +1 -0
- spectask_init-0.1.0/spectask_init/__main__.py +4 -0
- spectask_init-0.1.0/spectask_init/acquire.py +108 -0
- spectask_init-0.1.0/spectask_init/bootstrap.py +151 -0
- spectask_init-0.1.0/spectask_init/cli.py +84 -0
- spectask_init-0.1.0/spectask_init.egg-info/PKG-INFO +6 -0
- spectask_init-0.1.0/spectask_init.egg-info/SOURCES.txt +17 -0
- spectask_init-0.1.0/spectask_init.egg-info/dependency_links.txt +1 -0
- spectask_init-0.1.0/spectask_init.egg-info/entry_points.txt +2 -0
- spectask_init-0.1.0/spectask_init.egg-info/requires.txt +3 -0
- spectask_init-0.1.0/spectask_init.egg-info/top_level.txt +1 -0
- spectask_init-0.1.0/tests/test_acquire.py +57 -0
- spectask_init-0.1.0/tests/test_bootstrap_unit.py +73 -0
- spectask_init-0.1.0/tests/test_cli_parse.py +60 -0
- spectask_init-0.1.0/tests/test_integration_cli.py +183 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# spectask-init
|
|
2
|
+
|
|
3
|
+
Python3.10+ CLI to bootstrap [Spectask](https://github.com/noant/spectask)-style template files into the **current working directory** (ZIP or Git sources, optional `spec/extend` overlay, IDE paths from `skills-map.json`).
|
|
4
|
+
|
|
5
|
+
The PyPI project and console command are **`spectask-init`**.
|
|
6
|
+
|
|
7
|
+
## Use with uvx (recommended)
|
|
8
|
+
|
|
9
|
+
[`uvx`](https://docs.astral.sh/uv/guides/tools/) runs the tool from PyPI without a permanent install. Install [**uv**](https://docs.astral.sh/uv/getting-started/installation/) first (it includes `uvx`).
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uvx spectask-init --ide <key>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uvx spectask-init --ide cursor
|
|
19
|
+
uvx spectask-init --ide cursor --template-branch main
|
|
20
|
+
uvx spectask-init --help
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Requirements:**
|
|
24
|
+
|
|
25
|
+
- Network access (PyPI).
|
|
26
|
+
- After the package is published, the command resolves **`spectask-init`** from [PyPI](https://pypi.org/project/spectask-init/). Until then, install from this repo (see below).
|
|
27
|
+
- For **Git** template URLs (not ending in `.zip`), `git` must be on your `PATH`.
|
|
28
|
+
|
|
29
|
+
## Install from source
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone <this-repo-url>
|
|
33
|
+
cd spectask-cli
|
|
34
|
+
pip install .
|
|
35
|
+
spectask-init --ide <key>
|
|
36
|
+
# or: python -m spectask_init --ide <key>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## pip install (global / venv)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install spectask-init
|
|
43
|
+
spectask-init --ide <key>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Installing uv (quick reference)
|
|
47
|
+
|
|
48
|
+
| Platform | Command |
|
|
49
|
+
|----------|---------|
|
|
50
|
+
| **Windows** (PowerShell) | `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 \| iex"` |
|
|
51
|
+
| **macOS / Linux** | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
|
52
|
+
|
|
53
|
+
More options: [uv installation](https://docs.astral.sh/uv/getting-started/installation/).
|
|
54
|
+
|
|
55
|
+
## Docs
|
|
56
|
+
|
|
57
|
+
- Architecture: [`spec/design/hla.md`](spec/design/hla.md)
|
|
58
|
+
- Spec methodology: [`spec/main.md`](spec/main.md)
|
|
59
|
+
|
|
60
|
+
## Maintainer: tests and publishing
|
|
61
|
+
|
|
62
|
+
From the repo root, with [uv](https://docs.astral.sh/uv/) on `PATH`.
|
|
63
|
+
|
|
64
|
+
**Tests** — install dev dependencies (includes pytest), then run the suite:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv sync --extra dev
|
|
68
|
+
uv run pytest tests
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Skip integration tests (no network or `git clone`; unit tests only):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv run pytest tests -m "not integration"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Publish to PyPI** — set a [PyPI API token](https://pypi.org/manage/account/) and run:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
export spectask_publish_pypi_token=pypi-... # or: python scripts/publish.py --token pypi-...
|
|
81
|
+
python scripts/publish.py
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Do not commit tokens. See [uv publish](https://docs.astral.sh/uv/guides/publish/).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "spectask-init"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
dev = ["pytest>=8"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
spectask-init = "spectask_init.cli:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
include = ["spectask_init*"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bootstrap Spectask template files into the current working directory."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import zipfile
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Generator, Literal
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
from urllib.request import urlopen
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_zip_url(url: str) -> bool:
|
|
15
|
+
return urlparse(url).path.lower().endswith(".zip")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_zip_base(extract_dir: Path, layout: Literal["template", "extend"]) -> Path:
|
|
19
|
+
def has_marker(base: Path) -> bool:
|
|
20
|
+
if layout == "template":
|
|
21
|
+
return (base / ".metadata").is_dir()
|
|
22
|
+
return (base / "spec" / "extend").is_dir()
|
|
23
|
+
|
|
24
|
+
base = extract_dir
|
|
25
|
+
if has_marker(base):
|
|
26
|
+
return base
|
|
27
|
+
children = [p for p in base.iterdir() if p.is_dir()]
|
|
28
|
+
if len(children) == 1:
|
|
29
|
+
base = children[0]
|
|
30
|
+
if has_marker(base):
|
|
31
|
+
return base
|
|
32
|
+
marker = ".metadata" if layout == "template" else "spec/extend/"
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
f"Cannot resolve {layout} root under {extract_dir}: expected {marker} "
|
|
35
|
+
"(after unwrapping at most one single top-level folder if present)",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ensure_git_available() -> None:
|
|
40
|
+
try:
|
|
41
|
+
r = subprocess.run(
|
|
42
|
+
["git", "--version"],
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=30,
|
|
46
|
+
)
|
|
47
|
+
except FileNotFoundError as e:
|
|
48
|
+
raise RuntimeError("git is not installed or not on PATH; required for non-ZIP URLs.") from e
|
|
49
|
+
except subprocess.TimeoutExpired as e:
|
|
50
|
+
raise RuntimeError("git --version timed out; check your git installation.") from e
|
|
51
|
+
if r.returncode != 0:
|
|
52
|
+
msg = (r.stderr or r.stdout or "").strip() or f"exit code {r.returncode}"
|
|
53
|
+
raise RuntimeError(f"git is not available ({msg}); required for non-ZIP URLs.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def acquire_source(
|
|
58
|
+
url: str,
|
|
59
|
+
*,
|
|
60
|
+
git_branch: str,
|
|
61
|
+
layout: Literal["template", "extend"],
|
|
62
|
+
) -> Generator[Path, None, None]:
|
|
63
|
+
if is_zip_url(url):
|
|
64
|
+
with tempfile.TemporaryDirectory(prefix="spectask-src-zip-") as td:
|
|
65
|
+
td_path = Path(td)
|
|
66
|
+
zip_path = td_path / "archive.zip"
|
|
67
|
+
try:
|
|
68
|
+
with urlopen(url, timeout=60) as resp:
|
|
69
|
+
zip_path.write_bytes(resp.read())
|
|
70
|
+
except (HTTPError, URLError, TimeoutError, OSError) as e:
|
|
71
|
+
raise RuntimeError(f"Failed to download archive {url!r}: {e}") from e
|
|
72
|
+
extract_dir = td_path / "extract"
|
|
73
|
+
extract_dir.mkdir()
|
|
74
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
75
|
+
zf.extractall(extract_dir)
|
|
76
|
+
yield resolve_zip_base(extract_dir, layout)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
ensure_git_available()
|
|
80
|
+
with tempfile.TemporaryDirectory(prefix="spectask-src-git-") as td:
|
|
81
|
+
repo = Path(td) / "repo"
|
|
82
|
+
try:
|
|
83
|
+
subprocess.run(
|
|
84
|
+
[
|
|
85
|
+
"git",
|
|
86
|
+
"clone",
|
|
87
|
+
"--depth",
|
|
88
|
+
"1",
|
|
89
|
+
"--branch",
|
|
90
|
+
git_branch,
|
|
91
|
+
"--single-branch",
|
|
92
|
+
url,
|
|
93
|
+
str(repo),
|
|
94
|
+
],
|
|
95
|
+
check=True,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
)
|
|
99
|
+
except subprocess.CalledProcessError as e:
|
|
100
|
+
err = (e.stderr or e.stdout or "").strip()
|
|
101
|
+
detail = f": {err}" if err else ""
|
|
102
|
+
raise RuntimeError(f"git clone failed for {url!r}{detail}") from e
|
|
103
|
+
|
|
104
|
+
if layout == "template" and not (repo / ".metadata").is_dir():
|
|
105
|
+
raise RuntimeError(f"Cloned template missing .metadata directory: {repo}")
|
|
106
|
+
if layout == "extend" and not (repo / "spec" / "extend").is_dir():
|
|
107
|
+
raise RuntimeError(f"Cloned extend source missing spec/extend: {repo}")
|
|
108
|
+
yield repo
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from spectask_init.acquire import acquire_source
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _paths_from_entry(entry: dict[str, Any]) -> list[str] | None:
|
|
12
|
+
"""IDE entries use `paths` (spec) or `files` (upstream template)."""
|
|
13
|
+
if "paths" in entry:
|
|
14
|
+
v = entry["paths"]
|
|
15
|
+
elif "files" in entry:
|
|
16
|
+
v = entry["files"]
|
|
17
|
+
else:
|
|
18
|
+
return None
|
|
19
|
+
if not isinstance(v, list):
|
|
20
|
+
return None
|
|
21
|
+
return [p for p in v if isinstance(p, str)]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_json(path: Path) -> dict[str, Any]:
|
|
25
|
+
try:
|
|
26
|
+
raw = path.read_text(encoding="utf-8")
|
|
27
|
+
except OSError as e:
|
|
28
|
+
raise RuntimeError(f"Cannot read {path}: {e}") from e
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(raw)
|
|
31
|
+
except json.JSONDecodeError as e:
|
|
32
|
+
raise RuntimeError(f"Invalid JSON in {path}: {e}") from e
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
raise RuntimeError(f"Expected JSON object at root in {path}")
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ide_files_for(skills: dict[str, Any], ide: str) -> list[str]:
|
|
39
|
+
ides = skills.get("ides")
|
|
40
|
+
if not isinstance(ides, list):
|
|
41
|
+
raise RuntimeError("skills-map.json: missing or invalid 'ides' array")
|
|
42
|
+
|
|
43
|
+
if ide == "all":
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
out: list[str] = []
|
|
46
|
+
for entry in ides:
|
|
47
|
+
if not isinstance(entry, dict):
|
|
48
|
+
continue
|
|
49
|
+
paths = _paths_from_entry(entry)
|
|
50
|
+
if not paths:
|
|
51
|
+
continue
|
|
52
|
+
for p in paths:
|
|
53
|
+
if p not in seen:
|
|
54
|
+
seen.add(p)
|
|
55
|
+
out.append(p)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
for entry in ides:
|
|
59
|
+
if not isinstance(entry, dict):
|
|
60
|
+
continue
|
|
61
|
+
if entry.get("name") != ide:
|
|
62
|
+
continue
|
|
63
|
+
paths = _paths_from_entry(entry)
|
|
64
|
+
if paths is None:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"skills-map.json: IDE {ide!r} must have a 'paths' or 'files' array of strings",
|
|
67
|
+
)
|
|
68
|
+
return paths
|
|
69
|
+
|
|
70
|
+
raise RuntimeError(f"Unknown IDE {ide!r} in skills-map.json (not in ides[].name, and not 'all')")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def copy_into_cwd(template_root: Path, rel_path: str) -> None:
|
|
74
|
+
src = (template_root / rel_path).resolve()
|
|
75
|
+
root = template_root.resolve()
|
|
76
|
+
try:
|
|
77
|
+
src.relative_to(root)
|
|
78
|
+
except ValueError as e:
|
|
79
|
+
raise RuntimeError(f"Refusing to copy path outside template root: {rel_path!r}") from e
|
|
80
|
+
|
|
81
|
+
if not src.exists():
|
|
82
|
+
raise RuntimeError(f"Template missing path {rel_path!r} (expected under {template_root})")
|
|
83
|
+
|
|
84
|
+
dest = (Path.cwd() / rel_path).resolve()
|
|
85
|
+
cwd = Path.cwd().resolve()
|
|
86
|
+
try:
|
|
87
|
+
dest.relative_to(cwd)
|
|
88
|
+
except ValueError as e:
|
|
89
|
+
raise RuntimeError(f"Refusing to copy outside current working directory: {rel_path!r}") from e
|
|
90
|
+
|
|
91
|
+
if src.is_file():
|
|
92
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
shutil.copy2(src, dest)
|
|
94
|
+
elif src.is_dir():
|
|
95
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
|
96
|
+
else:
|
|
97
|
+
raise RuntimeError(f"Not a file or directory: {rel_path!r}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run_template_bootstrap(
|
|
101
|
+
*,
|
|
102
|
+
template_url: str,
|
|
103
|
+
ide: str,
|
|
104
|
+
skip_example: bool,
|
|
105
|
+
template_branch: str,
|
|
106
|
+
) -> None:
|
|
107
|
+
with acquire_source(template_url, git_branch=template_branch, layout="template") as template_root:
|
|
108
|
+
meta = template_root / ".metadata"
|
|
109
|
+
required_data = load_json(meta / "required-list.json")
|
|
110
|
+
required = required_data.get("required")
|
|
111
|
+
if not isinstance(required, list):
|
|
112
|
+
raise RuntimeError("required-list.json: missing or invalid 'required' array")
|
|
113
|
+
for rel in required:
|
|
114
|
+
if not isinstance(rel, str):
|
|
115
|
+
raise RuntimeError("required-list.json: 'required' must be a list of strings")
|
|
116
|
+
copy_into_cwd(template_root, rel)
|
|
117
|
+
|
|
118
|
+
if not skip_example:
|
|
119
|
+
example_data = load_json(meta / "example-list.json")
|
|
120
|
+
examples = example_data.get("examples")
|
|
121
|
+
if not isinstance(examples, list):
|
|
122
|
+
raise RuntimeError("example-list.json: missing or invalid 'examples' array")
|
|
123
|
+
for rel in examples:
|
|
124
|
+
if not isinstance(rel, str):
|
|
125
|
+
raise RuntimeError("example-list.json: 'examples' must be a list of strings")
|
|
126
|
+
copy_into_cwd(template_root, rel)
|
|
127
|
+
|
|
128
|
+
skills = load_json(meta / "skills-map.json")
|
|
129
|
+
for rel in ide_files_for(skills, ide):
|
|
130
|
+
copy_into_cwd(template_root, rel)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def copy_extend_overlay(extend_root: Path) -> None:
|
|
134
|
+
src_dir = extend_root / "spec" / "extend"
|
|
135
|
+
if not src_dir.is_dir():
|
|
136
|
+
raise FileNotFoundError(f"No spec/extend in extend source: {src_dir}")
|
|
137
|
+
dest_root = Path.cwd()
|
|
138
|
+
for path in src_dir.rglob("*"):
|
|
139
|
+
if path.is_dir():
|
|
140
|
+
continue
|
|
141
|
+
rel = path.relative_to(src_dir)
|
|
142
|
+
dest = dest_root / "spec" / "extend" / rel
|
|
143
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
shutil.copy2(path, dest)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_extend(*, extend_url: str | None, extend_branch: str) -> None:
|
|
148
|
+
if not extend_url:
|
|
149
|
+
return
|
|
150
|
+
with acquire_source(extend_url, git_branch=extend_branch, layout="extend") as root:
|
|
151
|
+
copy_extend_overlay(root)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import zipfile
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from spectask_init.bootstrap import run_extend, run_template_bootstrap
|
|
9
|
+
|
|
10
|
+
DEFAULT_TEMPLATE_URL = "https://github.com/noant/spectask.git"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CliOptions:
|
|
15
|
+
template_url: str
|
|
16
|
+
ide: str
|
|
17
|
+
template_branch: str
|
|
18
|
+
extend: str | None
|
|
19
|
+
extend_branch: str
|
|
20
|
+
skip_example: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
epilog = """
|
|
25
|
+
ZIP vs Git:
|
|
26
|
+
If the URL path ends with .zip (case-insensitive), the tool downloads and extracts
|
|
27
|
+
the archive. Otherwise it runs git clone (requires git on PATH), using
|
|
28
|
+
--template-branch for --template-url and --extend-branch for --extend.
|
|
29
|
+
The same rule applies to --extend when that option is used.
|
|
30
|
+
|
|
31
|
+
Default --template-url is the official Spectask GitHub repository (.git); use a .zip URL
|
|
32
|
+
to avoid git for the template step only.
|
|
33
|
+
""".strip()
|
|
34
|
+
p = argparse.ArgumentParser(
|
|
35
|
+
description="Bootstrap Spectask template files into the current directory.",
|
|
36
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
37
|
+
epilog=epilog,
|
|
38
|
+
)
|
|
39
|
+
p.add_argument(
|
|
40
|
+
"--template-url",
|
|
41
|
+
default=DEFAULT_TEMPLATE_URL,
|
|
42
|
+
metavar="URL",
|
|
43
|
+
help=f"Template source (ZIP or Git). Default: {DEFAULT_TEMPLATE_URL}",
|
|
44
|
+
)
|
|
45
|
+
p.add_argument("--ide", required=True, help="IDE key from skills-map.json, or 'all'.")
|
|
46
|
+
p.add_argument("--template-branch", default="main", help="Git branch for template URL when not ZIP (default: main).")
|
|
47
|
+
p.add_argument("--extend", default=None, help="Optional overlay source (ZIP or Git) for spec/extend/.")
|
|
48
|
+
p.add_argument("--extend-branch", default="main", help="Git branch for --extend when not ZIP (default: main).")
|
|
49
|
+
p.add_argument("--skip-example", action="store_true", help="Do not copy example-list.json paths.")
|
|
50
|
+
return p
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_args(argv: list[str] | None = None) -> CliOptions:
|
|
54
|
+
p = build_parser()
|
|
55
|
+
ns = p.parse_args(argv)
|
|
56
|
+
return CliOptions(
|
|
57
|
+
template_url=ns.template_url,
|
|
58
|
+
ide=ns.ide,
|
|
59
|
+
template_branch=ns.template_branch,
|
|
60
|
+
extend=ns.extend,
|
|
61
|
+
extend_branch=ns.extend_branch,
|
|
62
|
+
skip_example=ns.skip_example,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main() -> None:
|
|
67
|
+
opts = parse_args()
|
|
68
|
+
try:
|
|
69
|
+
run_template_bootstrap(
|
|
70
|
+
template_url=opts.template_url,
|
|
71
|
+
ide=opts.ide,
|
|
72
|
+
skip_example=opts.skip_example,
|
|
73
|
+
template_branch=opts.template_branch,
|
|
74
|
+
)
|
|
75
|
+
except (OSError, RuntimeError, zipfile.BadZipFile) as e:
|
|
76
|
+
print(f"spectask-init: {e}", file=sys.stderr)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
if opts.extend:
|
|
80
|
+
try:
|
|
81
|
+
run_extend(extend_url=opts.extend, extend_branch=opts.extend_branch)
|
|
82
|
+
except (OSError, RuntimeError, zipfile.BadZipFile) as e:
|
|
83
|
+
print(f"spectask-init: {e}", file=sys.stderr)
|
|
84
|
+
sys.exit(1)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
spectask_init/__init__.py
|
|
4
|
+
spectask_init/__main__.py
|
|
5
|
+
spectask_init/acquire.py
|
|
6
|
+
spectask_init/bootstrap.py
|
|
7
|
+
spectask_init/cli.py
|
|
8
|
+
spectask_init.egg-info/PKG-INFO
|
|
9
|
+
spectask_init.egg-info/SOURCES.txt
|
|
10
|
+
spectask_init.egg-info/dependency_links.txt
|
|
11
|
+
spectask_init.egg-info/entry_points.txt
|
|
12
|
+
spectask_init.egg-info/requires.txt
|
|
13
|
+
spectask_init.egg-info/top_level.txt
|
|
14
|
+
tests/test_acquire.py
|
|
15
|
+
tests/test_bootstrap_unit.py
|
|
16
|
+
tests/test_cli_parse.py
|
|
17
|
+
tests/test_integration_cli.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spectask_init
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from spectask_init.acquire import is_zip_url, resolve_zip_base
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize(
|
|
9
|
+
("url", "expected"),
|
|
10
|
+
[
|
|
11
|
+
("https://github.com/o/r/archive/refs/heads/main.zip", True),
|
|
12
|
+
("https://example.com/path.ZIP", True),
|
|
13
|
+
("https://github.com/noant/spectask.git", False),
|
|
14
|
+
("https://example.com/archive.zip/extra", False),
|
|
15
|
+
],
|
|
16
|
+
)
|
|
17
|
+
def test_is_zip_url(url: str, expected: bool) -> None:
|
|
18
|
+
assert is_zip_url(url) is expected
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_resolve_zip_base_template_at_root(tmp_path) -> None:
|
|
22
|
+
root = tmp_path / "extract"
|
|
23
|
+
root.mkdir()
|
|
24
|
+
(root / ".metadata").mkdir()
|
|
25
|
+
assert resolve_zip_base(root, "template") == root
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_resolve_zip_base_template_one_wrapper(tmp_path) -> None:
|
|
29
|
+
extract = tmp_path / "extract"
|
|
30
|
+
extract.mkdir()
|
|
31
|
+
inner = extract / "repo-main"
|
|
32
|
+
inner.mkdir()
|
|
33
|
+
(inner / ".metadata").mkdir()
|
|
34
|
+
assert resolve_zip_base(extract, "template") == inner
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_resolve_zip_base_extend_at_root(tmp_path) -> None:
|
|
38
|
+
root = tmp_path / "extract"
|
|
39
|
+
root.mkdir()
|
|
40
|
+
(root / "spec" / "extend").mkdir(parents=True)
|
|
41
|
+
assert resolve_zip_base(root, "extend") == root
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_resolve_zip_base_extend_one_wrapper(tmp_path) -> None:
|
|
45
|
+
extract = tmp_path / "extract"
|
|
46
|
+
extract.mkdir()
|
|
47
|
+
inner = extract / "repo-main"
|
|
48
|
+
inner.mkdir()
|
|
49
|
+
(inner / "spec" / "extend").mkdir(parents=True)
|
|
50
|
+
assert resolve_zip_base(extract, "extend") == inner
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_resolve_zip_base_template_fails(tmp_path) -> None:
|
|
54
|
+
root = tmp_path / "extract"
|
|
55
|
+
root.mkdir()
|
|
56
|
+
with pytest.raises(RuntimeError, match="Cannot resolve template root"):
|
|
57
|
+
resolve_zip_base(root, "template")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from spectask_init.bootstrap import copy_into_cwd, ide_files_for, load_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_ide_files_for_named() -> None:
|
|
11
|
+
skills = {
|
|
12
|
+
"ides": [
|
|
13
|
+
{"name": "cursor", "paths": ["a.md", "b.md"]},
|
|
14
|
+
{"name": "other", "files": ["c.md"]},
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
assert ide_files_for(skills, "cursor") == ["a.md", "b.md"]
|
|
18
|
+
assert ide_files_for(skills, "other") == ["c.md"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_ide_files_for_all_dedupes() -> None:
|
|
22
|
+
skills = {
|
|
23
|
+
"ides": [
|
|
24
|
+
{"name": "a", "paths": ["shared.md", "only-a.md"]},
|
|
25
|
+
{"name": "b", "files": ["shared.md", "only-b.md"]},
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
assert ide_files_for(skills, "all") == ["shared.md", "only-a.md", "only-b.md"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_ide_files_for_unknown_ide() -> None:
|
|
32
|
+
skills = {"ides": [{"name": "cursor", "paths": ["x.md"]}]}
|
|
33
|
+
with pytest.raises(RuntimeError, match="Unknown IDE"):
|
|
34
|
+
ide_files_for(skills, "missing")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_ide_files_for_missing_ides() -> None:
|
|
38
|
+
with pytest.raises(RuntimeError, match="ides"):
|
|
39
|
+
ide_files_for({}, "cursor")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_ide_files_for_named_without_paths() -> None:
|
|
43
|
+
skills = {"ides": [{"name": "cursor", "oops": []}]}
|
|
44
|
+
with pytest.raises(RuntimeError, match="must have a 'paths' or 'files'"):
|
|
45
|
+
ide_files_for(skills, "cursor")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_load_json_invalid(tmp_path) -> None:
|
|
49
|
+
p = tmp_path / "bad.json"
|
|
50
|
+
p.write_text("not json", encoding="utf-8")
|
|
51
|
+
with pytest.raises(RuntimeError, match="Invalid JSON"):
|
|
52
|
+
load_json(p)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_copy_into_cwd_file(tmp_path, monkeypatch) -> None:
|
|
56
|
+
monkeypatch.chdir(tmp_path)
|
|
57
|
+
root = tmp_path / "tpl"
|
|
58
|
+
(root / "dir").mkdir(parents=True)
|
|
59
|
+
src = root / "dir" / "f.txt"
|
|
60
|
+
src.write_text("hi", encoding="utf-8")
|
|
61
|
+
copy_into_cwd(root, "dir/f.txt")
|
|
62
|
+
assert (tmp_path / "dir" / "f.txt").read_text(encoding="utf-8") == "hi"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_copy_into_cwd_rejects_escape(tmp_path, monkeypatch) -> None:
|
|
66
|
+
monkeypatch.chdir(tmp_path)
|
|
67
|
+
root = tmp_path / "tpl"
|
|
68
|
+
root.mkdir()
|
|
69
|
+
other = tmp_path / "outside"
|
|
70
|
+
other.mkdir()
|
|
71
|
+
(other / "secret.txt").write_text("x", encoding="utf-8")
|
|
72
|
+
with pytest.raises(RuntimeError, match="outside template root"):
|
|
73
|
+
copy_into_cwd(root, "../outside/secret.txt")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from spectask_init.cli import DEFAULT_TEMPLATE_URL, parse_args
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_parse_defaults() -> None:
|
|
9
|
+
o = parse_args(["--ide", "cursor"])
|
|
10
|
+
assert o.template_url == DEFAULT_TEMPLATE_URL
|
|
11
|
+
assert o.ide == "cursor"
|
|
12
|
+
assert o.template_branch == "main"
|
|
13
|
+
assert o.extend is None
|
|
14
|
+
assert o.extend_branch == "main"
|
|
15
|
+
assert o.skip_example is False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_parse_custom_template_and_branches() -> None:
|
|
19
|
+
o = parse_args(
|
|
20
|
+
[
|
|
21
|
+
"--template-url",
|
|
22
|
+
"https://example.com/t.zip",
|
|
23
|
+
"--ide",
|
|
24
|
+
"windsurf",
|
|
25
|
+
"--template-branch",
|
|
26
|
+
"develop",
|
|
27
|
+
"--extend-branch",
|
|
28
|
+
"topic",
|
|
29
|
+
]
|
|
30
|
+
)
|
|
31
|
+
assert o.template_url == "https://example.com/t.zip"
|
|
32
|
+
assert o.ide == "windsurf"
|
|
33
|
+
assert o.template_branch == "develop"
|
|
34
|
+
assert o.extend is None
|
|
35
|
+
assert o.extend_branch == "topic"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_parse_extend_git() -> None:
|
|
39
|
+
o = parse_args(
|
|
40
|
+
[
|
|
41
|
+
"--ide",
|
|
42
|
+
"cursor",
|
|
43
|
+
"--extend",
|
|
44
|
+
"https://github.com/noant/spectask-my-extend.git",
|
|
45
|
+
"--extend-branch",
|
|
46
|
+
"main",
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
assert o.extend == "https://github.com/noant/spectask-my-extend.git"
|
|
50
|
+
assert o.extend_branch == "main"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_parse_skip_example() -> None:
|
|
54
|
+
o = parse_args(["--ide", "cursor", "--skip-example"])
|
|
55
|
+
assert o.skip_example is True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_parse_ide_required() -> None:
|
|
59
|
+
with pytest.raises(SystemExit):
|
|
60
|
+
parse_args([])
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from spectask_init.cli import main
|
|
9
|
+
|
|
10
|
+
TEMPLATE_ZIP = "https://github.com/noant/spectask/archive/refs/heads/main.zip"
|
|
11
|
+
TEMPLATE_GIT = "https://github.com/noant/spectask.git"
|
|
12
|
+
EXTEND_ZIP = "https://github.com/noant/spectask-my-extend/archive/refs/heads/main.zip"
|
|
13
|
+
EXTEND_GIT = "https://github.com/noant/spectask-my-extend.git"
|
|
14
|
+
|
|
15
|
+
EXAMPLE_ONLY = Path("spec/tasks/0-example-hello/overview.md")
|
|
16
|
+
EXTEND_OVERLAY = Path("spec/extend/0-misc.md")
|
|
17
|
+
CURSOR_SKILL = Path(".cursor/skills/spectask-create/SKILL.md")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_main(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, argv: list[str]) -> None:
|
|
21
|
+
monkeypatch.chdir(tmp_path)
|
|
22
|
+
monkeypatch.setattr(sys, "argv", ["spectask-init", *argv])
|
|
23
|
+
main()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _assert_baseline(cwd: Path) -> None:
|
|
27
|
+
assert (cwd / "spec/main.md").is_file()
|
|
28
|
+
assert (cwd / "spec/navigation.md").is_file()
|
|
29
|
+
assert (cwd / "spec/design/hla.md").is_file()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.integration
|
|
33
|
+
def test_zip_template_and_zip_extend(tmp_path, monkeypatch) -> None:
|
|
34
|
+
_run_main(
|
|
35
|
+
monkeypatch,
|
|
36
|
+
tmp_path,
|
|
37
|
+
[
|
|
38
|
+
"--template-url",
|
|
39
|
+
TEMPLATE_ZIP,
|
|
40
|
+
"--template-branch",
|
|
41
|
+
"unused-for-zip",
|
|
42
|
+
"--ide",
|
|
43
|
+
"cursor",
|
|
44
|
+
"--extend",
|
|
45
|
+
EXTEND_ZIP,
|
|
46
|
+
"--extend-branch",
|
|
47
|
+
"unused-for-zip",
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
_assert_baseline(tmp_path)
|
|
51
|
+
assert (tmp_path / EXTEND_OVERLAY).is_file()
|
|
52
|
+
assert (tmp_path / CURSOR_SKILL).is_file()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.integration
|
|
56
|
+
def test_git_template_and_git_extend(tmp_path, monkeypatch, git_on_path) -> None:
|
|
57
|
+
_run_main(
|
|
58
|
+
monkeypatch,
|
|
59
|
+
tmp_path,
|
|
60
|
+
[
|
|
61
|
+
"--template-url",
|
|
62
|
+
TEMPLATE_GIT,
|
|
63
|
+
"--template-branch",
|
|
64
|
+
"main",
|
|
65
|
+
"--ide",
|
|
66
|
+
"cursor",
|
|
67
|
+
"--extend",
|
|
68
|
+
EXTEND_GIT,
|
|
69
|
+
"--extend-branch",
|
|
70
|
+
"main",
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
_assert_baseline(tmp_path)
|
|
74
|
+
assert (tmp_path / EXTEND_OVERLAY).is_file()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.integration
|
|
78
|
+
def test_zip_template_only(tmp_path, monkeypatch) -> None:
|
|
79
|
+
_run_main(
|
|
80
|
+
monkeypatch,
|
|
81
|
+
tmp_path,
|
|
82
|
+
["--template-url", TEMPLATE_ZIP, "--ide", "cursor"],
|
|
83
|
+
)
|
|
84
|
+
_assert_baseline(tmp_path)
|
|
85
|
+
assert not (tmp_path / EXTEND_OVERLAY).is_file()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.integration
|
|
89
|
+
def test_git_template_only(tmp_path, monkeypatch, git_on_path) -> None:
|
|
90
|
+
_run_main(
|
|
91
|
+
monkeypatch,
|
|
92
|
+
tmp_path,
|
|
93
|
+
["--template-url", TEMPLATE_GIT, "--template-branch", "main", "--ide", "cursor"],
|
|
94
|
+
)
|
|
95
|
+
_assert_baseline(tmp_path)
|
|
96
|
+
assert not (tmp_path / EXTEND_OVERLAY).is_file()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.integration
|
|
100
|
+
def test_zip_template_and_git_extend(tmp_path, monkeypatch, git_on_path) -> None:
|
|
101
|
+
_run_main(
|
|
102
|
+
monkeypatch,
|
|
103
|
+
tmp_path,
|
|
104
|
+
[
|
|
105
|
+
"--template-url",
|
|
106
|
+
TEMPLATE_ZIP,
|
|
107
|
+
"--ide",
|
|
108
|
+
"cursor",
|
|
109
|
+
"--extend",
|
|
110
|
+
EXTEND_GIT,
|
|
111
|
+
"--extend-branch",
|
|
112
|
+
"main",
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
_assert_baseline(tmp_path)
|
|
116
|
+
assert (tmp_path / EXTEND_OVERLAY).is_file()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.integration
|
|
120
|
+
def test_git_template_and_zip_extend(tmp_path, monkeypatch, git_on_path) -> None:
|
|
121
|
+
_run_main(
|
|
122
|
+
monkeypatch,
|
|
123
|
+
tmp_path,
|
|
124
|
+
[
|
|
125
|
+
"--template-url",
|
|
126
|
+
TEMPLATE_GIT,
|
|
127
|
+
"--template-branch",
|
|
128
|
+
"main",
|
|
129
|
+
"--ide",
|
|
130
|
+
"cursor",
|
|
131
|
+
"--extend",
|
|
132
|
+
EXTEND_ZIP,
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
_assert_baseline(tmp_path)
|
|
136
|
+
assert (tmp_path / EXTEND_OVERLAY).is_file()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.integration
|
|
140
|
+
def test_skip_example_off_includes_example_paths(tmp_path, monkeypatch) -> None:
|
|
141
|
+
_run_main(
|
|
142
|
+
monkeypatch,
|
|
143
|
+
tmp_path,
|
|
144
|
+
["--template-url", TEMPLATE_ZIP, "--ide", "cursor"],
|
|
145
|
+
)
|
|
146
|
+
assert (tmp_path / EXAMPLE_ONLY).is_file()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.integration
|
|
150
|
+
def test_skip_example_on_excludes_example_paths(tmp_path, monkeypatch) -> None:
|
|
151
|
+
_run_main(
|
|
152
|
+
monkeypatch,
|
|
153
|
+
tmp_path,
|
|
154
|
+
["--template-url", TEMPLATE_ZIP, "--ide", "cursor", "--skip-example"],
|
|
155
|
+
)
|
|
156
|
+
assert not (tmp_path / EXAMPLE_ONLY).exists()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.integration
|
|
160
|
+
def test_ide_all_copies_union(tmp_path, monkeypatch) -> None:
|
|
161
|
+
_run_main(
|
|
162
|
+
monkeypatch,
|
|
163
|
+
tmp_path,
|
|
164
|
+
["--template-url", TEMPLATE_ZIP, "--ide", "all"],
|
|
165
|
+
)
|
|
166
|
+
assert (tmp_path / CURSOR_SKILL).is_file()
|
|
167
|
+
assert (tmp_path / "CLAUDE.md").is_file()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.integration
|
|
171
|
+
def test_zip_run_with_custom_tmp_env(tmp_path, monkeypatch) -> None:
|
|
172
|
+
"""ZIP extract uses tempfile; point TMP* at a writable dir (cleaned up after exit)."""
|
|
173
|
+
sandbox = tmp_path / "sandbox"
|
|
174
|
+
sandbox.mkdir()
|
|
175
|
+
monkeypatch.setenv("TMPDIR", str(sandbox))
|
|
176
|
+
monkeypatch.setenv("TEMP", str(sandbox))
|
|
177
|
+
monkeypatch.setenv("TMP", str(sandbox))
|
|
178
|
+
_run_main(
|
|
179
|
+
monkeypatch,
|
|
180
|
+
tmp_path,
|
|
181
|
+
["--template-url", TEMPLATE_ZIP, "--ide", "cursor"],
|
|
182
|
+
)
|
|
183
|
+
_assert_baseline(tmp_path)
|