project-kit-cli 0.1.0__py3-none-any.whl
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.
- project_kit/__init__.py +8 -0
- project_kit/__main__.py +5 -0
- project_kit/agents.py +81 -0
- project_kit/ai_attribution.py +69 -0
- project_kit/approval.py +36 -0
- project_kit/assets/hooks/commit-msg +14 -0
- project_kit/assets/project/workflow.yml +63 -0
- project_kit/assets/skills/pk-decide/SKILL.md +63 -0
- project_kit/assets/skills/pk-discover/SKILL.md +57 -0
- project_kit/assets/skills/pk-handoff/SKILL.md +43 -0
- project_kit/assets/skills/pk-plan/SKILL.md +55 -0
- project_kit/assets/skills/pk-provision/SKILL.md +45 -0
- project_kit/assets/skills/pk-start/SKILL.md +47 -0
- project_kit/assets/skills/pk-workflow/SKILL.md +51 -0
- project_kit/assets/templates/adr.md +32 -0
- project_kit/assets/templates/backlog.example.json +59 -0
- project_kit/assets/templates/brief.md +77 -0
- project_kit/assets/templates/constitution-input.md +78 -0
- project_kit/assets/templates/development-workflow.md +51 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/adr.yml +21 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/bug.yml +25 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/config.yml +1 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/epic.yml +15 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/feature.yml +22 -0
- project_kit/assets/templates/github/pull_request_template.md +21 -0
- project_kit/assets/templates/interview.md +68 -0
- project_kit/backlog.py +312 -0
- project_kit/cli.py +286 -0
- project_kit/config.py +139 -0
- project_kit/doctor.py +187 -0
- project_kit/fsutil.py +27 -0
- project_kit/gh.py +152 -0
- project_kit/handoff.py +360 -0
- project_kit/help.py +152 -0
- project_kit/installer.py +100 -0
- project_kit/labels.py +17 -0
- project_kit/project_choice.py +127 -0
- project_kit/providers/__init__.py +1 -0
- project_kit/providers/github.py +329 -0
- project_kit/provision.py +260 -0
- project_kit/provision_issues.py +360 -0
- project_kit/state.py +115 -0
- project_kit/templating.py +23 -0
- project_kit/workflow.py +168 -0
- project_kit_cli-0.1.0.dist-info/METADATA +97 -0
- project_kit_cli-0.1.0.dist-info/RECORD +49 -0
- project_kit_cli-0.1.0.dist-info/WHEEL +4 -0
- project_kit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- project_kit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
project_kit/handoff.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""``project-kit handoff``: hand the project over to Spec Kit.
|
|
2
|
+
|
|
3
|
+
project-kit never writes inside ``.specify/``; Spec Kit owns it. What this module does:
|
|
4
|
+
|
|
5
|
+
* detects Spec Kit (CLI and ``.specify/``) and its version;
|
|
6
|
+
* generates ``.project-kit/handoff/constitution-input.md``, the text to pass to
|
|
7
|
+
``/speckit-constitution``;
|
|
8
|
+
* with explicit confirmation, installs the Spec Kit CLI and initialises the repository by
|
|
9
|
+
running Spec Kit's own commands.
|
|
10
|
+
|
|
11
|
+
The only contract with Spec Kit is therefore a plain file plus its public commands, so an
|
|
12
|
+
unknown Spec Kit version degrades to a warning instead of breaking.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from importlib.resources import files
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from project_kit.backlog import BacklogError, load_backlog
|
|
25
|
+
from project_kit.config import PROJECT_DIR, Config, load_config
|
|
26
|
+
from project_kit.fsutil import atomic_write_text
|
|
27
|
+
from project_kit.state import load_state
|
|
28
|
+
from project_kit.templating import render
|
|
29
|
+
|
|
30
|
+
# Spec Kit versions (major.minor) this release was tried against.
|
|
31
|
+
TESTED_SPECKIT = ("1.2",)
|
|
32
|
+
MANAGED_HEADER = "<!-- project-kit:managed -->"
|
|
33
|
+
TEMPLATE = "constitution-input.md"
|
|
34
|
+
INPUT_FILE = Path(PROJECT_DIR) / "handoff" / "constitution-input.md"
|
|
35
|
+
EXTRA_FILE = Path(PROJECT_DIR) / "handoff" / "constitution-extra.md"
|
|
36
|
+
|
|
37
|
+
Which = Callable[[str], str | None]
|
|
38
|
+
Runner = Callable[..., subprocess.CompletedProcess]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- detecting Spec Kit ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SpecKit:
|
|
46
|
+
cli: str | None # path of the `specify` executable
|
|
47
|
+
cli_version: str | None
|
|
48
|
+
initialised: bool # this repository has a .specify/ directory
|
|
49
|
+
project_version: str | None # version recorded by Spec Kit in .specify/init-options.json
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def installed(self) -> bool:
|
|
53
|
+
return self.cli is not None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_tested(version: str | None) -> bool:
|
|
57
|
+
return bool(version) and any(
|
|
58
|
+
version == t or version.startswith(t + ".")
|
|
59
|
+
for t in TESTED_SPECKIT # type: ignore[union-attr]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cli_version(exe: str, runner: Runner) -> str | None:
|
|
64
|
+
try:
|
|
65
|
+
proc = runner([exe, "--version"], capture_output=True, text=True, encoding="utf-8")
|
|
66
|
+
except OSError:
|
|
67
|
+
return None
|
|
68
|
+
match = re.search(r"\d+\.\d+\.\d+\S*", proc.stdout or "") if proc.returncode == 0 else None
|
|
69
|
+
return match.group(0) if match else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _project_version(root: Path) -> str | None:
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads((root / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
version = data.get("speckit_version") if isinstance(data, dict) else None
|
|
78
|
+
return version if isinstance(version, str) else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def detect_speckit(
|
|
82
|
+
root: Path, which: Which = shutil.which, runner: Runner = subprocess.run
|
|
83
|
+
) -> SpecKit:
|
|
84
|
+
cli = which("specify")
|
|
85
|
+
return SpecKit(
|
|
86
|
+
cli=cli,
|
|
87
|
+
cli_version=_cli_version(cli, runner) if cli else None,
|
|
88
|
+
initialised=(root / ".specify").is_dir(),
|
|
89
|
+
project_version=_project_version(root),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- the constitution input --------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _load_template(root: Path) -> str:
|
|
97
|
+
"""The project's own copy if it has one (templates are overridable), else the shipped one."""
|
|
98
|
+
own = root / PROJECT_DIR / "templates" / TEMPLATE
|
|
99
|
+
if own.is_file():
|
|
100
|
+
return own.read_text(encoding="utf-8")
|
|
101
|
+
return (files("project_kit") / "assets" / "templates" / TEMPLATE).read_text(encoding="utf-8")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _adr_entry(path: Path, root: Path) -> str:
|
|
105
|
+
text = path.read_text(encoding="utf-8")
|
|
106
|
+
title = next(
|
|
107
|
+
(m.group(1).strip() for line in text.splitlines() if (m := re.match(r"#\s+(.+)", line))),
|
|
108
|
+
path.stem,
|
|
109
|
+
)
|
|
110
|
+
# "Status: Accepted", "**Status:** Accepted", "- Status: Accepted"...
|
|
111
|
+
status = re.search(
|
|
112
|
+
r"^[\s*_#>\-]*status[\s*_]*[:\-][\s*_]*([A-Za-z]+)", text, re.IGNORECASE | re.MULTILINE
|
|
113
|
+
)
|
|
114
|
+
label = f" ({status.group(1)})" if status else ""
|
|
115
|
+
return f"- `{path.relative_to(root).as_posix()}`: {title}{label}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def list_decisions(root: Path) -> list[str]:
|
|
119
|
+
adr_dir = root / "docs" / "adr"
|
|
120
|
+
if not adr_dir.is_dir():
|
|
121
|
+
return []
|
|
122
|
+
return [_adr_entry(p, root) for p in sorted(adr_dir.glob("*.md"))]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _references(root: Path) -> str:
|
|
126
|
+
lines = []
|
|
127
|
+
docs = [
|
|
128
|
+
f"`{p}`"
|
|
129
|
+
for p in ("docs/project/brief.md", "docs/project/roadmap.md")
|
|
130
|
+
if (root / p).is_file()
|
|
131
|
+
]
|
|
132
|
+
if docs:
|
|
133
|
+
lines.append(f"- Project documents: {', '.join(docs)}.")
|
|
134
|
+
try:
|
|
135
|
+
phases = [p.title for p in load_backlog(root).phases]
|
|
136
|
+
except BacklogError:
|
|
137
|
+
phases = []
|
|
138
|
+
if phases:
|
|
139
|
+
lines.append(f"- Delivery phases: {', '.join(phases)}.")
|
|
140
|
+
return "\n".join(lines)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_constitution_input(root: Path, config: Config) -> str:
|
|
144
|
+
decisions = list_decisions(root)
|
|
145
|
+
extra_path = root / EXTRA_FILE
|
|
146
|
+
extra = ""
|
|
147
|
+
if extra_path.is_file() and extra_path.read_text(encoding="utf-8").strip():
|
|
148
|
+
extra = (
|
|
149
|
+
"\n## Project-specific rules\n\n"
|
|
150
|
+
+ extra_path.read_text(encoding="utf-8").strip()
|
|
151
|
+
+ "\n"
|
|
152
|
+
)
|
|
153
|
+
variables = {
|
|
154
|
+
"project_name": config.project_name,
|
|
155
|
+
"language": config.language,
|
|
156
|
+
"provider": config.provider,
|
|
157
|
+
**{f"branches.{k}": v for k, v in config.branches.items()},
|
|
158
|
+
"stack": (
|
|
159
|
+
"defined by the decisions on record below."
|
|
160
|
+
if decisions
|
|
161
|
+
else "not decided yet; it is settled by an ADR before the first spec."
|
|
162
|
+
),
|
|
163
|
+
"references": _references(root),
|
|
164
|
+
"decisions": "\n".join(decisions) if decisions else "No decisions are recorded yet.",
|
|
165
|
+
"extra": extra,
|
|
166
|
+
}
|
|
167
|
+
return render(_load_template(root), variables)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_managed(path: Path, text: str) -> str:
|
|
171
|
+
"""Write a generated file. Returns created | updated | unchanged | kept.
|
|
172
|
+
|
|
173
|
+
A file that lost its ``project-kit:managed`` header has been taken over by the user and
|
|
174
|
+
is never overwritten.
|
|
175
|
+
"""
|
|
176
|
+
if not path.exists():
|
|
177
|
+
atomic_write_text(path, text)
|
|
178
|
+
return "created"
|
|
179
|
+
current = path.read_text(encoding="utf-8")
|
|
180
|
+
if current == text:
|
|
181
|
+
return "unchanged"
|
|
182
|
+
if MANAGED_HEADER not in current.splitlines()[:3]:
|
|
183
|
+
return "kept"
|
|
184
|
+
atomic_write_text(path, text)
|
|
185
|
+
return "updated"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --- installing Spec Kit -----------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class Step:
|
|
193
|
+
description: str
|
|
194
|
+
command: list[str]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def install_steps(sk: SpecKit, which: Which) -> tuple[list[Step], list[str]]:
|
|
198
|
+
"""What has to run to get a working Spec Kit here, and what prevents it."""
|
|
199
|
+
steps: list[Step] = []
|
|
200
|
+
problems: list[str] = []
|
|
201
|
+
if not sk.installed:
|
|
202
|
+
if which("uv"):
|
|
203
|
+
steps.append(Step("Install the Spec Kit CLI", ["uv", "tool", "install", "specify-cli"]))
|
|
204
|
+
else:
|
|
205
|
+
problems.append(
|
|
206
|
+
"uv is needed to install Spec Kit (https://docs.astral.sh/uv/), or install "
|
|
207
|
+
"the `specify-cli` package yourself (for example with pipx) and run this again."
|
|
208
|
+
)
|
|
209
|
+
if not sk.initialised:
|
|
210
|
+
steps.append(
|
|
211
|
+
Step(
|
|
212
|
+
"Initialise Spec Kit in this repository (it merges its files into it)",
|
|
213
|
+
[
|
|
214
|
+
"specify",
|
|
215
|
+
"init",
|
|
216
|
+
"--here",
|
|
217
|
+
"--integration",
|
|
218
|
+
"claude",
|
|
219
|
+
"--force",
|
|
220
|
+
"--non-interactive",
|
|
221
|
+
],
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
return steps, problems
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _resolve_specify(which: Which, runner: Runner) -> str | None:
|
|
228
|
+
"""Find ``specify`` right after installing it, when the current PATH may not have it yet."""
|
|
229
|
+
if found := which("specify"):
|
|
230
|
+
return found
|
|
231
|
+
try:
|
|
232
|
+
proc = runner(
|
|
233
|
+
["uv", "tool", "dir", "--bin"], capture_output=True, text=True, encoding="utf-8"
|
|
234
|
+
)
|
|
235
|
+
except OSError:
|
|
236
|
+
return None
|
|
237
|
+
if proc.returncode != 0:
|
|
238
|
+
return None
|
|
239
|
+
bin_dir = Path((proc.stdout or "").strip())
|
|
240
|
+
for name in ("specify.exe", "specify"):
|
|
241
|
+
if (bin_dir / name).is_file():
|
|
242
|
+
return str(bin_dir / name)
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --- the command -------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
NEXT_STEPS = f"""\
|
|
249
|
+
Next steps
|
|
250
|
+
1. In Claude Code, run:
|
|
251
|
+
/speckit-constitution @{INPUT_FILE.as_posix()}
|
|
252
|
+
Spec Kit owns .specify/memory/constitution.md; project-kit never edits it.
|
|
253
|
+
2. Optional: mention docs/project/ and docs/adr/ in CLAUDE.md or AGENTS.md so that
|
|
254
|
+
/speckit-specify and /speckit-plan read them.
|
|
255
|
+
3. Every card of the backlog is a candidate spec: use its issue as the input of
|
|
256
|
+
/speckit-specify.
|
|
257
|
+
4. When the constitution is in place, record it with: project-kit approve handoff"""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_handoff(
|
|
261
|
+
root: Path,
|
|
262
|
+
*,
|
|
263
|
+
install: bool,
|
|
264
|
+
assume_yes: bool,
|
|
265
|
+
dry_run: bool,
|
|
266
|
+
confirm: Callable[[str], bool],
|
|
267
|
+
out: Callable[[str], None] = print,
|
|
268
|
+
which: Which = shutil.which,
|
|
269
|
+
runner: Runner = subprocess.run,
|
|
270
|
+
) -> int:
|
|
271
|
+
"""Returns the process exit code."""
|
|
272
|
+
config = load_config(root)
|
|
273
|
+
state = load_state(root)
|
|
274
|
+
if not state.is_approved("plan"):
|
|
275
|
+
out("Note: the plan has not been approved yet (project-kit approve plan).\n")
|
|
276
|
+
|
|
277
|
+
sk = detect_speckit(root, which, runner)
|
|
278
|
+
text = build_constitution_input(root, config)
|
|
279
|
+
target = root / INPUT_FILE
|
|
280
|
+
|
|
281
|
+
if dry_run:
|
|
282
|
+
out(f"Dry run: {INPUT_FILE.as_posix()} would contain:\n\n{text}")
|
|
283
|
+
else:
|
|
284
|
+
result = write_managed(target, text)
|
|
285
|
+
if result == "kept":
|
|
286
|
+
out(
|
|
287
|
+
f"{INPUT_FILE.as_posix()} no longer has the project-kit:managed header, so it was "
|
|
288
|
+
"left as it is. Delete it to have it generated again."
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
out(f"{INPUT_FILE.as_posix()}: {result}.")
|
|
292
|
+
|
|
293
|
+
for note in _speckit_notes(sk):
|
|
294
|
+
out(note)
|
|
295
|
+
|
|
296
|
+
steps, problems = install_steps(sk, which)
|
|
297
|
+
if steps and not install:
|
|
298
|
+
out(
|
|
299
|
+
"\nSpec Kit is not ready in this repository. Run `project-kit handoff "
|
|
300
|
+
"--install-speckit` to set it up, or do it yourself with Spec Kit's own commands."
|
|
301
|
+
)
|
|
302
|
+
elif steps:
|
|
303
|
+
for problem in problems:
|
|
304
|
+
out(f"\nBLOCKING: {problem}")
|
|
305
|
+
if problems:
|
|
306
|
+
return 1
|
|
307
|
+
out("\nTo set up Spec Kit:")
|
|
308
|
+
for step in steps:
|
|
309
|
+
out(f" - {step.description}: {' '.join(step.command)}")
|
|
310
|
+
if dry_run:
|
|
311
|
+
out("\nDry run: nothing was run.")
|
|
312
|
+
return 0
|
|
313
|
+
if not assume_yes and not confirm("\nRun these commands?"):
|
|
314
|
+
out("Aborted. Spec Kit was not touched.")
|
|
315
|
+
return 1
|
|
316
|
+
if not _run_steps(root, steps, out, which, runner):
|
|
317
|
+
return 1
|
|
318
|
+
sk = detect_speckit(root, which, runner)
|
|
319
|
+
elif problems:
|
|
320
|
+
for problem in problems:
|
|
321
|
+
out(f"\nBLOCKING: {problem}")
|
|
322
|
+
return 1
|
|
323
|
+
|
|
324
|
+
if sk.initialised and not dry_run:
|
|
325
|
+
out("\n" + NEXT_STEPS)
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _speckit_notes(sk: SpecKit) -> list[str]:
|
|
330
|
+
notes = []
|
|
331
|
+
for label, version in (("CLI", sk.cli_version), ("this repository", sk.project_version)):
|
|
332
|
+
if version and not is_tested(version):
|
|
333
|
+
notes.append(
|
|
334
|
+
f"Note: Spec Kit {label} is version {version}, but project-kit was tried with "
|
|
335
|
+
f"{', '.join(v + '.x' for v in TESTED_SPECKIT)}. Only the constitution input "
|
|
336
|
+
"file is relied on, so this should still work."
|
|
337
|
+
)
|
|
338
|
+
return notes
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _run_steps(
|
|
342
|
+
root: Path, steps: list[Step], out: Callable[[str], None], which: Which, runner: Runner
|
|
343
|
+
) -> bool:
|
|
344
|
+
for step in steps:
|
|
345
|
+
command = list(step.command)
|
|
346
|
+
if command[0] == "specify":
|
|
347
|
+
exe = _resolve_specify(which, runner)
|
|
348
|
+
if exe is None:
|
|
349
|
+
out(
|
|
350
|
+
"\nSpec Kit was installed but `specify` is not on your PATH yet. Run "
|
|
351
|
+
"`uv tool update-shell`, open a new terminal and run this command again."
|
|
352
|
+
)
|
|
353
|
+
return False
|
|
354
|
+
command[0] = exe
|
|
355
|
+
out(f"\n> {' '.join(command)}")
|
|
356
|
+
proc = runner(command, cwd=root)
|
|
357
|
+
if proc.returncode != 0:
|
|
358
|
+
out(f"\nStopped: `{' '.join(step.command)}` failed (exit {proc.returncode}).")
|
|
359
|
+
return False
|
|
360
|
+
return True
|
project_kit/help.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""``project-kit help``: a visual guide to the flow and the commands, for the terminal.
|
|
2
|
+
|
|
3
|
+
Plain text with a little ANSI colour. It degrades on its own: no colour when the output is not
|
|
4
|
+
a terminal or ``NO_COLOR`` is set, and ASCII instead of box-drawing characters when the output
|
|
5
|
+
encoding cannot print them (the Windows console redirected to a file, for instance).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import textwrap
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TextIO
|
|
14
|
+
|
|
15
|
+
from project_kit import __version__
|
|
16
|
+
from project_kit.state import State
|
|
17
|
+
from project_kit.workflow import Workflow, step_marks
|
|
18
|
+
|
|
19
|
+
# What runs in the terminal. The test suite checks that every subcommand is listed here.
|
|
20
|
+
TERMINAL_COMMANDS = (
|
|
21
|
+
("project-kit init", "install the skills, templates and workflow in this directory"),
|
|
22
|
+
("project-kit status", "show the phases, where you are and what comes next"),
|
|
23
|
+
("project-kit doctor", "check that git, gh, uv and Spec Kit are available"),
|
|
24
|
+
("project-kit approve <phase>", "record your sign-off of a phase"),
|
|
25
|
+
("project-kit backlog validate", "check .project-kit/backlog.json"),
|
|
26
|
+
("project-kit provision [--dry-run]", "create milestones, branches, Project, issues"),
|
|
27
|
+
("project-kit handoff [--install-speckit]", "prepare the Spec Kit constitution input"),
|
|
28
|
+
("project-kit help", "show this guide"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Style:
|
|
34
|
+
color: bool = False
|
|
35
|
+
unicode: bool = False
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def detect(cls, stream: TextIO | None = None) -> "Style":
|
|
39
|
+
stream = stream or sys.stdout
|
|
40
|
+
color = bool(getattr(stream, "isatty", lambda: False)()) and not os.environ.get("NO_COLOR")
|
|
41
|
+
try:
|
|
42
|
+
"─│→".encode(getattr(stream, "encoding", None) or "ascii")
|
|
43
|
+
unicode = True
|
|
44
|
+
except (UnicodeEncodeError, LookupError):
|
|
45
|
+
unicode = False
|
|
46
|
+
if color and os.name == "nt":
|
|
47
|
+
os.system("") # switches the Windows console to understand ANSI escapes
|
|
48
|
+
return cls(color=color, unicode=unicode)
|
|
49
|
+
|
|
50
|
+
def paint(self, text: str, code: str) -> str:
|
|
51
|
+
return f"\033[{code}m{text}\033[0m" if self.color else text
|
|
52
|
+
|
|
53
|
+
def bold(self, text: str) -> str:
|
|
54
|
+
return self.paint(text, "1")
|
|
55
|
+
|
|
56
|
+
def dim(self, text: str) -> str:
|
|
57
|
+
return self.paint(text, "2")
|
|
58
|
+
|
|
59
|
+
def green(self, text: str) -> str:
|
|
60
|
+
return self.paint(text, "32")
|
|
61
|
+
|
|
62
|
+
def cyan(self, text: str) -> str:
|
|
63
|
+
return self.paint(text, "1;36")
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def bar(self) -> str:
|
|
67
|
+
return "│" if self.unicode else "|"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def arrow(self) -> str:
|
|
71
|
+
return "→" if self.unicode else "->"
|
|
72
|
+
|
|
73
|
+
def rule(self, title: str, width: int) -> str:
|
|
74
|
+
line = ("─" if self.unicode else "-") * max(width - len(title) - 2, 4)
|
|
75
|
+
return f"{self.bold(title)} {self.dim(line)}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _mark(style: Style, mark: str) -> str:
|
|
79
|
+
cell = f"[{mark}]"
|
|
80
|
+
if mark == "x":
|
|
81
|
+
return style.green(cell)
|
|
82
|
+
if mark == ">":
|
|
83
|
+
return style.cyan(cell)
|
|
84
|
+
return style.dim(cell) if mark == "-" else cell
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _flow(workflow: Workflow, state: State | None, style: Style, width: int) -> list[str]:
|
|
88
|
+
marks = (
|
|
89
|
+
step_marks(workflow, state)
|
|
90
|
+
if state is not None
|
|
91
|
+
else [(s, " " if s.enabled else "-", "") for s in workflow.steps]
|
|
92
|
+
)
|
|
93
|
+
indent = " " * 18
|
|
94
|
+
lines = []
|
|
95
|
+
for n, (step, mark, _note) in enumerate(marks):
|
|
96
|
+
name = f"{step.id:<10} /{step.skill}"
|
|
97
|
+
lines.append(f" {_mark(style, mark)} {style.bold(name) if mark == '>' else name}")
|
|
98
|
+
detail = textwrap.wrap(step.title, max(width - len(indent) - 4, 30))
|
|
99
|
+
if step.produces:
|
|
100
|
+
detail.append(f"{style.arrow} {', '.join(step.produces)}")
|
|
101
|
+
for text in detail:
|
|
102
|
+
lines.append(f" {style.bar}{indent[5:]}{style.dim(text)}")
|
|
103
|
+
if n < len(marks) - 1:
|
|
104
|
+
lines.append(f" {style.bar}")
|
|
105
|
+
return lines
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_help(
|
|
109
|
+
workflow: Workflow,
|
|
110
|
+
state: State | None = None,
|
|
111
|
+
project_name: str | None = None,
|
|
112
|
+
style: Style | None = None,
|
|
113
|
+
width: int | None = None,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""The guide. With a ``state`` it marks the progress of the project in this directory."""
|
|
116
|
+
style = style or Style()
|
|
117
|
+
width = min(width or shutil.get_terminal_size((80, 24)).columns, 100)
|
|
118
|
+
out = [
|
|
119
|
+
f"{style.bold('project-kit')} {__version__}",
|
|
120
|
+
"Define, plan and set up a project before handing it over to Spec Kit.",
|
|
121
|
+
"",
|
|
122
|
+
style.rule("HOW IT WORKS", width),
|
|
123
|
+
" The terminal sets things up; your AI agent (Claude Code) runs the guided",
|
|
124
|
+
" conversations as /pk-... skills. Every phase ends with your approval, and",
|
|
125
|
+
" you can fill each document by hand instead.",
|
|
126
|
+
"",
|
|
127
|
+
]
|
|
128
|
+
title = "THE FLOW" + (f" ({project_name})" if project_name else "")
|
|
129
|
+
out += [style.rule(title, width), *_flow(workflow, state, style, width), ""]
|
|
130
|
+
if state is not None:
|
|
131
|
+
nxt = workflow.next_step(state)
|
|
132
|
+
if nxt is None:
|
|
133
|
+
out += ["Every enabled phase is done.", ""]
|
|
134
|
+
else:
|
|
135
|
+
out += [
|
|
136
|
+
f"Next: {style.cyan('/' + nxt.skill)} in your agent (or see `project-kit status`).",
|
|
137
|
+
"",
|
|
138
|
+
]
|
|
139
|
+
out += [style.rule("IN THE TERMINAL", width)]
|
|
140
|
+
for cmd, desc in TERMINAL_COMMANDS:
|
|
141
|
+
out += [f" {style.bold(cmd)}", f" {style.dim(desc)}"]
|
|
142
|
+
out += [""]
|
|
143
|
+
if state is None:
|
|
144
|
+
out += [
|
|
145
|
+
style.rule("FIRST TIME", width),
|
|
146
|
+
" 1. project-kit doctor check the tools",
|
|
147
|
+
" 2. project-kit init in your repository; it offers to open your agent",
|
|
148
|
+
" 3. /pk-start in the agent: only initialise, or begin",
|
|
149
|
+
"",
|
|
150
|
+
]
|
|
151
|
+
out += ["Details of one command: project-kit <command> --help"]
|
|
152
|
+
return "\n".join(out)
|
project_kit/installer.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""``project-kit init``: install project-kit's files into a target repository.
|
|
2
|
+
|
|
3
|
+
Rule: never overwrite. A file that already exists is kept as it is (whether or not it
|
|
4
|
+
differs from the shipped one), so manual edits are safe and running ``init`` again is a
|
|
5
|
+
no-op. Updating shipped files is the job of the future ``upgrade`` command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from importlib.resources.abc import Traversable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from project_kit import ai_attribution
|
|
15
|
+
from project_kit.config import PROJECT_DIR, Config, config_path, load_config, save_config
|
|
16
|
+
from project_kit.fsutil import atomic_write_text
|
|
17
|
+
from project_kit.state import State, save_state, state_path
|
|
18
|
+
|
|
19
|
+
# Where each shipped asset directory lands in the target repository.
|
|
20
|
+
_ASSET_TARGETS = {
|
|
21
|
+
"skills": Path(".claude") / "skills",
|
|
22
|
+
"templates": Path(PROJECT_DIR) / "templates",
|
|
23
|
+
"project": Path(PROJECT_DIR),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Report:
|
|
29
|
+
created: list[Path] = field(default_factory=list)
|
|
30
|
+
unchanged: list[Path] = field(default_factory=list) # exists and identical to ours
|
|
31
|
+
kept: list[Path] = field(default_factory=list) # exists and differs: left alone
|
|
32
|
+
notes: list[str] = field(default_factory=list) # things the user should know or do
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _walk(node: Traversable, prefix: Path = Path()) -> Iterator[tuple[Path, Traversable]]:
|
|
36
|
+
for child in sorted(node.iterdir(), key=lambda c: c.name):
|
|
37
|
+
rel = prefix / child.name
|
|
38
|
+
if child.is_dir():
|
|
39
|
+
yield from _walk(child, rel)
|
|
40
|
+
elif child.name != "__pycache__":
|
|
41
|
+
yield rel, child
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def iter_assets() -> Iterator[tuple[Path, bytes]]:
|
|
45
|
+
"""Every shipped file as (path relative to the target repo, content)."""
|
|
46
|
+
assets = files("project_kit") / "assets"
|
|
47
|
+
for kind, target in _ASSET_TARGETS.items():
|
|
48
|
+
for rel, node in _walk(assets / kind):
|
|
49
|
+
yield target / rel, node.read_bytes()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _install_file(root: Path, rel: Path, content: bytes, report: Report) -> None:
|
|
53
|
+
dest = root / rel
|
|
54
|
+
if dest.exists():
|
|
55
|
+
(report.unchanged if dest.read_bytes() == content else report.kept).append(rel)
|
|
56
|
+
return
|
|
57
|
+
atomic_write_text(dest, content.decode("utf-8"))
|
|
58
|
+
report.created.append(rel)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _apply_ai_policy(root: Path, config: Config, report: Report) -> None:
|
|
62
|
+
"""If the project asks that AI assistants are not co-authors, set that up (idempotent)."""
|
|
63
|
+
if config.ai_coauthor:
|
|
64
|
+
return
|
|
65
|
+
for create in (ai_attribution.configure_claude, ai_attribution.install_hook):
|
|
66
|
+
changed, note = create(root)
|
|
67
|
+
if changed:
|
|
68
|
+
report.created.append(changed)
|
|
69
|
+
if note:
|
|
70
|
+
report.notes.append(note)
|
|
71
|
+
if not ai_attribution.hook_is_active(root):
|
|
72
|
+
report.notes.append(
|
|
73
|
+
"To strip AI co-author trailers from every commit, run: "
|
|
74
|
+
+ ai_attribution.HOOKS_PATH_COMMAND
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def init_project(root: Path, config: Config) -> Report:
|
|
79
|
+
"""Install project-kit into ``root``. ``config`` is used only if there is none yet."""
|
|
80
|
+
report = Report()
|
|
81
|
+
|
|
82
|
+
cfg = config_path(root)
|
|
83
|
+
if cfg.exists():
|
|
84
|
+
load_config(root) # fail early, with a clear message, if the existing one is invalid
|
|
85
|
+
report.unchanged.append(cfg.relative_to(root))
|
|
86
|
+
else:
|
|
87
|
+
save_config(root, config)
|
|
88
|
+
report.created.append(cfg.relative_to(root))
|
|
89
|
+
|
|
90
|
+
st = state_path(root)
|
|
91
|
+
if st.exists():
|
|
92
|
+
report.unchanged.append(st.relative_to(root))
|
|
93
|
+
else:
|
|
94
|
+
save_state(root, State())
|
|
95
|
+
report.created.append(st.relative_to(root))
|
|
96
|
+
|
|
97
|
+
for rel, content in iter_assets():
|
|
98
|
+
_install_file(root, rel, content, report)
|
|
99
|
+
_apply_ai_policy(root, load_config(root), report)
|
|
100
|
+
return report
|
project_kit/labels.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Labels project-kit derives from the backlog: one per issue type and per priority."""
|
|
2
|
+
|
|
3
|
+
# (name, colour, description). Bugs reuse GitHub's own default "bug" label.
|
|
4
|
+
TYPE_LABELS = {
|
|
5
|
+
"epic": ("type:epic", "3E4B9E", "Epic: groups child issues"),
|
|
6
|
+
"feature": ("type:feature", "1D76DB", "New functionality"),
|
|
7
|
+
"adr": ("type:adr", "5319E7", "Architecture decision"),
|
|
8
|
+
"chore": ("type:chore", "6E7781", "Maintenance or technical task"),
|
|
9
|
+
"docs": ("type:docs", "0075CA", "Documentation"),
|
|
10
|
+
"bug": ("bug", "D73A4A", "Something is not working"),
|
|
11
|
+
}
|
|
12
|
+
PRIORITY_LABELS = {
|
|
13
|
+
"P0": ("P0", "B60205", "Critical priority"),
|
|
14
|
+
"P1": ("P1", "D93F0B", "High priority"),
|
|
15
|
+
"P2": ("P2", "FBCA04", "Medium priority"),
|
|
16
|
+
"P3": ("P3", "C2E0C6", "Low priority"),
|
|
17
|
+
}
|