craftkit 0.3.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.
craft_kit/cli.py ADDED
@@ -0,0 +1,394 @@
1
+ """craft: study → build → check → refine, from the terminal.
2
+
3
+ craft the home screen: past creations, a new project
4
+ craft new [TARGET] [REQUEST…] start a project (also: craft-new) (asks for what's missing)
5
+ craft run [MESSAGE…] the whole loop; with a message, a change to the build
6
+ craft study | build | check one step at a time
7
+ craft status where the project is
8
+ craft answer answer the builder's questions
9
+ craft brief print the builder's brief (to paste into any agent)
10
+ craft open open the result
11
+ craft skill [--install DIR] print or install the SKILL.md this tool follows
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import signal
18
+ import sys
19
+ import webbrowser
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from . import __version__, agent, brief, browser, checks, study, tables
24
+ from .ui import UI
25
+ from .workspace import STEPS, CraftError, Project, parse_target, read_json, slug, write_atomic
26
+
27
+ STEP_STATE = {"todo": "todo", "running": "run", "done": "done", "failed": "fail", "waiting": "wait", "paused": "wait"}
28
+
29
+
30
+ # --- views ----------------------------------------------------------------------------------------
31
+
32
+ def show_status(p: Project, ui: UI) -> None:
33
+ s = p.state
34
+ ui.header(s["name"], f"{s['kind']} · {s['stack']} · {p.mode()}")
35
+ ui.say(f" {ui.dim('“' + s['request'] + '”')}")
36
+ last = read_json(p.round_dir(s.get("round", 0)) / "result.json") if s.get("round") else None
37
+ details = {
38
+ "study": s["target"]["value"] or "no target",
39
+ "build": f"{s.get('agent') or 'agent'} · refinements {s.get('rounds_used', 0)}/{s.get('max_rounds')}",
40
+ "check": (f"round {last['round']}: " + ", ".join(f"{c['id']} {'✓' if c['passed'] else ('–' if c['passed'] is None else '✗')}" + (f" {c['score']:.0f}%" if c.get("score") is not None else "") for c in last["checks"])) if isinstance(last, dict) else "",
41
+ "ready": "",
42
+ }
43
+ for step in STEPS:
44
+ ui.step(STEP_STATE.get(s["steps"].get(step, "todo"), "todo"), step, details[step])
45
+ extra = []
46
+ if p.pending_questions():
47
+ extra.append(f"{len(p.pending_questions())} question(s) waiting: craft answer")
48
+ if p.open_unknowns():
49
+ extra.append(f"{p.open_unknowns()} open unknown(s) in unknowns.md")
50
+ for e in extra:
51
+ ui.say(f" {ui.yellow('›')} {e}")
52
+
53
+
54
+ def ask_questions(p: Project, ui: UI) -> Optional[list[dict]]:
55
+ """Ask pending questions in the terminal. None when they can't be asked here (not a terminal)."""
56
+ pending = p.pending_questions()
57
+ if not pending:
58
+ return []
59
+ if not ui.interactive():
60
+ return None
61
+ answered = []
62
+ for q in pending:
63
+ choices = [c for c in q.get("choices", []) if isinstance(c, str)][:6]
64
+ a = ui.choose(q["question"], choices) if choices else ui.ask(q["question"])
65
+ if a:
66
+ answered.append({**q, "answer": a})
67
+ p.save_answers(answered)
68
+ p.log("answers: " + "; ".join(f"{q['question']} → {q['answer']}" for q in answered))
69
+ return answered
70
+
71
+
72
+ # --- steps ----------------------------------------------------------------------------------------
73
+
74
+ def do_study(p: Project, ui: UI) -> None:
75
+ p.set_step("study", "running")
76
+ ui.stage("study", p.state["target"]["value"] or "no target: the request alone")
77
+ study.study(p, ui)
78
+ p.set_step("study", "done")
79
+ if p.state["target"]["type"] != "website":
80
+ ui.step("done", "study", "capture/README.md written")
81
+
82
+
83
+ def write_brief(p: Project) -> str:
84
+ text = brief.first(p)
85
+ write_atomic(p.root / "BRIEF.md", text + "\n")
86
+ return text
87
+
88
+
89
+ def do_check(p: Project, ui: UI, self_check: bool = False) -> dict:
90
+ n = 0 if self_check else p.state.get("round", 0) + 1
91
+ if not self_check:
92
+ p.state["round"] = n
93
+ p.set_step("check", "running")
94
+ ui.stage("check", f"round {n}")
95
+ res = checks.run_checks(p, n, ui)
96
+ checks.show(res, ui)
97
+ if not self_check:
98
+ p.set_step("check", "done" if res["good"] else "failed")
99
+ return res
100
+
101
+
102
+ def finish(p: Project, ui: UI, res: Optional[dict], reply: str) -> None:
103
+ ok = bool(res and res["good"])
104
+ p.set_step("ready", "done" if ok else "failed")
105
+ ui.stage("ready")
106
+ ui.step("done" if ok else "fail", "ready", "every check passes" if ok else "not yet: stopped before every check passed")
107
+ if reply.strip():
108
+ ui.say("")
109
+ for line in reply.strip().splitlines()[-12:]:
110
+ ui.say(f" {line}")
111
+ ui.say("")
112
+ ui.say(f" {ui.dim('open it:')} craft open {ui.dim('change it:')} craft run \"…\"")
113
+
114
+
115
+ def loop(p: Project, ui: UI, message: Optional[str], agent_name: Optional[str]) -> int:
116
+ s = p.state
117
+ if message: # a new request resets the refinement budget; retries never do
118
+ s["rounds_used"] = 0
119
+ s["unchanged"] = 0
120
+ p.save()
121
+ if s["steps"].get("study") != "done":
122
+ do_study(p, ui)
123
+ else:
124
+ ui.step("done", "study", "evidence in capture/")
125
+ if ask_questions(p, ui) is None:
126
+ p.set_step("build", "waiting")
127
+ ui.step("wait", "build", "the builder asked something: craft answer")
128
+ return 3
129
+ answered = p.undelivered_answers()
130
+ ans = brief.answers_text(answered)
131
+ if message:
132
+ text, label = brief.follow_up(p, message, ans), "changing the build"
133
+ elif s["steps"].get("build") == "todo":
134
+ text, label = write_brief(p) + ans, "building"
135
+ else:
136
+ text, label = brief.resume(p, ans), "carrying on"
137
+ reply, res = "", None
138
+ while True:
139
+ before = p.fingerprint()
140
+ p.set_step("build", "running")
141
+ ui.stage("refine" if label.startswith("refin") else "build", f"{label} · turn {s.get('turns', 0) + 1}")
142
+ try:
143
+ reply = agent.turn(p, text, ui, agent_name, label)
144
+ p.mark_delivered(answered)
145
+ except KeyboardInterrupt:
146
+ p.set_step("build", "paused")
147
+ ui.step("wait", "build", "paused: craft run carries on")
148
+ return 130
149
+ changed = p.fingerprint() != before
150
+ s["unchanged"] = 0 if changed else s.get("unchanged", 0) + 1
151
+ p.set_step("build", "done")
152
+ ui.step("done", "build", reply.strip().splitlines()[-1][:80] if reply.strip() else "turn finished")
153
+ if ask_questions(p, ui) is None:
154
+ p.set_step("build", "waiting")
155
+ ui.step("wait", "build", "the builder asked something: craft answer, then craft run")
156
+ return 3
157
+ answered = p.undelivered_answers()
158
+ res = do_check(p, ui)
159
+ if res["good"]:
160
+ break
161
+ if s["unchanged"] >= tables.UNCHANGED_LIMIT:
162
+ ui.error(f"The builder changed nothing {s['unchanged']} turns in a row; stopping. Add detail with craft run \"…\".")
163
+ break
164
+ if s["rounds_used"] >= s["max_rounds"]:
165
+ ui.error(f"{s['max_rounds']} refinements used for this request. Ask for a change (craft run \"…\") to start a new budget.")
166
+ break
167
+ s["rounds_used"] += 1
168
+ p.save()
169
+ text = brief.refine(p, checks.refine_text(res), brief.answers_text(answered))
170
+ label = f"refining {s['rounds_used']}/{s['max_rounds']}"
171
+ finish(p, ui, res, reply)
172
+ return 0 if res and res["good"] else 1
173
+
174
+
175
+ # --- commands -------------------------------------------------------------------------------------
176
+
177
+ def cmd_new(a, ui: UI) -> int:
178
+ target_raw = a.target
179
+ request = " ".join(a.request).strip()
180
+ if target_raw and not request and not target_raw.startswith(("http", "/", ".", "~")) and " " in target_raw:
181
+ target_raw, request = "", target_raw # `craft new "a todo app in rust"`
182
+ if ui.interactive():
183
+ if not request:
184
+ request = ui.ask("What do you want to make?")
185
+ if target_raw is None:
186
+ target_raw = ui.ask("Something to recreate? (web address, path, command — or Enter for none)")
187
+ if not request:
188
+ raise CraftError("A request is needed.", 'craft new [TARGET] "what you want to make"')
189
+ target = parse_target(target_raw or "")
190
+ kind = a.kind or tables.detect_kind(request, target["type"])
191
+ stack = tables.stack_by_name(a.stack)["name"] if a.stack else tables.detect_stack(request)["name"]
192
+ folder = Path(a.dir) if a.dir else Path.cwd() / slug(request)
193
+ p = Project.create(folder.resolve(), target, request, kind, stack, a.deep, a.allow_run)
194
+ ui.header(p.state["name"], f"{kind} · {stack} · {p.mode()}")
195
+ ui.step("done", "new", str(folder))
196
+ ui.say(f" {ui.dim('add pictures to')} {folder / 'reference'}{ui.dim(', then:')} cd {folder.name} && craft run")
197
+ return 0
198
+
199
+
200
+ def cmd_run(a, ui: UI) -> int:
201
+ p = Project.open(Path(a.C))
202
+ show_header(p, ui)
203
+ return loop(p, ui, " ".join(a.message).strip() or None, a.agent)
204
+
205
+
206
+ def show_header(p: Project, ui: UI) -> None:
207
+ ui.header(p.state["name"], f"{p.state['kind']} · {p.state['stack']} · {p.mode()}")
208
+
209
+
210
+ def cmd_study(a, ui: UI) -> int:
211
+ p = Project.open(Path(a.C))
212
+ show_header(p, ui)
213
+ do_study(p, ui)
214
+ return 0
215
+
216
+
217
+ def cmd_build(a, ui: UI) -> int:
218
+ p = Project.open(Path(a.C))
219
+ show_header(p, ui)
220
+ msg = " ".join(a.message).strip()
221
+ first = p.state["steps"].get("build") == "todo"
222
+ text = brief.follow_up(p, msg) if msg else (write_brief(p) if first else brief.resume(p))
223
+ p.set_step("build", "running")
224
+ ui.stage("build", f"turn {p.state.get('turns', 0) + 1}")
225
+ reply = agent.turn(p, text, ui, a.agent, "building")
226
+ p.set_step("build", "done")
227
+ ui.step("done", "build", "turn finished")
228
+ for line in reply.strip().splitlines()[-8:]:
229
+ ui.say(f" {line}")
230
+ return 0
231
+
232
+
233
+ def cmd_check(a, ui: UI) -> int:
234
+ p = Project.open(Path(a.C))
235
+ if not a.self:
236
+ show_header(p, ui)
237
+ res = do_check(p, ui, self_check=a.self)
238
+ return 0 if res["good"] else 1
239
+
240
+
241
+ def cmd_status(a, ui: UI) -> int:
242
+ show_status(Project.open(Path(a.C)), ui)
243
+ return 0
244
+
245
+
246
+ def cmd_answer(a, ui: UI) -> int:
247
+ p = Project.open(Path(a.C))
248
+ if not p.pending_questions():
249
+ ui.step("done", "answer", "no questions waiting")
250
+ return 0
251
+ if ask_questions(p, ui) is None:
252
+ raise CraftError("Questions need a terminal to answer.", "Edit questions.json and add an \"answer\" to each question.")
253
+ ui.step("done", "answer", "saved; craft run carries on")
254
+ return 0
255
+
256
+
257
+ def cmd_brief(a, ui: UI) -> int:
258
+ p = Project.open(Path(a.C))
259
+ print(write_brief(p))
260
+ return 0
261
+
262
+
263
+ def cmd_open(a, ui: UI) -> int:
264
+ return open_result(Project.open(Path(a.C)), ui)
265
+
266
+
267
+ def open_result(p: Project, ui: UI) -> int:
268
+ site = p.site_dir()
269
+ if site is not None:
270
+ with browser.serve(site) as url:
271
+ ui.step("run", "open", f"{url} {ui.dim('(Ctrl-C to stop)')}")
272
+ webbrowser.open(url)
273
+ try:
274
+ while True:
275
+ input()
276
+ except (KeyboardInterrupt, EOFError):
277
+ return 0
278
+ run = p.app_spec().get("run")
279
+ if not run:
280
+ raise CraftError("Nothing to open yet.", 'The build declares how to run it in app/craft-app.json ("run" or "site").')
281
+ ui.step("run", "open", " ".join(run))
282
+ import subprocess # the person's own interactive run: their terminal, their input
283
+
284
+ try:
285
+ return subprocess.call(run, cwd=str(p.app))
286
+ except (OSError, KeyboardInterrupt) as e:
287
+ raise CraftError(f"It couldn't start: {e}", "Check the run command in app/craft-app.json.")
288
+
289
+
290
+ def cmd_home(a, ui: UI) -> int:
291
+ if not ui.interactive():
292
+ raise CraftError("The home screen needs a terminal.", "Use craft new / craft run / craft status instead.")
293
+ from .home import Home
294
+
295
+ return Home(ui, loop, show_status).run()
296
+
297
+
298
+ def cmd_skill(a, ui: UI) -> int:
299
+ text = (Path(__file__).parent / "SKILL.md").read_text("utf-8")
300
+ if not a.install:
301
+ print(text)
302
+ return 0
303
+ dest = Path(a.install).expanduser() / "craft" / "SKILL.md"
304
+ write_atomic(dest, text)
305
+ ui.step("done", "skill", str(dest))
306
+ return 0
307
+
308
+
309
+ def parser() -> argparse.ArgumentParser:
310
+ ap = argparse.ArgumentParser(prog="craft", description="Study → build → check → refine any software, from the terminal.")
311
+ ap.add_argument("-C", default=".", metavar="DIR", help="project folder (default: here)")
312
+ ap.add_argument("--version", action="version", version=f"craft {__version__}")
313
+ sub = ap.add_subparsers(dest="cmd")
314
+
315
+ n = sub.add_parser("new", help="start a project")
316
+ n.add_argument("target", nargs="?", help="web address, file, folder or command to recreate")
317
+ n.add_argument("request", nargs="*", help="what you want to make")
318
+ n.add_argument("--kind", choices=list(tables.KINDS))
319
+ n.add_argument("--stack", help="e.g. rust, python, react, html")
320
+ n.add_argument("--deep", action="store_true", help="allow looking inside the original (private use)")
321
+ n.add_argument("--allow-run", action="store_true", help="allow running the original program to record what it prints")
322
+ n.add_argument("--dir", help="project folder (default: a new folder named after the request)")
323
+ n.set_defaults(fn=cmd_new)
324
+
325
+ for name, fn, hlp in (("run", cmd_run, "the whole loop (or a change, with a message)"), ("build", cmd_build, "one builder turn")):
326
+ r = sub.add_parser(name, help=hlp)
327
+ r.add_argument("message", nargs="*")
328
+ r.add_argument("--agent", help=f"coding agent: {', '.join(tables.AGENT_ORDER)} (default: first installed)")
329
+ r.set_defaults(fn=fn)
330
+
331
+ c = sub.add_parser("check", help="run the checks")
332
+ c.add_argument("--self", action="store_true", help="the builder's own check (round 0, not counted)")
333
+ c.set_defaults(fn=cmd_check)
334
+ for name, fn, hlp in (("study", cmd_study, "gather evidence into capture/"), ("status", cmd_status, "where the project is"),
335
+ ("answer", cmd_answer, "answer the builder's questions"), ("brief", cmd_brief, "print the builder's brief"),
336
+ ("open", cmd_open, "open the result")):
337
+ sub.add_parser(name, help=hlp).set_defaults(fn=fn)
338
+ sub.add_parser("home", help="the home screen: past creations and a new project").set_defaults(fn=cmd_home)
339
+ s = sub.add_parser("skill", help="print or install SKILL.md")
340
+ s.add_argument("--install", metavar="DIR", help="skills folder, e.g. ~/.claude/skills")
341
+ s.set_defaults(fn=cmd_skill)
342
+ return ap
343
+
344
+
345
+ def _stop_on_signal(signum, frame) -> None:
346
+ # A closed terminal (SIGHUP) or a polite kill (SIGTERM) becomes the same stop as Ctrl-C, so the
347
+ # agent and anything else started (each in its own process group) is killed, never orphaned.
348
+ raise KeyboardInterrupt
349
+
350
+
351
+ def main(argv: Optional[list] = None) -> int:
352
+ for name in ("SIGTERM", "SIGHUP"):
353
+ if hasattr(signal, name):
354
+ try:
355
+ signal.signal(getattr(signal, name), _stop_on_signal)
356
+ except (ValueError, OSError): # not the main thread, or not allowed here
357
+ pass
358
+ ui = UI()
359
+ ap = parser()
360
+ a = ap.parse_args(argv)
361
+ if not getattr(a, "fn", None) and ui.interactive():
362
+ a.fn = cmd_home
363
+ if not getattr(a, "fn", None):
364
+ try:
365
+ show_status(Project.open(Path(a.C)), ui)
366
+ return 0
367
+ except CraftError:
368
+ ap.print_help()
369
+ return 0
370
+ try:
371
+ return a.fn(a, ui)
372
+ except CraftError as e:
373
+ ui.error(e.message)
374
+ if e.fix:
375
+ ui.say(f" {ui.dim(e.fix)}")
376
+ return 2
377
+ except KeyboardInterrupt:
378
+ ui.say("")
379
+ ui.say(f" {ui.dim('stopped')}")
380
+ return 130
381
+
382
+
383
+ def main_new() -> int:
384
+ """`craft-new …` is `craft new …`."""
385
+ return main(["new", *sys.argv[1:]])
386
+
387
+
388
+ def main_run() -> int:
389
+ """`craft-run …` is `craft run …`."""
390
+ return main(["run", *sys.argv[1:]])
391
+
392
+
393
+ if __name__ == "__main__":
394
+ sys.exit(main())