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/backlog.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
""".project-kit/backlog.json: the project backlog, source of truth for provisioning.
|
|
2
|
+
|
|
3
|
+
Shape (see docs/backlog-schema.md for the full description)::
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"version": 1,
|
|
7
|
+
"phases": [{"id", "title", "description"?}], # -> milestones / Phase field
|
|
8
|
+
"labels": [{"name", "color"?, "description"?}], # extra labels, optional
|
|
9
|
+
"issues": [{"id", "type", "phase", "title", "priority", "size",
|
|
10
|
+
"parent"?, "body"?, "acceptance"?, "labels"?}]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
Validation collects every problem it finds, so the user can fix them in one pass.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from project_kit.config import PROJECT_DIR
|
|
24
|
+
from project_kit.fsutil import atomic_write_text
|
|
25
|
+
|
|
26
|
+
BACKLOG_VERSION = 1
|
|
27
|
+
ISSUE_TYPES = ("epic", "feature", "adr", "chore", "bug", "docs")
|
|
28
|
+
PRIORITIES = ("P0", "P1", "P2", "P3")
|
|
29
|
+
SIZES = ("XS", "S", "M", "L", "XL")
|
|
30
|
+
# Work that gets implemented needs a definition of done; epics and decisions do not.
|
|
31
|
+
NEEDS_ACCEPTANCE = frozenset({"feature", "bug", "chore"})
|
|
32
|
+
|
|
33
|
+
_ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
34
|
+
_COLOR = re.compile(r"^[0-9a-fA-F]{6}$")
|
|
35
|
+
_DEFAULT_COLOR = "ededed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BacklogError(ValueError):
|
|
39
|
+
"""The backlog is missing, unreadable or invalid. ``problems`` lists every issue found."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, problems: list[str]) -> None:
|
|
42
|
+
super().__init__("Invalid backlog:\n" + "\n".join(f" - {p}" for p in problems))
|
|
43
|
+
self.problems = problems
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def backlog_path(root: Path) -> Path:
|
|
47
|
+
return root / PROJECT_DIR / "backlog.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Phase:
|
|
52
|
+
id: str
|
|
53
|
+
title: str
|
|
54
|
+
description: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Label:
|
|
59
|
+
name: str
|
|
60
|
+
color: str = _DEFAULT_COLOR
|
|
61
|
+
description: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Issue:
|
|
66
|
+
id: str
|
|
67
|
+
type: str
|
|
68
|
+
phase: str
|
|
69
|
+
title: str
|
|
70
|
+
priority: str
|
|
71
|
+
size: str
|
|
72
|
+
parent: str | None = None
|
|
73
|
+
body: str = ""
|
|
74
|
+
acceptance: list[str] = field(default_factory=list)
|
|
75
|
+
labels: list[str] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Backlog:
|
|
80
|
+
phases: list[Phase] = field(default_factory=list)
|
|
81
|
+
issues: list[Issue] = field(default_factory=list)
|
|
82
|
+
labels: list[Label] = field(default_factory=list)
|
|
83
|
+
|
|
84
|
+
def children_of(self, epic_id: str) -> list[Issue]:
|
|
85
|
+
return [i for i in self.issues if i.parent == epic_id]
|
|
86
|
+
|
|
87
|
+
def summary(self) -> str:
|
|
88
|
+
by_type = Counter(i.type for i in self.issues)
|
|
89
|
+
lines = [f"{len(self.phases)} phase(s), {len(self.issues)} issue(s)"]
|
|
90
|
+
for phase in self.phases:
|
|
91
|
+
items = [i for i in self.issues if i.phase == phase.id]
|
|
92
|
+
counts = ", ".join(f"{by}: {n}" for by, n in Counter(i.type for i in items).items())
|
|
93
|
+
lines.append(f" {phase.title}: {len(items)} ({counts or 'empty'})")
|
|
94
|
+
if by_type:
|
|
95
|
+
lines.append(
|
|
96
|
+
"by priority: "
|
|
97
|
+
+ ", ".join(
|
|
98
|
+
f"{p}: {n}" for p, n in sorted(Counter(i.priority for i in self.issues).items())
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict[str, Any]:
|
|
104
|
+
def issue(i: Issue) -> dict[str, Any]:
|
|
105
|
+
d: dict[str, Any] = {
|
|
106
|
+
"id": i.id,
|
|
107
|
+
"type": i.type,
|
|
108
|
+
"phase": i.phase,
|
|
109
|
+
"title": i.title,
|
|
110
|
+
"priority": i.priority,
|
|
111
|
+
"size": i.size,
|
|
112
|
+
}
|
|
113
|
+
if i.parent:
|
|
114
|
+
d["parent"] = i.parent
|
|
115
|
+
if i.body:
|
|
116
|
+
d["body"] = i.body
|
|
117
|
+
if i.acceptance:
|
|
118
|
+
d["acceptance"] = list(i.acceptance)
|
|
119
|
+
if i.labels:
|
|
120
|
+
d["labels"] = list(i.labels)
|
|
121
|
+
return d
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"version": BACKLOG_VERSION,
|
|
125
|
+
"phases": [
|
|
126
|
+
{k: v for k, v in vars(p).items() if v != ""} | {"id": p.id, "title": p.title}
|
|
127
|
+
for p in self.phases
|
|
128
|
+
],
|
|
129
|
+
"labels": [{k: v for k, v in vars(label).items() if v != ""} for label in self.labels],
|
|
130
|
+
"issues": [issue(i) for i in self.issues],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _Collector:
|
|
135
|
+
"""Reads typed values out of untyped JSON, recording problems instead of raising."""
|
|
136
|
+
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
self.problems: list[str] = []
|
|
139
|
+
|
|
140
|
+
def add(self, where: str, message: str) -> None:
|
|
141
|
+
self.problems.append(f"{where}: {message}")
|
|
142
|
+
|
|
143
|
+
def obj(self, value: Any, where: str, allowed: set[str]) -> dict[str, Any] | None:
|
|
144
|
+
if not isinstance(value, dict):
|
|
145
|
+
self.add(where, "must be an object")
|
|
146
|
+
return None
|
|
147
|
+
for key in sorted(set(value) - allowed, key=str):
|
|
148
|
+
self.add(where, f"unknown field {key!r}")
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
def text(self, data: dict[str, Any], key: str, where: str, *, required: bool = True) -> str:
|
|
152
|
+
if key not in data or data[key] is None:
|
|
153
|
+
if required:
|
|
154
|
+
self.add(where, f"missing required field {key!r}")
|
|
155
|
+
return ""
|
|
156
|
+
value = data[key]
|
|
157
|
+
if not isinstance(value, str):
|
|
158
|
+
self.add(where, f"{key!r} must be a string")
|
|
159
|
+
return ""
|
|
160
|
+
if required and not value.strip():
|
|
161
|
+
self.add(where, f"{key!r} must not be empty")
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
def choice(self, data: dict[str, Any], key: str, where: str, options: tuple[str, ...]) -> str:
|
|
165
|
+
value = self.text(data, key, where)
|
|
166
|
+
if value and value not in options:
|
|
167
|
+
self.add(where, f"{key!r} must be one of {', '.join(options)}, got {value!r}")
|
|
168
|
+
return value
|
|
169
|
+
|
|
170
|
+
def strings(self, data: dict[str, Any], key: str, where: str) -> list[str]:
|
|
171
|
+
value = data.get(key, [])
|
|
172
|
+
if not isinstance(value, list) or not all(isinstance(v, str) and v.strip() for v in value):
|
|
173
|
+
self.add(where, f"{key!r} must be a list of non-empty strings")
|
|
174
|
+
return []
|
|
175
|
+
return list(value)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _ident(c: _Collector, data: dict[str, Any], where: str) -> str:
|
|
179
|
+
value = c.text(data, "id", where)
|
|
180
|
+
if value and not _ID.match(value):
|
|
181
|
+
c.add(where, f"'id' must be lowercase letters, digits and hyphens, got {value!r}")
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_backlog(text: str) -> Backlog:
|
|
186
|
+
try:
|
|
187
|
+
raw = json.loads(text)
|
|
188
|
+
except json.JSONDecodeError as exc:
|
|
189
|
+
raise BacklogError([f"backlog.json is not valid JSON: {exc}"]) from exc
|
|
190
|
+
|
|
191
|
+
c = _Collector()
|
|
192
|
+
root = c.obj(raw, "backlog", {"version", "phases", "labels", "issues"})
|
|
193
|
+
if root is None:
|
|
194
|
+
raise BacklogError(c.problems)
|
|
195
|
+
if root.get("version") != BACKLOG_VERSION:
|
|
196
|
+
c.add(
|
|
197
|
+
"backlog", f"unsupported version {root.get('version')!r} (expected {BACKLOG_VERSION})"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
backlog = Backlog()
|
|
201
|
+
|
|
202
|
+
for n, item in enumerate(root.get("phases") or [], 1):
|
|
203
|
+
where = f"phases[{n}]"
|
|
204
|
+
if (d := c.obj(item, where, {"id", "title", "description"})) is not None:
|
|
205
|
+
backlog.phases.append(
|
|
206
|
+
Phase(
|
|
207
|
+
_ident(c, d, where),
|
|
208
|
+
c.text(d, "title", where),
|
|
209
|
+
c.text(d, "description", where, required=False),
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
for n, item in enumerate(root.get("labels") or [], 1):
|
|
214
|
+
where = f"labels[{n}]"
|
|
215
|
+
if (d := c.obj(item, where, {"name", "color", "description"})) is not None:
|
|
216
|
+
color = c.text(d, "color", where, required=False) or _DEFAULT_COLOR
|
|
217
|
+
if not _COLOR.match(color):
|
|
218
|
+
c.add(where, f"'color' must be 6 hex digits without '#', got {color!r}")
|
|
219
|
+
backlog.labels.append(
|
|
220
|
+
Label(
|
|
221
|
+
c.text(d, "name", where),
|
|
222
|
+
color,
|
|
223
|
+
c.text(d, "description", where, required=False),
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
allowed = {"id", "type", "phase", "title", "priority", "size", "parent", "body",
|
|
228
|
+
"acceptance", "labels"} # fmt: skip
|
|
229
|
+
for n, item in enumerate(root.get("issues") or [], 1):
|
|
230
|
+
where = f"issues[{n}]"
|
|
231
|
+
if (d := c.obj(item, where, allowed)) is None:
|
|
232
|
+
continue
|
|
233
|
+
ident = _ident(c, d, where)
|
|
234
|
+
where = f"issue {ident!r}" if ident else where
|
|
235
|
+
parent = c.text(d, "parent", where, required=False) or None
|
|
236
|
+
backlog.issues.append(
|
|
237
|
+
Issue(
|
|
238
|
+
id=ident,
|
|
239
|
+
type=c.choice(d, "type", where, ISSUE_TYPES),
|
|
240
|
+
phase=c.text(d, "phase", where),
|
|
241
|
+
title=c.text(d, "title", where),
|
|
242
|
+
priority=c.choice(d, "priority", where, PRIORITIES),
|
|
243
|
+
size=c.choice(d, "size", where, SIZES),
|
|
244
|
+
parent=parent,
|
|
245
|
+
body=c.text(d, "body", where, required=False),
|
|
246
|
+
acceptance=c.strings(d, "acceptance", where),
|
|
247
|
+
labels=c.strings(d, "labels", where),
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
_check_references(backlog, c)
|
|
252
|
+
if c.problems:
|
|
253
|
+
raise BacklogError(c.problems)
|
|
254
|
+
return backlog
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _duplicates(values: list[str]) -> list[str]:
|
|
258
|
+
return sorted(v for v, n in Counter(values).items() if v and n > 1)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _check_references(backlog: Backlog, c: _Collector) -> None:
|
|
262
|
+
for dup in _duplicates([p.id for p in backlog.phases]):
|
|
263
|
+
c.add("phases", f"duplicate id {dup!r}")
|
|
264
|
+
for dup in _duplicates([p.title for p in backlog.phases]):
|
|
265
|
+
c.add("phases", f"duplicate title {dup!r}")
|
|
266
|
+
for dup in _duplicates([label.name for label in backlog.labels]):
|
|
267
|
+
c.add("labels", f"duplicate name {dup!r}")
|
|
268
|
+
for dup in _duplicates([i.id for i in backlog.issues]):
|
|
269
|
+
c.add("issues", f"duplicate id {dup!r}")
|
|
270
|
+
|
|
271
|
+
phase_ids = {p.id for p in backlog.phases}
|
|
272
|
+
declared = {label.name for label in backlog.labels}
|
|
273
|
+
by_id = {i.id: i for i in backlog.issues}
|
|
274
|
+
|
|
275
|
+
for i in backlog.issues:
|
|
276
|
+
where = f"issue {i.id!r}"
|
|
277
|
+
if i.phase and i.phase not in phase_ids:
|
|
278
|
+
c.add(where, f"unknown phase {i.phase!r}")
|
|
279
|
+
if i.type in NEEDS_ACCEPTANCE and not i.acceptance:
|
|
280
|
+
c.add(where, f"a {i.type} needs at least one acceptance criterion")
|
|
281
|
+
for name in i.labels:
|
|
282
|
+
if name not in declared:
|
|
283
|
+
c.add(where, f"label {name!r} is not declared in 'labels'")
|
|
284
|
+
if i.parent is None:
|
|
285
|
+
continue
|
|
286
|
+
if i.type == "epic":
|
|
287
|
+
c.add(where, "an epic cannot have a parent")
|
|
288
|
+
parent = by_id.get(i.parent)
|
|
289
|
+
if parent is None:
|
|
290
|
+
c.add(where, f"unknown parent {i.parent!r}")
|
|
291
|
+
elif parent.type != "epic":
|
|
292
|
+
c.add(where, f"parent {i.parent!r} is a {parent.type}, not an epic")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def validate_backlog(backlog: Backlog) -> None:
|
|
296
|
+
"""Re-validate a backlog built in code (``parse_backlog`` validates on load)."""
|
|
297
|
+
parse_backlog(json.dumps(backlog.to_dict()))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def load_backlog(root: Path) -> Backlog:
|
|
301
|
+
path = backlog_path(root)
|
|
302
|
+
try:
|
|
303
|
+
text = path.read_text(encoding="utf-8")
|
|
304
|
+
except FileNotFoundError as exc:
|
|
305
|
+
raise BacklogError([f"No backlog found at {path}. Run the pk-plan skill first."]) from exc
|
|
306
|
+
return parse_backlog(text)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def save_backlog(root: Path, backlog: Backlog) -> None:
|
|
310
|
+
validate_backlog(backlog)
|
|
311
|
+
text = json.dumps(backlog.to_dict(), indent=2, ensure_ascii=False) + "\n"
|
|
312
|
+
atomic_write_text(backlog_path(root), text)
|
project_kit/cli.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from project_kit import (
|
|
9
|
+
__version__,
|
|
10
|
+
agents,
|
|
11
|
+
approval,
|
|
12
|
+
backlog,
|
|
13
|
+
doctor,
|
|
14
|
+
handoff,
|
|
15
|
+
installer,
|
|
16
|
+
provision,
|
|
17
|
+
)
|
|
18
|
+
from project_kit import (
|
|
19
|
+
help as help_guide,
|
|
20
|
+
)
|
|
21
|
+
from project_kit.config import (
|
|
22
|
+
PROVIDERS,
|
|
23
|
+
Config,
|
|
24
|
+
ConfigError,
|
|
25
|
+
config_path,
|
|
26
|
+
load_config,
|
|
27
|
+
save_config,
|
|
28
|
+
)
|
|
29
|
+
from project_kit.gh import Gh, GhError
|
|
30
|
+
from project_kit.providers.github import GitHubProvider
|
|
31
|
+
from project_kit.state import PHASES, StateError, load_state
|
|
32
|
+
from project_kit.templating import TemplateError
|
|
33
|
+
from project_kit.workflow import WorkflowError, default_workflow, load_workflow, render_status
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
prog="project-kit",
|
|
39
|
+
description="Define, plan and set up a project before handing off to Spec Kit.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
42
|
+
sub = parser.add_subparsers(dest="command")
|
|
43
|
+
|
|
44
|
+
sub.add_parser("doctor", help="check that the required tools are available")
|
|
45
|
+
|
|
46
|
+
init = sub.add_parser(
|
|
47
|
+
"init",
|
|
48
|
+
help="install project-kit into the current directory (never overwrites existing files)",
|
|
49
|
+
)
|
|
50
|
+
init.add_argument(
|
|
51
|
+
"--here", action="store_true", help="accepted for compatibility; init always installs here"
|
|
52
|
+
)
|
|
53
|
+
init.add_argument("--project-name", help="project name (default: the directory name)")
|
|
54
|
+
init.add_argument("--provider", choices=PROVIDERS, default="github")
|
|
55
|
+
init.add_argument("--language", default="en", help="language of the generated documents")
|
|
56
|
+
init.add_argument(
|
|
57
|
+
"--ai-coauthor",
|
|
58
|
+
choices=("keep", "remove"),
|
|
59
|
+
help="whether AI assistants appear as co-authors of commits and PRs "
|
|
60
|
+
"(asked when omitted and a terminal is attached)",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
init.add_argument(
|
|
64
|
+
"--agent",
|
|
65
|
+
choices=(*agents.AGENT_IDS, "none"),
|
|
66
|
+
help="AI agent to open when init finishes, or none (asked when omitted and a terminal "
|
|
67
|
+
"is attached)",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
sub.add_parser("status", help="show the phases of the project and what comes next")
|
|
71
|
+
sub.add_parser("help", help="show a visual guide to the flow and the commands")
|
|
72
|
+
|
|
73
|
+
bl = sub.add_parser("backlog", help="work with .project-kit/backlog.json")
|
|
74
|
+
bl_sub = bl.add_subparsers(dest="backlog_command", required=True)
|
|
75
|
+
bl_sub.add_parser("validate", help="validate the backlog and print a summary")
|
|
76
|
+
|
|
77
|
+
pv = sub.add_parser(
|
|
78
|
+
"provision",
|
|
79
|
+
help="create milestones, labels and branches on GitHub from the backlog",
|
|
80
|
+
)
|
|
81
|
+
pv.add_argument("--repo", help="owner/name (default: the repository of the current directory)")
|
|
82
|
+
pv.add_argument(
|
|
83
|
+
"--project",
|
|
84
|
+
help="GitHub Project to use: its number or exact title, or a new title to create "
|
|
85
|
+
"(default: detect the ones linked to the repository)",
|
|
86
|
+
)
|
|
87
|
+
pv.add_argument("--dry-run", action="store_true", help="show the plan and change nothing")
|
|
88
|
+
pv.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
89
|
+
|
|
90
|
+
ho = sub.add_parser(
|
|
91
|
+
"handoff", help="generate the constitution input and hand the project over to Spec Kit"
|
|
92
|
+
)
|
|
93
|
+
ho.add_argument(
|
|
94
|
+
"--install-speckit",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="install the Spec Kit CLI and initialise this repository, after confirmation",
|
|
97
|
+
)
|
|
98
|
+
ho.add_argument("--dry-run", action="store_true", help="show what would happen, change nothing")
|
|
99
|
+
ho.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
100
|
+
|
|
101
|
+
ap = sub.add_parser("approve", help="record the user's approval of a phase")
|
|
102
|
+
ap.add_argument("phase", choices=PHASES)
|
|
103
|
+
return parser
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _print_init_report(report: installer.Report) -> None:
|
|
107
|
+
for label, paths in (
|
|
108
|
+
("created", report.created),
|
|
109
|
+
("kept (differs from the shipped version)", report.kept),
|
|
110
|
+
("already up to date", report.unchanged),
|
|
111
|
+
):
|
|
112
|
+
if paths:
|
|
113
|
+
print(f"{label}:")
|
|
114
|
+
for path in paths:
|
|
115
|
+
print(f" {path.as_posix()}")
|
|
116
|
+
for note in report.notes:
|
|
117
|
+
print(f"\nNote: {note}")
|
|
118
|
+
if not report.created:
|
|
119
|
+
print("\nNothing to do: project-kit is already installed here.")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
NEXT_AFTER_INIT = """
|
|
123
|
+
Next: open Claude Code in this directory (restart it if it was already open, so it loads
|
|
124
|
+
the new skills) and run /pk-start. It asks whether to stop here or to start defining the
|
|
125
|
+
project. `project-kit status` shows where you are at any time."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _status() -> int:
|
|
129
|
+
root = Path.cwd()
|
|
130
|
+
try:
|
|
131
|
+
config = load_config(root)
|
|
132
|
+
print(render_status(load_workflow(root), load_state(root), config.project_name))
|
|
133
|
+
except (ConfigError, StateError, WorkflowError) as exc:
|
|
134
|
+
print(f"project-kit status: {exc}", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _offer_agent(root: Path, requested: str | None) -> bool:
|
|
140
|
+
"""List the supported AI agents and open the chosen one with /pk-start.
|
|
141
|
+
|
|
142
|
+
Returns True when an agent was started, so the caller does not repeat the instructions.
|
|
143
|
+
"""
|
|
144
|
+
if requested == "none":
|
|
145
|
+
return False
|
|
146
|
+
if requested is None and not sys.stdin.isatty():
|
|
147
|
+
return False # nobody to ask, and nothing must start by surprise
|
|
148
|
+
for known in agents.AGENTS:
|
|
149
|
+
if agents.is_inside(known):
|
|
150
|
+
print(f"\nYou are already inside {known.name}: run {known.prompt} here.")
|
|
151
|
+
return True
|
|
152
|
+
agent = agents.get_agent(requested) if requested else agents.choose()
|
|
153
|
+
if agent is None:
|
|
154
|
+
return False
|
|
155
|
+
if agents.find_executable(agent) is None:
|
|
156
|
+
print(f"\n{agent.name} was not found on your PATH. Install it from {agent.install_hint}.")
|
|
157
|
+
return False
|
|
158
|
+
try:
|
|
159
|
+
config = load_config(root)
|
|
160
|
+
if config.agent != agent.id:
|
|
161
|
+
config.agent = agent.id
|
|
162
|
+
save_config(root, config)
|
|
163
|
+
except ConfigError as exc:
|
|
164
|
+
print(f"project-kit init: {exc}", file=sys.stderr)
|
|
165
|
+
return False
|
|
166
|
+
print(f"\nStarting {agent.name} with {agent.prompt}...\n")
|
|
167
|
+
agents.launch(agent, root)
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _help() -> int:
|
|
172
|
+
root = Path.cwd()
|
|
173
|
+
workflow, state, name = default_workflow(), None, None
|
|
174
|
+
try:
|
|
175
|
+
name = load_config(root).project_name
|
|
176
|
+
workflow, state = load_workflow(root), load_state(root)
|
|
177
|
+
except (ConfigError, StateError, WorkflowError):
|
|
178
|
+
workflow, state, name = default_workflow(), None, None # not a project here: generic guide
|
|
179
|
+
print(help_guide.render_help(workflow, state, name, help_guide.Style.detect()))
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _init(args: argparse.Namespace) -> int:
|
|
184
|
+
root = Path.cwd()
|
|
185
|
+
choice = args.ai_coauthor
|
|
186
|
+
if choice is None and sys.stdin.isatty() and not config_path(root).exists():
|
|
187
|
+
remove = _ask("Remove AI assistants (Claude, Copilot...) as co-authors of commits and PRs?")
|
|
188
|
+
choice = "remove" if remove else "keep"
|
|
189
|
+
config = Config(
|
|
190
|
+
project_name=args.project_name or root.name,
|
|
191
|
+
provider=args.provider,
|
|
192
|
+
language=args.language,
|
|
193
|
+
ai_coauthor=choice != "remove",
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
config.validate()
|
|
197
|
+
report = installer.init_project(root, config)
|
|
198
|
+
except (ConfigError, StateError) as exc:
|
|
199
|
+
print(f"project-kit init: {exc}", file=sys.stderr)
|
|
200
|
+
return 1
|
|
201
|
+
_print_init_report(report)
|
|
202
|
+
if not _offer_agent(root, args.agent):
|
|
203
|
+
print(NEXT_AFTER_INIT)
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _backlog_validate() -> int:
|
|
208
|
+
try:
|
|
209
|
+
loaded = backlog.load_backlog(Path.cwd())
|
|
210
|
+
except backlog.BacklogError as exc:
|
|
211
|
+
print(exc, file=sys.stderr)
|
|
212
|
+
return 1
|
|
213
|
+
print("Backlog is valid.\n" + loaded.summary())
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _approve(phase: str) -> int:
|
|
218
|
+
try:
|
|
219
|
+
approval.approve_phase(Path.cwd(), phase)
|
|
220
|
+
except (ConfigError, StateError, backlog.BacklogError, WorkflowError) as exc:
|
|
221
|
+
print(f"project-kit approve: {exc}", file=sys.stderr)
|
|
222
|
+
return 1
|
|
223
|
+
print(f"Phase '{phase}' approved.")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _ask(question: str) -> bool:
|
|
228
|
+
try:
|
|
229
|
+
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
|
|
230
|
+
except EOFError: # no terminal attached: never assume consent
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _provision(args: argparse.Namespace) -> int:
|
|
235
|
+
try:
|
|
236
|
+
return provision.run_provision(
|
|
237
|
+
Path.cwd(),
|
|
238
|
+
GitHubProvider(Gh(), args.repo),
|
|
239
|
+
dry_run=args.dry_run,
|
|
240
|
+
assume_yes=args.yes,
|
|
241
|
+
confirm=_ask,
|
|
242
|
+
project=args.project,
|
|
243
|
+
interactive=sys.stdin.isatty(),
|
|
244
|
+
)
|
|
245
|
+
except (ConfigError, StateError, backlog.BacklogError, GhError) as exc:
|
|
246
|
+
print(f"project-kit provision: {exc}", file=sys.stderr)
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _handoff(args: argparse.Namespace) -> int:
|
|
251
|
+
try:
|
|
252
|
+
return handoff.run_handoff(
|
|
253
|
+
Path.cwd(),
|
|
254
|
+
install=args.install_speckit,
|
|
255
|
+
assume_yes=args.yes,
|
|
256
|
+
dry_run=args.dry_run,
|
|
257
|
+
confirm=_ask,
|
|
258
|
+
)
|
|
259
|
+
except (ConfigError, StateError, TemplateError) as exc:
|
|
260
|
+
print(f"project-kit handoff: {exc}", file=sys.stderr)
|
|
261
|
+
return 1
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
265
|
+
parser = build_parser()
|
|
266
|
+
args = parser.parse_args(argv)
|
|
267
|
+
if args.command == "doctor":
|
|
268
|
+
checks = doctor.run_checks()
|
|
269
|
+
print(doctor.render(checks))
|
|
270
|
+
return doctor.exit_code(checks)
|
|
271
|
+
if args.command == "init":
|
|
272
|
+
return _init(args)
|
|
273
|
+
if args.command == "status":
|
|
274
|
+
return _status()
|
|
275
|
+
if args.command in ("help", None):
|
|
276
|
+
return _help()
|
|
277
|
+
if args.command == "backlog":
|
|
278
|
+
return _backlog_validate()
|
|
279
|
+
if args.command == "provision":
|
|
280
|
+
return _provision(args)
|
|
281
|
+
if args.command == "handoff":
|
|
282
|
+
return _handoff(args)
|
|
283
|
+
if args.command == "approve":
|
|
284
|
+
return _approve(args.phase)
|
|
285
|
+
parser.print_help()
|
|
286
|
+
return 0
|