Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2// recap/cli.mjs — `ac-24` CLI for the aesthetic 24 recap pipeline.
3//
4// The minimum-viable scaffold mirrors `papers/cli.mjs`: a single
5// versioned entrypoint that owns the surface area used by the oven's
6// recap-builder.mjs and (later) by anyone running the pipeline locally.
7// For now it's a thin wrapper around the existing `bin/*` scripts +
8// `pipeline.fish` — every step's caching lives inside its own script
9// (tts.mjs, transcribe.mjs, align.mjs, jeffrey-photos.mjs, etc.) and
10// will keep getting tightened. See feedback memory:
11// `feedback_recap_ac24_cli.md` for the full vision.
12//
13// Subcommands:
14// ac24 build <audience> — run the full pipeline for an audience
15// ac24 version — print CLI + pipeline version
16// ac24 cache — show per-episode cache state (stub)
17//
18// Usage from the repo:
19// node recap/cli.mjs build jeffrey-73h-2026-05-02
20//
21// On the oven, recap-builder.mjs can switch from `fish pipeline.fish`
22// to `node cli.mjs build <audience>` — same behavior, versioned shell.
23
24import { readFileSync, existsSync, statSync } from "node:fs";
25import { spawn } from "node:child_process";
26import { dirname } from "node:path";
27import { fileURLToPath } from "node:url";
28
29const HERE = dirname(fileURLToPath(import.meta.url));
30const ROOT = HERE; // recap/
31
32const PKG = (() => {
33 try { return JSON.parse(readFileSync(`${ROOT}/package.json`, "utf8")); }
34 catch { return { name: "ac-24", version: "0.0.0" }; }
35})();
36
37const argv = process.argv.slice(2);
38const cmd = argv[0];
39
40// ── help ──────────────────────────────────────────────────────────────
41function usage() {
42 console.log(`ac-24 ${PKG.version} — aesthetic 24 recap pipeline\n`);
43 console.log(`Subcommands:`);
44 console.log(` build <audience> run the full pipeline for an audience`);
45 console.log(` build <audience> --skip-tts reuse existing recap.mp3 (now redundant — tts.mjs auto-skips on hash match)`);
46 console.log(` cache <audience> show per-step cache state for an audience`);
47 console.log(` version print version`);
48 console.log(` help show this`);
49}
50
51// ── build ─────────────────────────────────────────────────────────────
52async function build(audience, extraArgs) {
53 if (!audience) { console.error("✗ build requires an audience name"); process.exit(2); }
54 if (!/^[\w.-]+$/.test(audience)) { console.error(`✗ invalid audience name: ${audience}`); process.exit(2); }
55 const audiencePath = `${ROOT}/audience/${audience}.mjs`;
56 if (!existsSync(audiencePath)) { console.error(`✗ audience config not found: ${audiencePath}`); process.exit(2); }
57
58 // pipeline.fish carries the source of truth for ordering today; this
59 // is a pass-through. When the cache layer lands here, this function
60 // will iterate the steps directly with hashed skip/run decisions.
61 return await new Promise((resolveProm) => {
62 const proc = spawn("fish", ["./pipeline.fish", audience, ...extraArgs], {
63 cwd: ROOT,
64 stdio: "inherit",
65 env: process.env,
66 });
67 proc.on("close", (code) => {
68 process.exitCode = code;
69 resolveProm();
70 });
71 proc.on("error", (err) => {
72 console.error(`✗ failed to spawn pipeline.fish: ${err.message}`);
73 process.exitCode = 1;
74 resolveProm();
75 });
76 });
77}
78
79// ── cache (read-only inspector) ───────────────────────────────────────
80function cacheStatus(audience) {
81 const out = `${ROOT}/out`;
82 const fmt = (p) => {
83 if (!existsSync(p)) return "MISSING";
84 const stat = statSync(p);
85 return `${(stat.size / 1024).toFixed(0)} KB · ${stat.mtime.toISOString()}`;
86 };
87 const items = [
88 [`tts (recap.mp3)`, `${out}/recap.mp3`, `${out}/recap.mp3.hash`],
89 [`transcribe (words.json)`, `${out}/words.json`, `${out}/words.json.hash`],
90 [`align (segments.json)`, `${out}/segments.json`, `${out}/segments.json.hash`],
91 [`subs.json`, `${out}/subs.json`, null],
92 [`subtitle-track.txt`, `${out}/subtitle-track.txt`, null],
93 [`waltz.mp3`, `${out}/waltz.mp3`, null],
94 [`recap.mp4`, `${out}/recap.mp4`, null],
95 ];
96 console.log(`ac-24 cache · ${audience || "(no audience filter)"}`);
97 for (const [label, file, hashFile] of items) {
98 const hash = hashFile && existsSync(hashFile)
99 ? readFileSync(hashFile, "utf8").trim().slice(0, 16)
100 : null;
101 console.log(` ${label.padEnd(26)} ${fmt(file)}${hash ? ` · hash ${hash}` : ""}`);
102 }
103 console.log(``);
104 console.log(` jeffrey-photos: ${(() => {
105 try { return require("node:fs").readdirSync(`${out}/jeffrey-photos`).length + " files"; }
106 catch { return "MISSING"; }
107 })()}`);
108}
109
110// ── dispatch ──────────────────────────────────────────────────────────
111const positional = argv.filter((a) => !a.startsWith("--"));
112const flags = argv.filter((a) => a.startsWith("--"));
113
114switch (cmd) {
115 case "build":
116 await build(positional[1], flags);
117 break;
118 case "cache":
119 cacheStatus(positional[1] || "");
120 break;
121 case "version":
122 console.log(`ac-24 ${PKG.version}`);
123 break;
124 case "help":
125 case "--help":
126 case "-h":
127 case undefined:
128 usage();
129 break;
130 default:
131 console.error(`✗ unknown subcommand: ${cmd}`);
132 usage();
133 process.exit(2);
134}