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/SKILL.md +145 -0
- craft_kit/__init__.py +3 -0
- craft_kit/__main__.py +5 -0
- craft_kit/agent.py +165 -0
- craft_kit/banner.py +86 -0
- craft_kit/brief.py +101 -0
- craft_kit/browser.py +198 -0
- craft_kit/checks.py +219 -0
- craft_kit/cli.py +394 -0
- craft_kit/home.py +370 -0
- craft_kit/png.py +135 -0
- craft_kit/proc.py +161 -0
- craft_kit/study.py +170 -0
- craft_kit/tables.py +154 -0
- craft_kit/ui.py +284 -0
- craft_kit/workspace.py +320 -0
- craftkit-0.3.0.dist-info/METADATA +174 -0
- craftkit-0.3.0.dist-info/RECORD +23 -0
- craftkit-0.3.0.dist-info/WHEEL +5 -0
- craftkit-0.3.0.dist-info/entry_points.txt +5 -0
- craftkit-0.3.0.dist-info/licenses/LICENSE-APACHE +177 -0
- craftkit-0.3.0.dist-info/licenses/LICENSE-MIT +21 -0
- craftkit-0.3.0.dist-info/top_level.txt +1 -0
craft_kit/study.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Study: gather evidence about the target into capture/, README.md first.
|
|
2
|
+
|
|
3
|
+
Clean room (default): only what a visitor or user sees: screenshots and visible facts of a website,
|
|
4
|
+
the person's pictures in reference/, and (with --allow-run) what a program prints when run.
|
|
5
|
+
Deep: also look inside: a site's HTML, a folder's files and manifests, a program's strings."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import re
|
|
13
|
+
import urllib.request
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import browser, proc, tables
|
|
18
|
+
from .ui import UI
|
|
19
|
+
from .workspace import IGNORED, Project, write_atomic
|
|
20
|
+
|
|
21
|
+
RUN_INPUTS: list[list[str]] = [[], ["--help"], ["--version"], ["-h"]]
|
|
22
|
+
MANIFESTS = ["package.json", "Cargo.toml", "pyproject.toml", "go.mod", "requirements.txt", "setup.py", "Makefile", "CMakeLists.txt", "README.md", "README", "README.txt"]
|
|
23
|
+
PICTURES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def study(p: Project, ui: UI) -> None:
|
|
27
|
+
t = p.state["target"]
|
|
28
|
+
cap = p.capture
|
|
29
|
+
if cap.exists():
|
|
30
|
+
shutil.rmtree(cap, ignore_errors=True)
|
|
31
|
+
cap.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
notes = [
|
|
33
|
+
"# Evidence",
|
|
34
|
+
"",
|
|
35
|
+
f"Request: “{p.state['request']}”",
|
|
36
|
+
f"Target: {t['value'] or 'none (build from the request)'}",
|
|
37
|
+
f"Mode: {p.mode()}" + ("" if p.deep else " — nothing inside the original was read; don't read it either."),
|
|
38
|
+
"",
|
|
39
|
+
]
|
|
40
|
+
if t["type"] == "website":
|
|
41
|
+
notes.append(_website(p, t["value"], ui))
|
|
42
|
+
elif t["type"] == "local":
|
|
43
|
+
path = Path(t["value"])
|
|
44
|
+
if p.deep:
|
|
45
|
+
notes.append(_inside(path, cap, ui))
|
|
46
|
+
else:
|
|
47
|
+
notes.append("## Clean room\n\nThe program, its files and its folder weren't opened. Build from the request, the pictures in reference/ and what it prints below (if anything). Ask the person about what can't be seen.\n")
|
|
48
|
+
if p.state.get("allow_run") and _runnable(path):
|
|
49
|
+
notes.append(_runs(path, cap, ui))
|
|
50
|
+
elif _runnable(path):
|
|
51
|
+
notes.append("It wasn't run (start the project with --allow-run to record what it prints).\n")
|
|
52
|
+
else:
|
|
53
|
+
notes.append("No target: build from the request alone.\n")
|
|
54
|
+
pics = sorted(x.name for x in p.reference.glob("*") if x.suffix.lower() in PICTURES) if p.reference.exists() else []
|
|
55
|
+
if pics:
|
|
56
|
+
notes.append("## Pictures from the person (reference/)\n\n" + "\n".join(f"- {n}" for n in pics) + "\n\nOpen each one and look closely.\n")
|
|
57
|
+
write_atomic(cap / "README.md", "\n".join(notes))
|
|
58
|
+
p.log(f"study done: {len(list(cap.rglob('*')))} files in capture/")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _website(p: Project, url: str, ui: UI) -> str:
|
|
62
|
+
out = []
|
|
63
|
+
with ui.live(f"photographing {url}", "study") as live:
|
|
64
|
+
shots, facts, problems = browser.capture(url, p.capture, tables.WIDTHS, facts=True, live=live)
|
|
65
|
+
if facts:
|
|
66
|
+
write_atomic(p.capture / "capture.json", json.dumps({"version": 1, "url": url, "widths": tables.WIDTHS, "facts": facts}, indent=1, ensure_ascii=False))
|
|
67
|
+
out.append("## Screenshots\n")
|
|
68
|
+
out += [f"- {s.name}" for s in shots] or ["None could be taken."]
|
|
69
|
+
out.append("\nOpen every screenshot and match it width by width: layout, spacing, sizes, colours, fonts, what changes on a phone.\n")
|
|
70
|
+
out.append(browser.facts_markdown(facts))
|
|
71
|
+
for prob in problems:
|
|
72
|
+
ui.note(prob)
|
|
73
|
+
out.append(f"> {prob}\n")
|
|
74
|
+
if p.deep:
|
|
75
|
+
try:
|
|
76
|
+
req = urllib.request.Request(url, headers={"User-Agent": "craftkit"})
|
|
77
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
78
|
+
html = r.read(2_000_000).decode("utf-8", "replace")
|
|
79
|
+
write_atomic(p.capture / "page.html", html)
|
|
80
|
+
out.append("Deep: the page's HTML as served is in page.html (rebuild what you learn; never paste it into app/).\n")
|
|
81
|
+
except (OSError, ValueError) as e:
|
|
82
|
+
out.append(f"Deep: the page's HTML couldn't be fetched ({e}).\n")
|
|
83
|
+
ui.step("done" if shots else "fail", "study", f"{len(shots)} screenshot(s)" + (", visible facts" if facts else ""))
|
|
84
|
+
return "\n".join(out)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _runnable(path: Path) -> bool:
|
|
88
|
+
return path.is_file() and os.access(path, os.X_OK)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _runs(program: Path, cap: Path, ui: UI) -> str:
|
|
92
|
+
runs = []
|
|
93
|
+
with ui.live(f"running {program.name} with {len(RUN_INPUTS)} inputs", "study") as live:
|
|
94
|
+
for args in RUN_INPUTS:
|
|
95
|
+
live.event("run", f"{program.name} {' '.join(args)}".strip())
|
|
96
|
+
r = proc.run([str(program), *args], cwd=str(cap), timeout=10, cap=16_000)
|
|
97
|
+
runs.append({"args": args, "code": r.code, "output": r.output[-16_000:], "timed_out": r.timed_out, "error": r.error})
|
|
98
|
+
write_atomic(cap / "runs.json", json.dumps({"version": 1, "program": program.name, "runs": runs}, indent=1, ensure_ascii=False))
|
|
99
|
+
lines = ["## What it prints (runs.json)", ""]
|
|
100
|
+
for run in runs:
|
|
101
|
+
first = (run["output"].strip().splitlines() or [run["error"] or "(nothing)"])[0][:100]
|
|
102
|
+
lines.append(f"- `{program.name} {' '.join(run['args'])}`: exit {run['code']} — {first}")
|
|
103
|
+
lines.append("\nThe rebuild must print the same for each input and exit the same way; the check reruns these inputs.\n")
|
|
104
|
+
ui.step("done", "runs", f"{len(runs)} runs recorded")
|
|
105
|
+
return "\n".join(lines)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _inside(path: Path, cap: Path, ui: UI) -> str:
|
|
109
|
+
out = ["## Deep: what's inside", ""]
|
|
110
|
+
if path.is_dir():
|
|
111
|
+
exts: Counter = Counter()
|
|
112
|
+
files = size = 0
|
|
113
|
+
top = sorted(x.name + ("/" if x.is_dir() else "") for x in path.iterdir())[:80]
|
|
114
|
+
for dirpath, dirnames, filenames in os.walk(path):
|
|
115
|
+
dirnames[:] = [d for d in dirnames if d not in IGNORED]
|
|
116
|
+
for n in filenames:
|
|
117
|
+
files += 1
|
|
118
|
+
exts[Path(n).suffix.lower() or "(none)"] += 1
|
|
119
|
+
try:
|
|
120
|
+
size += (Path(dirpath) / n).stat().st_size
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
if files > 50_000:
|
|
124
|
+
break
|
|
125
|
+
out.append(f"{files} files, {size / 1e6:.1f} MB. Top level: " + ", ".join(top))
|
|
126
|
+
out.append("\nBy type: " + ", ".join(f"{e} {c}" for e, c in exts.most_common(15)))
|
|
127
|
+
man = cap / "manifests"
|
|
128
|
+
copied = []
|
|
129
|
+
for name in MANIFESTS:
|
|
130
|
+
src = path / name
|
|
131
|
+
if src.is_file() and src.stat().st_size < 200_000:
|
|
132
|
+
man.mkdir(exist_ok=True)
|
|
133
|
+
shutil.copyfile(src, man / name)
|
|
134
|
+
copied.append(name)
|
|
135
|
+
if copied:
|
|
136
|
+
out.append("\nCopied to capture/manifests/: " + ", ".join(copied) + " (read them first).")
|
|
137
|
+
stacks = [tc["file"] for tc in tables.TOOLCHAINS if (path / tc["file"]).exists()]
|
|
138
|
+
if stacks:
|
|
139
|
+
out.append(f"Built with: {', '.join(stacks)}.")
|
|
140
|
+
out.append(f"\nThe original folder is {path}: read what you need there, never change it.\n")
|
|
141
|
+
elif path.is_file():
|
|
142
|
+
st = path.stat()
|
|
143
|
+
out.append(f"{path.name}: {st.st_size / 1e6:.2f} MB.")
|
|
144
|
+
if proc.which("file"):
|
|
145
|
+
r = proc.run(["file", "-b", str(path)], timeout=20)
|
|
146
|
+
if r.ok:
|
|
147
|
+
out.append(f"Type: {r.output.strip()}")
|
|
148
|
+
found = _strings(path)
|
|
149
|
+
write_atomic(cap / "strings.txt", "\n".join(found))
|
|
150
|
+
out.append(f"\n{len(found)} readable strings saved to strings.txt (options, messages, names: clues to its features).\n")
|
|
151
|
+
ui.step("done", "inside", "inventory written")
|
|
152
|
+
return "\n".join(out)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _strings(path: Path, minimum: int = 6, limit: int = 3000, scan: int = 32_000_000) -> list[str]:
|
|
156
|
+
try:
|
|
157
|
+
with open(path, "rb") as f:
|
|
158
|
+
data = f.read(scan)
|
|
159
|
+
except OSError:
|
|
160
|
+
return []
|
|
161
|
+
found: list[str] = []
|
|
162
|
+
seen: set = set()
|
|
163
|
+
for m in re.finditer(rb"[\x20-\x7e\t]{%d,}" % minimum, data):
|
|
164
|
+
s = m.group().decode("ascii").strip()
|
|
165
|
+
if s and s not in seen:
|
|
166
|
+
seen.add(s)
|
|
167
|
+
found.append(s[:200])
|
|
168
|
+
if len(found) >= limit:
|
|
169
|
+
break
|
|
170
|
+
return found
|
craft_kit/tables.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Catalogues as data: kinds of thing made, stacks, toolchains and coding agents.
|
|
2
|
+
Override or extend agents with ~/.config/craft/agents.json (same shape as AGENTS)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
WIDTHS = [1440, 768, 390] # desktop, tablet, phone: Study and Check photograph the same widths
|
|
12
|
+
MAX_ROUNDS = 6 # refinement rounds per request (kept across runs; a new request resets it)
|
|
13
|
+
GOOD_ENOUGH = 90.0 # stop refining once every scored check is at least this alike (percent)
|
|
14
|
+
UNCHANGED_LIMIT = 2 # stop when the builder changes nothing this many turns in a row
|
|
15
|
+
PLAYWRIGHT = "playwright@1.48.2"
|
|
16
|
+
|
|
17
|
+
# A kind: how it's recognised (whole words, English and French), what the goal is, which checks judge it.
|
|
18
|
+
KINDS: dict[str, dict] = {
|
|
19
|
+
"website": {
|
|
20
|
+
"words": ["website", "site", "webpage", "landing", "homepage", "page", "web"],
|
|
21
|
+
"goal": "a website that looks and behaves like the original at every screen width",
|
|
22
|
+
"checks": ["build", "web", "tests"],
|
|
23
|
+
},
|
|
24
|
+
"game": {
|
|
25
|
+
"words": ["game", "jeu", "gameplay", "arcade", "platformer", "puzzle"],
|
|
26
|
+
"goal": "a playable game with the same rules, controls, numbers and feel",
|
|
27
|
+
"checks": ["build", "selftest", "screenshot"],
|
|
28
|
+
},
|
|
29
|
+
"cli": {
|
|
30
|
+
"words": ["cli", "command", "commande", "console", "terminal", "shell", "script"],
|
|
31
|
+
"goal": "a command-line program that prints the same output and exits with the same codes",
|
|
32
|
+
"checks": ["build", "runs", "tests"],
|
|
33
|
+
},
|
|
34
|
+
"app": {
|
|
35
|
+
"words": ["app", "application", "desktop", "gui", "window", "fenêtre", "mobile"],
|
|
36
|
+
"goal": "an application with the same core features and screens",
|
|
37
|
+
"checks": ["build", "screenshot", "tests"],
|
|
38
|
+
},
|
|
39
|
+
"tool": {
|
|
40
|
+
"words": ["library", "librairie", "bibliothèque", "api", "server", "serveur", "service", "sdk", "package", "plugin", "outil", "tool"],
|
|
41
|
+
"goal": "software that does what the original does, verified by its own tests",
|
|
42
|
+
"checks": ["build", "tests"],
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
KIND_ORDER = ["game", "website", "cli", "app", "tool"]
|
|
46
|
+
|
|
47
|
+
# A stack: phrases that ask for it (matched on " lower-case words "), and what the builder is told.
|
|
48
|
+
STACKS: list[dict] = [
|
|
49
|
+
{"name": "Next.js (static export)", "asked_by": [" next js ", " nextjs "],
|
|
50
|
+
"steps": "Next.js App Router + TypeScript with output: 'export', trailingSlash: true and unoptimized images; build with `npm install && npm run build`; the site is app/out."},
|
|
51
|
+
{"name": "React + TypeScript (Vite)", "asked_by": [" react ", " vite "],
|
|
52
|
+
"steps": "Vite + React + TypeScript with base './'; build with `npm install && npm run build`; the site is app/dist."},
|
|
53
|
+
{"name": "Plain HTML and CSS", "asked_by": [" html ", " plain html ", " no framework ", " sans framework ", " static "],
|
|
54
|
+
"steps": "plain HTML and CSS (JavaScript only where needed), no build step; index.html at the top of app/, one file per page."},
|
|
55
|
+
{"name": "Electron", "asked_by": [" electron "],
|
|
56
|
+
"steps": "Electron + TypeScript or JavaScript, contextIsolation on, nodeIntegration off, IPC through a preload script; a `build` script that exits without opening a window."},
|
|
57
|
+
{"name": "Python", "asked_by": [" python ", " py "],
|
|
58
|
+
"steps": "Python 3.9+ with a pyproject.toml, standard library first; tests with pytest under app/tests."},
|
|
59
|
+
{"name": "Rust", "asked_by": [" rust ", " cargo "],
|
|
60
|
+
"steps": "a cargo project at app/ itself (app/Cargo.toml, app/src/); `cargo build` must succeed and `cargo test` must pass."},
|
|
61
|
+
{"name": "Go", "asked_by": [" go ", " golang "],
|
|
62
|
+
"steps": "a Go module at app/ itself; `go build ./...` must succeed and `go test ./...` must pass."},
|
|
63
|
+
{"name": "Node.js + TypeScript", "asked_by": [" node ", " nodejs ", " typescript ", " javascript ", " js ", " ts "],
|
|
64
|
+
"steps": "Node.js 18+ with TypeScript, a package.json `build` script and tests runnable with `npm test`."},
|
|
65
|
+
]
|
|
66
|
+
BUILDERS_CHOICE = {
|
|
67
|
+
"name": "Builder's choice",
|
|
68
|
+
"steps": "whatever their request names; otherwise choose the stack that best fits the request and what the original visibly uses, and prefer the simplest one that does the job. A website must build to static files (index.html at the top of a folder) that work when served as they are.",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# How a project builds and tests itself, found by the file that names the tool (first match wins).
|
|
72
|
+
PY = os.path.basename(sys.executable) if sys.executable else "python3"
|
|
73
|
+
TOOLCHAINS: list[dict] = [
|
|
74
|
+
{"file": "package.json", "install": ["npm", "install"], "build": ["npm", "run", "build", "--if-present"], "test": ["npm", "test", "--if-present"]},
|
|
75
|
+
{"file": "Cargo.toml", "build": ["cargo", "build"], "test": ["cargo", "test"]},
|
|
76
|
+
{"file": "go.mod", "build": ["go", "build", "./..."], "test": ["go", "test", "./..."]},
|
|
77
|
+
{"file": "pyproject.toml", "build": [PY, "-m", "compileall", "-q", "."], "test": [PY, "-m", "pytest", "-q"]},
|
|
78
|
+
{"file": "requirements.txt", "build": [PY, "-m", "compileall", "-q", "."], "test": [PY, "-m", "pytest", "-q"]},
|
|
79
|
+
{"file": "CMakeLists.txt", "build": ["cmake", "-S", ".", "-B", "build"], "test": ["ctest", "--test-dir", "build"]},
|
|
80
|
+
{"file": "Makefile", "build": ["make"], "test": ["make", "test"]},
|
|
81
|
+
]
|
|
82
|
+
NO_TESTS = ["no tests ran", "no tests found", "0 tests", "running 0 tests", "no test files", "missing script: \"test\"", "no tests to run", "collected 0 items"]
|
|
83
|
+
SITE_DIRS = ["dist", "out", "build", "public", "."]
|
|
84
|
+
|
|
85
|
+
# Coding agents, driven headless. The brief goes in on stdin (never argv: it can be long).
|
|
86
|
+
_CLAUDE_TOOLS = ",".join(
|
|
87
|
+
["Read", "Write", "Edit", "Glob", "Grep", "TodoWrite"]
|
|
88
|
+
+ [f"Bash({p}:*)" for p in ["npm", "npx", "node", "pnpm", "yarn", "cargo", "rustc", "go", "python", "python3", "pip", "pytest", "make", "cmake", "craft", "ls", "mkdir", "cat"]]
|
|
89
|
+
)
|
|
90
|
+
AGENTS: dict[str, dict] = {
|
|
91
|
+
"claude": {
|
|
92
|
+
"argv": ["claude", "-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits", "--allowedTools", _CLAUDE_TOOLS],
|
|
93
|
+
"stream": "claude-json",
|
|
94
|
+
"resume": ["--resume", "{session}"],
|
|
95
|
+
"lost": "No conversation found",
|
|
96
|
+
},
|
|
97
|
+
"codex": {"argv": ["codex", "exec", "--full-auto", "-"], "stream": "text"},
|
|
98
|
+
"gemini": {"argv": ["gemini", "--yolo"], "stream": "text"},
|
|
99
|
+
}
|
|
100
|
+
AGENT_ORDER = ["claude", "codex", "gemini"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def config_dir() -> Path:
|
|
104
|
+
if os.environ.get("CRAFT_CONFIG_DIR"):
|
|
105
|
+
return Path(os.environ["CRAFT_CONFIG_DIR"])
|
|
106
|
+
if os.environ.get("XDG_CONFIG_HOME"):
|
|
107
|
+
return Path(os.environ["XDG_CONFIG_HOME"]) / "craft"
|
|
108
|
+
return Path.home() / ".config" / "craft"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def agents() -> dict[str, dict]:
|
|
112
|
+
table = {k: dict(v) for k, v in AGENTS.items()}
|
|
113
|
+
path = config_dir() / "agents.json"
|
|
114
|
+
try:
|
|
115
|
+
extra = json.loads(path.read_text("utf-8"))
|
|
116
|
+
except (OSError, ValueError):
|
|
117
|
+
return table
|
|
118
|
+
if isinstance(extra, dict):
|
|
119
|
+
for name, spec in extra.items():
|
|
120
|
+
if isinstance(spec, dict) and isinstance(spec.get("argv"), list) and all(isinstance(a, str) for a in spec["argv"]):
|
|
121
|
+
table[name] = {**table.get(name, {}), **spec}
|
|
122
|
+
if name not in AGENT_ORDER:
|
|
123
|
+
AGENT_ORDER.append(name)
|
|
124
|
+
return table
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def words(text: str) -> str:
|
|
128
|
+
"""Lower-case words padded with spaces, punctuation as spaces: "Next.js," -> " next js "."""
|
|
129
|
+
cleaned = "".join(ch if ch.isalnum() else " " for ch in text.lower())
|
|
130
|
+
return f" {' '.join(cleaned.split())} "
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def detect_kind(request: str, target_type: str) -> str:
|
|
134
|
+
w = words(request)
|
|
135
|
+
for kind in KIND_ORDER:
|
|
136
|
+
if any(f" {word} " in w for word in KINDS[kind]["words"]):
|
|
137
|
+
# "site" alone in a request about a game or CLI shouldn't win; KIND_ORDER puts game first.
|
|
138
|
+
return kind
|
|
139
|
+
return "website" if target_type == "website" else "tool"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def detect_stack(request: str) -> dict:
|
|
143
|
+
w = words(request)
|
|
144
|
+
for s in STACKS:
|
|
145
|
+
if any(p in w for p in s["asked_by"]):
|
|
146
|
+
return s
|
|
147
|
+
return BUILDERS_CHOICE
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def stack_by_name(name: str) -> dict:
|
|
151
|
+
for s in STACKS:
|
|
152
|
+
if s["name"].lower() == name.lower() or name.lower() in words(s["name"]):
|
|
153
|
+
return s
|
|
154
|
+
return BUILDERS_CHOICE if name.lower() in ("", "builder's choice", "auto", "choice") else {"name": name, "steps": f"{name}, as the request asks."}
|
craft_kit/ui.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""A minimal terminal interface: one line per step, a live feed of what's happening, one live line
|
|
2
|
+
while something runs. Each stage has its colour (study sky, build blue, refine violet, check amber,
|
|
3
|
+
ready green); NO_COLOR, a pipe or TERM=dumb give plain text, CRAFT_FEED=0 keeps only the live line."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from . import banner
|
|
15
|
+
|
|
16
|
+
ICONS = {"done": ("✓", "32"), "run": ("●", "36"), "todo": ("○", "2"), "fail": ("✗", "31"), "wait": ("?", "33"), "skip": ("–", "2")}
|
|
17
|
+
FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
18
|
+
|
|
19
|
+
RGB = tuple # (r, g, b)
|
|
20
|
+
|
|
21
|
+
# The stages of the loop, each with its colour.
|
|
22
|
+
STAGES: dict = {
|
|
23
|
+
"study": (70, 210, 255),
|
|
24
|
+
"build": (60, 125, 255),
|
|
25
|
+
"refine": (165, 95, 255),
|
|
26
|
+
"check": (255, 185, 60),
|
|
27
|
+
"ready": (80, 220, 130),
|
|
28
|
+
}
|
|
29
|
+
# Step lines whose name isn't a stage say which stage they belong to.
|
|
30
|
+
STEP_STAGE = {"runs": "study", "inside": "study", "new": "ready", "answer": "build", "open": "ready", "skill": "ready"}
|
|
31
|
+
|
|
32
|
+
# What the builder does, by kind: icon, verb, colour. None = dim.
|
|
33
|
+
KINDS: dict = {
|
|
34
|
+
"read": ("◇", "read", (120, 200, 255)),
|
|
35
|
+
"edit": ("✎", "edit", (90, 225, 140)),
|
|
36
|
+
"write": ("✚", "write", (60, 235, 170)),
|
|
37
|
+
"run": ("❯", "run", (255, 200, 80)),
|
|
38
|
+
"search": ("⌕", "search", (205, 140, 255)),
|
|
39
|
+
"web": ("◎", "web", (80, 165, 255)),
|
|
40
|
+
"plan": ("☰", "plan", (245, 115, 200)),
|
|
41
|
+
"agent": ("◆", "agent", (150, 160, 255)),
|
|
42
|
+
"tool": ("▸", "tool", (180, 180, 200)),
|
|
43
|
+
"fail": ("✗", "failed", (240, 85, 95)),
|
|
44
|
+
"say": ("“", "", None),
|
|
45
|
+
"out": ("·", "", None),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tty(stream) -> bool:
|
|
50
|
+
try:
|
|
51
|
+
return stream.isatty() and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb"
|
|
52
|
+
except (AttributeError, ValueError):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def elapsed(seconds: float) -> str:
|
|
57
|
+
s = int(seconds)
|
|
58
|
+
return f"{s // 60}m{s % 60:02d}s" if s >= 60 else f"{s}s"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def clock(seconds: float) -> str:
|
|
62
|
+
s = int(seconds)
|
|
63
|
+
return f"{s // 60}:{s % 60:02d}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fit(text: str, room: int) -> str:
|
|
67
|
+
text = " ".join(text.split())
|
|
68
|
+
return text if len(text) <= room else text[: max(0, room - 1)] + "…"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def stage_of(name: str) -> Optional[str]:
|
|
72
|
+
return name if name in STAGES else STEP_STAGE.get(name)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _mix(a: RGB, b: RGB, k: float) -> RGB:
|
|
76
|
+
return tuple(round(x + (y - x) * k) for x, y in zip(a, b))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UI:
|
|
80
|
+
def __init__(self, stream=None):
|
|
81
|
+
self.out = stream or sys.stderr
|
|
82
|
+
self.fancy = _tty(self.out)
|
|
83
|
+
self.feed = os.environ.get("CRAFT_FEED", "1").lower() not in ("0", "off", "no", "false")
|
|
84
|
+
self._lock = threading.Lock()
|
|
85
|
+
self._live: Live | None = None
|
|
86
|
+
|
|
87
|
+
# colours ---------------------------------------------------------------
|
|
88
|
+
def c(self, code: str, text: str) -> str:
|
|
89
|
+
return f"\033[{code}m{text}\033[0m" if self.fancy else text
|
|
90
|
+
|
|
91
|
+
def rgb(self, colour: Optional[RGB], text: str, bold: bool = False, italic: bool = False, dim: bool = False) -> str:
|
|
92
|
+
if not self.fancy or not text:
|
|
93
|
+
return text
|
|
94
|
+
style = ("\033[1m" if bold else "") + ("\033[3m" if italic else "") + ("\033[2m" if dim else "")
|
|
95
|
+
return f"{style}{banner.fg(colour) if colour else ''}{text}\033[0m"
|
|
96
|
+
|
|
97
|
+
def dim(self, t: str) -> str:
|
|
98
|
+
return self.c("2", t)
|
|
99
|
+
|
|
100
|
+
def bold(self, t: str) -> str:
|
|
101
|
+
return self.c("1", t)
|
|
102
|
+
|
|
103
|
+
def red(self, t: str) -> str:
|
|
104
|
+
return self.c("31", t)
|
|
105
|
+
|
|
106
|
+
def green(self, t: str) -> str:
|
|
107
|
+
return self.c("32", t)
|
|
108
|
+
|
|
109
|
+
def yellow(self, t: str) -> str:
|
|
110
|
+
return self.c("33", t)
|
|
111
|
+
|
|
112
|
+
def width(self) -> int:
|
|
113
|
+
return max(40, min(shutil.get_terminal_size((80, 20)).columns, 100))
|
|
114
|
+
|
|
115
|
+
# output ------------------------------------------------------------------
|
|
116
|
+
def say(self, text: str = "") -> None:
|
|
117
|
+
with self._lock:
|
|
118
|
+
if self._live is not None:
|
|
119
|
+
self._live.clear()
|
|
120
|
+
print(text, file=self.out, flush=True)
|
|
121
|
+
|
|
122
|
+
def header(self, name: str, right: str) -> None:
|
|
123
|
+
w = self.width()
|
|
124
|
+
left = f"craft · {name}"
|
|
125
|
+
gap = max(1, w - len(left) - len(right) - 2)
|
|
126
|
+
self.say(f" {self.bold(left)}{' ' * gap}{self.dim(right)}")
|
|
127
|
+
|
|
128
|
+
def stage(self, name: str, detail: str = "") -> None:
|
|
129
|
+
"""A coloured rule that opens a stage: ` ▍build claude · turn 3 ─────────`."""
|
|
130
|
+
colour = STAGES.get(name)
|
|
131
|
+
detail = fit(detail, self.width() - len(name) - 12)
|
|
132
|
+
rule = "─" * max(3, self.width() - len(name) - len(detail) - 8)
|
|
133
|
+
if not self.fancy:
|
|
134
|
+
self.say(f"\n -- {name} {detail}".rstrip())
|
|
135
|
+
return
|
|
136
|
+
self.say("")
|
|
137
|
+
self.say(f" {self.rgb(colour, '▍' + name, bold=True)} {detail}{' ' if detail else ''}{self.rgb(colour, rule, dim=True)}")
|
|
138
|
+
|
|
139
|
+
def step(self, state: str, name: str, detail: str = "", stage: Optional[str] = None) -> None:
|
|
140
|
+
icon, code = ICONS.get(state, ICONS["todo"])
|
|
141
|
+
quiet = state in ("todo", "skip")
|
|
142
|
+
detail = self.dim(detail) if quiet else detail
|
|
143
|
+
colour = STAGES.get(stage or stage_of(name) or "")
|
|
144
|
+
label = f"{name:<7}"
|
|
145
|
+
label = self.dim(label) if quiet else (self.rgb(colour, label, bold=True) if colour else label)
|
|
146
|
+
self.say(f" {self.c(code, icon)} {label} {detail}".rstrip())
|
|
147
|
+
|
|
148
|
+
def note(self, text: str) -> None:
|
|
149
|
+
for line in text.splitlines() or [""]:
|
|
150
|
+
self.say(f" {self.dim('│')} {line}")
|
|
151
|
+
|
|
152
|
+
def error(self, text: str) -> None:
|
|
153
|
+
self.say(f" {self.red('✗')} {text}")
|
|
154
|
+
|
|
155
|
+
def live(self, label: str, stage: Optional[str] = None) -> "Live":
|
|
156
|
+
return Live(self, label, stage)
|
|
157
|
+
|
|
158
|
+
# input -------------------------------------------------------------------
|
|
159
|
+
def interactive(self) -> bool:
|
|
160
|
+
return sys.stdin.isatty() and self.fancy
|
|
161
|
+
|
|
162
|
+
def ask(self, prompt: str) -> str:
|
|
163
|
+
with self._lock:
|
|
164
|
+
if self._live is not None:
|
|
165
|
+
self._live.clear()
|
|
166
|
+
try:
|
|
167
|
+
return input(f" {self.c('33', '?')} {prompt} ").strip()
|
|
168
|
+
except EOFError:
|
|
169
|
+
return ""
|
|
170
|
+
|
|
171
|
+
def choose(self, question: str, choices: list[str]) -> str:
|
|
172
|
+
self.say(f" {self.c('33', '?')} {question}")
|
|
173
|
+
for i, ch in enumerate(choices, 1):
|
|
174
|
+
self.say(f" {self.bold(str(i))} {ch}")
|
|
175
|
+
raw = self.ask("number, or your own answer:")
|
|
176
|
+
if raw.isdigit() and 1 <= int(raw) <= len(choices):
|
|
177
|
+
return choices[int(raw) - 1]
|
|
178
|
+
return raw
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Live:
|
|
182
|
+
"""While something runs: a feed of what it does (one line per action, kept on screen) above a
|
|
183
|
+
single line that redraws in place (spinner, shimmering label, elapsed time, latest activity)."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, ui: UI, label: str, stage: Optional[str] = None):
|
|
186
|
+
self.ui = ui
|
|
187
|
+
self.label = label
|
|
188
|
+
self.colour: RGB = STAGES.get(stage or "", (150, 160, 255))
|
|
189
|
+
self.detail = ""
|
|
190
|
+
self.start = time.monotonic()
|
|
191
|
+
self.counts: dict = {}
|
|
192
|
+
self._last_event = ""
|
|
193
|
+
self._stop = threading.Event()
|
|
194
|
+
self._thread: threading.Thread | None = None
|
|
195
|
+
|
|
196
|
+
def __enter__(self) -> "Live":
|
|
197
|
+
if self.ui.fancy:
|
|
198
|
+
self.ui._live = self
|
|
199
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
200
|
+
self._thread.start()
|
|
201
|
+
else:
|
|
202
|
+
print(f" … {self.label}", file=self.ui.out, flush=True)
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def __exit__(self, *exc) -> None:
|
|
206
|
+
self._stop.set()
|
|
207
|
+
if self._thread is not None:
|
|
208
|
+
self._thread.join(timeout=1)
|
|
209
|
+
with self.ui._lock:
|
|
210
|
+
self.clear()
|
|
211
|
+
self.ui._live = None
|
|
212
|
+
|
|
213
|
+
def update(self, detail: str) -> None:
|
|
214
|
+
self.detail = " ".join(detail.split())
|
|
215
|
+
|
|
216
|
+
def event(self, kind: str, text: str, count: bool = True) -> None:
|
|
217
|
+
"""One action, kept in the feed: ` 1:42 ✎ edit src/main.rs`."""
|
|
218
|
+
text = " ".join(text.split())
|
|
219
|
+
if not text:
|
|
220
|
+
return
|
|
221
|
+
self.update(text)
|
|
222
|
+
key = f"{kind}\0{text}"
|
|
223
|
+
if key == self._last_event: # the same line twice in a row says nothing new
|
|
224
|
+
return
|
|
225
|
+
self._last_event = key
|
|
226
|
+
if count and kind not in ("say", "out"):
|
|
227
|
+
self.counts[kind] = self.counts.get(kind, 0) + 1
|
|
228
|
+
if not self.ui.feed:
|
|
229
|
+
return
|
|
230
|
+
icon, verb, colour = KINDS.get(kind, KINDS["tool"])
|
|
231
|
+
stamp = clock(time.monotonic() - self.start)
|
|
232
|
+
head = f"{icon} {verb:<7}" if verb else f"{icon} "
|
|
233
|
+
room = self.ui.width() - len(stamp) - len(head) - 6
|
|
234
|
+
body = fit(text, room)
|
|
235
|
+
u = self.ui
|
|
236
|
+
if kind in ("say", "out"):
|
|
237
|
+
line = f" {u.dim(stamp)} {u.dim(head)}{u.rgb(None, body, italic=kind == 'say', dim=True)}"
|
|
238
|
+
else:
|
|
239
|
+
line = f" {u.dim(stamp)} {u.rgb(colour, head, bold=True)}{u.rgb(colour, body) if kind == 'fail' else body}"
|
|
240
|
+
u.say(line)
|
|
241
|
+
|
|
242
|
+
def tally(self) -> str:
|
|
243
|
+
"""`2m14s · 18 actions: 6 edit, 5 read, 4 run`."""
|
|
244
|
+
counts = {k: n for k, n in self.counts.items() if k != "fail"}
|
|
245
|
+
total = sum(counts.values())
|
|
246
|
+
out = elapsed(time.monotonic() - self.start)
|
|
247
|
+
if total:
|
|
248
|
+
parts = sorted(counts.items(), key=lambda kv: -kv[1])
|
|
249
|
+
out += f" · {total} action{'s' if total > 1 else ''}: " + ", ".join(f"{n} {KINDS.get(k, KINDS['tool'])[1] or k}" for k, n in parts)
|
|
250
|
+
if self.counts.get("fail"):
|
|
251
|
+
out += f" · {self.counts['fail']} failed"
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
def clear(self) -> None:
|
|
255
|
+
if self.ui.fancy:
|
|
256
|
+
self.ui.out.write("\r\033[2K")
|
|
257
|
+
self.ui.out.flush()
|
|
258
|
+
|
|
259
|
+
def _shimmer(self, text: str, i: int) -> str:
|
|
260
|
+
"""The label in its stage colour, a bright band sweeping across it."""
|
|
261
|
+
base = _mix(self.colour, (40, 40, 60), 0.25)
|
|
262
|
+
n = len(text) + 8
|
|
263
|
+
pos = (i * 0.6) % n - 4
|
|
264
|
+
cells = []
|
|
265
|
+
for x, ch in enumerate(text):
|
|
266
|
+
k = max(0.0, 1.0 - abs(x - pos) / 4.0)
|
|
267
|
+
cells.append(f"{banner.fg(_mix(base, (255, 255, 255), 0.7 * k))}{ch}")
|
|
268
|
+
return "\033[1m" + "".join(cells) + "\033[0m"
|
|
269
|
+
|
|
270
|
+
def _spin(self) -> None:
|
|
271
|
+
i = 0
|
|
272
|
+
while not self._stop.wait(0.1):
|
|
273
|
+
i += 1
|
|
274
|
+
u = self.ui
|
|
275
|
+
frame = FRAMES[i % len(FRAMES)]
|
|
276
|
+
time_s = elapsed(time.monotonic() - self.start)
|
|
277
|
+
done = sum(n for k, n in self.counts.items() if k != "fail")
|
|
278
|
+
meta = f" · {time_s}" + (f" · {done}" if done else "")
|
|
279
|
+
room = u.width() - 1 - 5 - len(self.label) - len(meta)
|
|
280
|
+
detail = f" · {fit(self.detail, room - 3)}" if self.detail and room > 8 else ""
|
|
281
|
+
text = f" {u.rgb(self.colour, frame, bold=True)} {self._shimmer(self.label, i)}{u.dim(meta)}{u.dim(detail)}"
|
|
282
|
+
with u._lock:
|
|
283
|
+
u.out.write("\r\033[2K" + text)
|
|
284
|
+
u.out.flush()
|