projectinator 0.3.0 → 0.3.1

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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![npm](https://img.shields.io/npm/v/projectinator.svg?color=e0a72d&label=npm)](https://www.npmjs.com/package/projectinator)
8
8
  ![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)
9
9
  ![node](https://img.shields.io/badge/node-%E2%89%A522.19-brightgreen.svg)
10
- ![tests: 181 passing](https://img.shields.io/badge/tests-181%20passing-brightgreen.svg)
10
+ ![tests: 187 passing](https://img.shields.io/badge/tests-187%20passing-brightgreen.svg)
11
11
  ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)
12
12
  ![built on Pi](https://img.shields.io/badge/built%20on-Pi%20agent%20harness-e0a72d.svg)
13
13
 
@@ -30,6 +30,8 @@ npx projectinator # run without installing
30
30
  npm install -g projectinator # then just: projectinator
31
31
  ```
32
32
 
33
+ `projectinator --help` / `--version` work without opening the app; `projectinator doctor` checks your setup.
34
+
33
35
  > **It's a CLI, not a library.** `npm install projectinator` (without `-g`) only drops it into a
34
36
  > project's `node_modules` — it won't create a runnable command. Use `npx projectinator` or
35
37
  > `npm install -g projectinator`. (If `-g` installs but the command isn't found, npm's global
@@ -120,16 +122,32 @@ Real, unedited output from a full build, kept in [`examples/`](examples/):
120
122
 
121
123
  ## CLI (same engine, for scripting/CI)
122
124
 
125
+ Everything below uses the same workspace as the cockpit, so headless builds show up in the app.
126
+
123
127
  ```bash
124
- npm start # the cockpit (the normal way to use it)
125
- npm run build -- --live --mini # cheap end-to-end proof (~$0.10)
126
- npm run build -- --live --lock anthropic "idea" # full pipeline on one provider
127
- npm run build -- --live --mini --resume # resume a halted/finished build (skips done tasks)
128
- npm run build -- --live --mini --task-cap 0.5 --task-timeout 5 # per-task limits (USD / minutes)
128
+ projectinator doctor # Node, keys, Chromium, Pi catalog, git
129
+ projectinator build "a tip calculator" --dry-run # plan + estimate only (one PM call)
130
+ projectinator build "a tip calculator" --yes # build without the confirmation prompt
131
+ projectinator build "…" --json --budget 2 --provider anthropic # NDJSON events; cap; lock provider
132
+ projectinator build "…" --task-cap 0.5 --task-timeout 5 # per-task limits (USD / minutes)
133
+ projectinator projects # past builds, status, cost
134
+ projectinator models # the roster as it will run, with prices
135
+ ```
136
+
137
+ Exit codes: `0` ok · `1` environment problem · `2` bad usage · `3` build halted (resume it in the app).
138
+
139
+ <details>
140
+ <summary>Developer scripts (from a clone)</summary>
141
+
142
+ ```bash
143
+ npm start # the cockpit
144
+ npm run build -- --live --mini # fixed 4-task build, cheap end-to-end proof (~$0.10)
145
+ npm run build -- --live --mini --resume # resume a halted run
129
146
  npm run bakeoff -- --capability design "Design a pricing page" # model bake-off
130
- npm test # 181 tests
147
+ npm test # 187 tests
131
148
  npm run typecheck
132
149
  ```
150
+ </details>
133
151
 
134
152
  ## Configuration & data
135
153
 
@@ -2,14 +2,52 @@
2
2
  // Launcher for `projectinator` / `npx github:smanookian/projectinator`.
3
3
  // Runs the Ink TUI (TypeScript) through tsx — no build step, no compiled dist.
4
4
  import { spawnSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { dirname, join } from "node:path";
7
8
 
8
9
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
9
10
  const entry = join(root, "src", "tui.tsx");
10
11
 
12
+ const args = process.argv.slice(2);
13
+ const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
14
+
15
+ if (args.includes("--version") || args.includes("-v")) {
16
+ console.log(version);
17
+ process.exit(0);
18
+ }
19
+ if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
20
+ console.log(`projectinator ${version} — your AI build team in the terminal
21
+
22
+ Usage
23
+ projectinator open the cockpit (TUI)
24
+ projectinator doctor check Node, API keys, Chromium, Pi catalog, git
25
+ projectinator build "<idea>" [...] plan + build headless (--dry-run, --yes, --json,
26
+ --budget, --provider, --concurrency, --task-cap, --task-timeout)
27
+ projectinator projects list past builds with status and cost
28
+ projectinator models the roster as it will run, with prices
29
+ projectinator --version | --help
30
+
31
+ Setup
32
+ Inside the app: Settings → API keys (Anthropic, OpenAI, Gemini, OpenRouter).
33
+ Keys live in ~/.projectinator/config.json (chmod 0600).
34
+ Optional: npx playwright install chromium → lets the tester run the app headless.
35
+
36
+ Exit codes: 0 ok · 1 environment problem · 2 bad usage · 3 build halted
37
+ Docs: https://github.com/smanookian/projectinator#readme`);
38
+ process.exit(0);
39
+ }
40
+
41
+ const COMMANDS = new Set(["doctor", "build", "projects", "models"]);
42
+ if (args.length && !COMMANDS.has(args[0])) {
43
+ console.error(`projectinator: unknown ${args[0].startsWith("-") ? "option" : "command"} "${args[0]}". Try --help.`);
44
+ process.exit(2);
45
+ }
46
+
11
47
  // `node --import tsx <entry>` registers tsx's loader, then runs the TS entry.
12
- const res = spawnSync(process.execPath, ["--import", "tsx", entry], {
48
+ // No command the TUI; a command → the headless CLI with the remaining args.
49
+ const target = args.length ? join(root, "src", "cli.ts") : entry;
50
+ const res = spawnSync(process.execPath, ["--import", "tsx", target, ...args], {
13
51
  stdio: "inherit",
14
52
  cwd: root,
15
53
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectinator",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Your AI build team in the terminal — hand it an app idea, a PM model plans a Scrum backlog, and the best model per role designs, codes, and tests it into working files. Bring your own API key.",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.ts ADDED
@@ -0,0 +1,263 @@
1
+ // Headless CLI — `projectinator <command>`. Same engine as the cockpit (engine.ts),
2
+ // same workspace root, so builds started here show up in the TUI's project list.
3
+ //
4
+ // doctor check Node, keys, Chromium, Pi catalog, git
5
+ // build "<idea>" [...] plan + build without the TUI
6
+ // projects list past builds
7
+ // models the roster as it will actually run, with prices
8
+ //
9
+ // The launcher (bin/projectinator.mjs) handles --version/--help itself and only
10
+ // spawns this file for a real command, so those stay instant.
11
+
12
+ import { createInterface } from "node:readline";
13
+ import { spawnSync } from "node:child_process";
14
+ import { accessSync, constants, mkdirSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import type { OrchestratorEvent } from "./orchestrator.js";
18
+ import type { Provider } from "./types.js";
19
+ import { MODELS, getModel } from "./models.js";
20
+ import { REGISTRY } from "./registry.js";
21
+ import { piRuntime, resolvePiModel } from "./executor.js";
22
+ import { chromiumAvailable, CHROMIUM_INSTALL_HINT } from "./preview.js";
23
+ import { applyKeysToEnv, getPrefs, loadConfig, ENV_VAR } from "./tui/config.js";
24
+ import {
25
+ availableProviders,
26
+ effectiveRoster,
27
+ listProjects,
28
+ planBuild,
29
+ startBuild,
30
+ PROVIDER_LABEL,
31
+ } from "./tui/engine.js";
32
+
33
+ const NODE_MIN = "22.19.0";
34
+ const money = (n: number) => `$${n.toFixed(2)}`;
35
+
36
+ // ---- tiny argv parser: `--flag value`, `--flag=value`, `--bool`, positionals ----
37
+
38
+ interface Argv {
39
+ positional: string[];
40
+ flags: Record<string, string | true>;
41
+ }
42
+
43
+ export function parseArgv(args: string[]): Argv {
44
+ const out: Argv = { positional: [], flags: {} };
45
+ for (let i = 0; i < args.length; i++) {
46
+ const a = args[i]!;
47
+ if (!a.startsWith("--")) { out.positional.push(a); continue; }
48
+ const eq = a.indexOf("=");
49
+ if (eq > 0) { out.flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
50
+ const next = args[i + 1];
51
+ if (next !== undefined && !next.startsWith("--")) { out.flags[a.slice(2)] = next; i++; }
52
+ else out.flags[a.slice(2)] = true;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function num(v: string | true | undefined, fallback: number): number {
58
+ if (v === undefined || v === true) return fallback;
59
+ const n = parseFloat(v);
60
+ return Number.isFinite(n) ? n : fallback;
61
+ }
62
+
63
+ export function semverGte(a: string, b: string): boolean {
64
+ const pa = a.split(".").map(Number);
65
+ const pb = b.split(".").map(Number);
66
+ for (let i = 0; i < 3; i++) {
67
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
68
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ // ---- doctor ----
74
+
75
+ type Check = { label: string; ok: boolean; detail: string; fatal?: boolean };
76
+
77
+ async function doctor(): Promise<number> {
78
+ const checks: Check[] = [];
79
+
80
+ const node = process.versions.node;
81
+ checks.push({ label: "Node", ok: semverGte(node, NODE_MIN), detail: `v${node} (need ≥ ${NODE_MIN})`, fatal: true });
82
+
83
+ const cfg = loadConfig();
84
+ const providers = availableProviders();
85
+ for (const p of Object.keys(ENV_VAR) as Provider[]) {
86
+ const has = providers.includes(p);
87
+ const src = cfg.keys[p] ? "~/.projectinator/config.json" : has ? "env" : "";
88
+ checks.push({ label: `Key: ${PROVIDER_LABEL[p]}`, ok: has, detail: has ? `set (${src})` : `not set — Settings → API keys, or export ${ENV_VAR[p]}` });
89
+ }
90
+ if (providers.length === 0) checks.push({ label: "Any provider key", ok: false, detail: "no keys at all — nothing can run", fatal: true });
91
+
92
+ try {
93
+ const runtime = await piRuntime();
94
+ const bad: string[] = [];
95
+ for (const e of REGISTRY) for (const b of ["api", "web"] as const) {
96
+ try { resolvePiModel(runtime, e.byBackend[b].provider, e.byBackend[b].model); } catch { bad.push(`${e.capability}/${e.tier}/${b}`); }
97
+ }
98
+ checks.push({ label: "Pi model catalog", ok: bad.length === 0, detail: bad.length ? `unresolved: ${bad.join(", ")}` : `${Object.keys(MODELS).length} models priced, every registry pick resolves`, fatal: true });
99
+ } catch (e) {
100
+ checks.push({ label: "Pi model catalog", ok: false, detail: e instanceof Error ? e.message : String(e), fatal: true });
101
+ }
102
+
103
+ const chromium = await chromiumAvailable();
104
+ checks.push({ label: "Headless Chromium", ok: chromium, detail: chromium ? "installed — tester runs the app" : `missing — tests will be code-reading only (PASS*); ${CHROMIUM_INSTALL_HINT}` });
105
+
106
+ const git = spawnSync("git", ["--version"], { encoding: "utf8" });
107
+ checks.push({ label: "git", ok: git.status === 0, detail: git.status === 0 ? git.stdout.trim() : "not found — builds won't be versioned (undo/history disabled)" });
108
+
109
+ const home = join(homedir(), ".projectinator");
110
+ try { mkdirSync(home, { recursive: true }); accessSync(home, constants.W_OK); checks.push({ label: "Data dir", ok: true, detail: home }); }
111
+ catch { checks.push({ label: "Data dir", ok: false, detail: `${home} not writable`, fatal: true }); }
112
+
113
+ const prefs = getPrefs();
114
+ checks.push({ label: "Prefs", ok: true, detail: `budget cap ${money(prefs.budgetCapUSD)} · ${prefs.concurrency} at once · task limits ${prefs.taskTimeoutMin || "∞"} min / ${prefs.taskCostCapUSD ? money(prefs.taskCostCapUSD) : "∞"}` });
115
+
116
+ console.log("\n projectinator doctor\n");
117
+ for (const c of checks) console.log(` ${c.ok ? "✓" : c.fatal ? "✗" : "!"} ${c.label.padEnd(22)} ${c.detail}`);
118
+ const fatal = checks.filter((c) => !c.ok && c.fatal);
119
+ const warn = checks.filter((c) => !c.ok && !c.fatal);
120
+ console.log(`\n ${fatal.length ? `${fatal.length} blocking problem${fatal.length === 1 ? "" : "s"}` : "Ready"}${warn.length ? ` · ${warn.length} warning${warn.length === 1 ? "" : "s"}` : ""}\n`);
121
+ return fatal.length ? 1 : 0;
122
+ }
123
+
124
+ // ---- projects ----
125
+
126
+ function projects(): number {
127
+ const list = listProjects();
128
+ if (!list.length) { console.log("\n No projects yet. Run `projectinator` or `projectinator build \"an idea\"`.\n"); return 0; }
129
+ console.log("");
130
+ for (const p of list) {
131
+ const done = p.state.outcomes.filter((o) => !o.error).length;
132
+ const mark = p.status === "complete" ? "✓" : p.status === "halted" ? "⚠" : "…";
133
+ console.log(` ${mark} ${p.slug.padEnd(40)} ${p.status.padEnd(9)} ${money(p.totalCost).padStart(8)} ${done}/${p.taskCount} tasks`);
134
+ console.log(` ${p.idea.slice(0, 90)}${p.idea.length > 90 ? "…" : ""}`);
135
+ }
136
+ console.log(`\n ${list.length} project${list.length === 1 ? "" : "s"} · ${money(list.reduce((a, p) => a + p.totalCost, 0))} all time · ${join(homedir(), "…")}\n`);
137
+ return 0;
138
+ }
139
+
140
+ // ---- models ----
141
+
142
+ function models(): number {
143
+ const providers = availableProviders();
144
+ console.log(`\n Roster as it will run now${providers.length ? ` (keys: ${providers.join(", ")})` : " (no keys — best-of-breed picks shown)"}\n`);
145
+ for (const r of effectiveRoster()) {
146
+ const m = r.model ? getModel(r.model) : undefined;
147
+ const price = m ? `$${m.cost.input}/$${m.cost.output} per 1M` : "";
148
+ console.log(` ${r.label.padEnd(18)} ${(r.model ?? "—").padEnd(28)} ${(r.provider ?? "").padEnd(11)} ${price}`);
149
+ }
150
+ console.log("\n Change in the app: Settings → Models. Prices are from Pi's catalog; actual cost is measured per run.\n");
151
+ return 0;
152
+ }
153
+
154
+ // ---- build ----
155
+
156
+ function ask(question: string): Promise<string> {
157
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
158
+ const { promise, resolve } = Promise.withResolvers<string>();
159
+ rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
160
+ return promise;
161
+ }
162
+
163
+ async function build(argv: Argv): Promise<number> {
164
+ const idea = argv.positional.join(" ").trim();
165
+ const json = argv.flags.json === true;
166
+ const yes = json || argv.flags.yes === true || argv.flags.y === true;
167
+ const dry = argv.flags["dry-run"] === true;
168
+ const emit = (o: Record<string, unknown>) => { if (json) process.stdout.write(JSON.stringify(o) + "\n"); };
169
+ const say = (s: string) => { if (!json) console.log(s); };
170
+
171
+ if (!idea) { console.error(" build: give an idea, e.g. projectinator build \"a tip calculator\""); return 2; }
172
+
173
+ let providers = availableProviders();
174
+ const lock = argv.flags.provider;
175
+ if (typeof lock === "string") {
176
+ if (!(lock in PROVIDER_LABEL)) { console.error(` build: unknown provider "${lock}" (anthropic | openai | google | openrouter)`); return 2; }
177
+ if (!providers.includes(lock as Provider)) { console.error(` build: no key for ${lock} — export ${ENV_VAR[lock as Provider]} or set it in the app`); return 1; }
178
+ providers = [lock as Provider];
179
+ }
180
+ if (!providers.length) { console.error(" build: no API key found. Run `projectinator doctor`."); return 1; }
181
+
182
+ const prefs = getPrefs();
183
+ const budget = num(argv.flags.budget, prefs.budgetCapUSD);
184
+ const concurrency = Math.max(1, Math.floor(num(argv.flags.concurrency, prefs.concurrency)));
185
+ const taskLimits = {
186
+ timeoutMs: num(argv.flags["task-timeout"], prefs.taskTimeoutMin) * 60_000,
187
+ costCapUSD: num(argv.flags["task-cap"], prefs.taskCostCapUSD),
188
+ };
189
+
190
+ say(`\n Planning with the PM (${providers.join("/")})…`);
191
+ const plan = await planBuild(idea, providers);
192
+ emit({ event: "plan", provider: plan.provider, modelId: plan.modelId, estCost: plan.estCost, tasks: plan.tasks });
193
+ say(`\n ${plan.tasks.length} tasks · estimated ${money(plan.estCost)} · cap ${money(budget)}${plan.lock ? ` · locked to ${plan.lock}` : ""}\n`);
194
+ for (const t of plan.tasks) {
195
+ const dep = t.dependsOn?.length ? ` ← ${t.dependsOn.join(", ")}` : "";
196
+ say(` ${t.id.padEnd(6)} ${t.capability.padEnd(7)} ${t.difficulty.padEnd(8)} ${t.title}${dep}`);
197
+ }
198
+ if (plan.estCost > budget) say(`\n ⚠ Estimate exceeds the cap — the build may halt partway.`);
199
+
200
+ if (dry) { say("\n Dry run — nothing built (the PM call above was the only spend).\n"); return 0; }
201
+ if (!yes) {
202
+ const a = await ask(`\n Build it for ~${money(plan.estCost)}? [y/N] `);
203
+ if (!/^y(es)?$/i.test(a)) { say(" Cancelled.\n"); return 0; }
204
+ }
205
+
206
+ const onEvent = (e: OrchestratorEvent) => {
207
+ emit({ event: e.type, ...e });
208
+ if (json) return;
209
+ if (e.type === "task_start") console.log(` ▶ ${e.task.id} [${e.task.capability}] → ${e.provider}/${e.modelId}${e.round ? ` (round ${e.round})` : ""}`);
210
+ else if (e.type === "task_done") console.log(` ✓ ${e.outcome.taskId} ${money(e.outcome.cost)} running ${money(e.runningTotal)}${e.outcome.verdict ? ` ${e.outcome.verdict.passed ? (e.outcome.verdict.runtimeChecked || e.outcome.capability !== "test" ? "PASS" : "PASS* (app not executed)") : "FAIL"}` : ""}`);
211
+ else if (e.type === "task_failed") console.log(` ⛔ ${e.outcome.taskId} aborted: ${e.outcome.error} — billed ${money(e.outcome.cost)}`);
212
+ else if (e.type === "task_skipped") console.log(` · ${e.taskId} skipped`);
213
+ else if (e.type === "test_failed") console.log(` ✗ ${e.taskId} failed (${e.bugs} bugs) — round ${e.round}`);
214
+ else if (e.type === "retry_dev") console.log(` ↻ re-running ${e.taskId} to fix ${e.forTest}`);
215
+ else if (e.type === "budget_halt") console.log(` ⚠ budget halt at ${money(e.runningTotal)} (cap ${money(e.cap)})`);
216
+ else if (e.type === "cycle_or_error") console.log(` ✗ ${e.message}`);
217
+ };
218
+
219
+ say(`\n Building…\n`);
220
+ const handle = startBuild(idea, plan, { concurrency, budgetCapUSD: budget, taskLimits, onEvent, mode: "auto" });
221
+ const r = await handle.promise;
222
+ emit({ event: "done", halted: r.halted, haltReason: r.haltReason, totalCost: r.totalCost, files: r.files, workspace: handle.workspace });
223
+ say(`\n ${r.halted ? `⚠ Halted (${r.haltReason ?? "?"})` : "✓ Complete"} · ${money(r.totalCost)} · ${r.files.length} file${r.files.length === 1 ? "" : "s"}`);
224
+ say(` ${handle.workspace}\n`);
225
+ return r.halted ? 3 : 0;
226
+ }
227
+
228
+ // ---- main ----
229
+
230
+ const USAGE = `Usage: projectinator <command> [options]
231
+
232
+ doctor check Node, API keys, Chromium, Pi catalog, git
233
+ build "<idea>" [options] plan + build headless (same workspace as the app)
234
+ --dry-run plan and estimate only (spends one PM call)
235
+ --yes, -y don't ask before building
236
+ --json NDJSON events on stdout (implies --yes)
237
+ --budget <usd> cap for this build (default: your prefs)
238
+ --provider <name> anthropic|openai|google|openrouter (default: prefs / keys)
239
+ --concurrency <n> tasks at once (default: your prefs)
240
+ --task-cap <usd> per-task cost ceiling (default: your prefs; 0 = off)
241
+ --task-timeout <min> per-task timeout (default: your prefs; 0 = off)
242
+ projects list past builds with status and cost
243
+ models the roster as it will run, with prices
244
+
245
+ Exit codes: 0 ok · 1 environment problem · 2 bad usage · 3 build halted`;
246
+
247
+ export async function main(args: string[]): Promise<number> {
248
+ applyKeysToEnv();
249
+ const argv = parseArgv(args);
250
+ const cmd = argv.positional.shift();
251
+ switch (cmd) {
252
+ case "doctor": return doctor();
253
+ case "projects": return projects();
254
+ case "models": return models();
255
+ case "build": return build(argv);
256
+ case undefined: case "help": console.log(USAGE); return cmd ? 0 : 2;
257
+ default: console.error(`projectinator: unknown command "${cmd}".\n\n${USAGE}`); return 2;
258
+ }
259
+ }
260
+
261
+ if (process.argv[1] && import.meta.url === new URL(process.argv[1], "file://").href) {
262
+ process.exitCode = await main(process.argv.slice(2));
263
+ }