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.
Files changed (49) hide show
  1. project_kit/__init__.py +8 -0
  2. project_kit/__main__.py +5 -0
  3. project_kit/agents.py +81 -0
  4. project_kit/ai_attribution.py +69 -0
  5. project_kit/approval.py +36 -0
  6. project_kit/assets/hooks/commit-msg +14 -0
  7. project_kit/assets/project/workflow.yml +63 -0
  8. project_kit/assets/skills/pk-decide/SKILL.md +63 -0
  9. project_kit/assets/skills/pk-discover/SKILL.md +57 -0
  10. project_kit/assets/skills/pk-handoff/SKILL.md +43 -0
  11. project_kit/assets/skills/pk-plan/SKILL.md +55 -0
  12. project_kit/assets/skills/pk-provision/SKILL.md +45 -0
  13. project_kit/assets/skills/pk-start/SKILL.md +47 -0
  14. project_kit/assets/skills/pk-workflow/SKILL.md +51 -0
  15. project_kit/assets/templates/adr.md +32 -0
  16. project_kit/assets/templates/backlog.example.json +59 -0
  17. project_kit/assets/templates/brief.md +77 -0
  18. project_kit/assets/templates/constitution-input.md +78 -0
  19. project_kit/assets/templates/development-workflow.md +51 -0
  20. project_kit/assets/templates/github/ISSUE_TEMPLATE/adr.yml +21 -0
  21. project_kit/assets/templates/github/ISSUE_TEMPLATE/bug.yml +25 -0
  22. project_kit/assets/templates/github/ISSUE_TEMPLATE/config.yml +1 -0
  23. project_kit/assets/templates/github/ISSUE_TEMPLATE/epic.yml +15 -0
  24. project_kit/assets/templates/github/ISSUE_TEMPLATE/feature.yml +22 -0
  25. project_kit/assets/templates/github/pull_request_template.md +21 -0
  26. project_kit/assets/templates/interview.md +68 -0
  27. project_kit/backlog.py +312 -0
  28. project_kit/cli.py +286 -0
  29. project_kit/config.py +139 -0
  30. project_kit/doctor.py +187 -0
  31. project_kit/fsutil.py +27 -0
  32. project_kit/gh.py +152 -0
  33. project_kit/handoff.py +360 -0
  34. project_kit/help.py +152 -0
  35. project_kit/installer.py +100 -0
  36. project_kit/labels.py +17 -0
  37. project_kit/project_choice.py +127 -0
  38. project_kit/providers/__init__.py +1 -0
  39. project_kit/providers/github.py +329 -0
  40. project_kit/provision.py +260 -0
  41. project_kit/provision_issues.py +360 -0
  42. project_kit/state.py +115 -0
  43. project_kit/templating.py +23 -0
  44. project_kit/workflow.py +168 -0
  45. project_kit_cli-0.1.0.dist-info/METADATA +97 -0
  46. project_kit_cli-0.1.0.dist-info/RECORD +49 -0
  47. project_kit_cli-0.1.0.dist-info/WHEEL +4 -0
  48. project_kit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  49. 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