rewritable 0.1.0 → 0.5.0

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/src/commands.mjs CHANGED
@@ -1,17 +1,24 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { spawn } from 'node:child_process';
4
5
  import crypto from 'node:crypto';
5
6
 
6
- import { loadSeed, applySeedSubs, replaceInlineDoc } from './seed.mjs';
7
+ import { loadSeed, applySeedSubs, replaceInlineDoc, extractInlineDoc, kindOverrides, KNOWN_KINDS } from './seed.mjs';
8
+ import { skinByName } from './skins.mjs';
9
+ import { resolveBareWord } from './template.mjs';
7
10
  import { convert } from './import.mjs';
11
+ import { convertPdfViaVision } from './import-vision.mjs';
12
+ import { convertViaClaudeCli } from './import-claude.mjs';
8
13
 
9
14
  const here = path.dirname(fileURLToPath(import.meta.url));
10
15
  const packageRoot = path.dirname(here);
11
16
 
12
17
  // Look in the in-package copy first (published case), fall back to the
13
- // repo-canonical seed (dev case where cli/ sits next to seeds/).
14
- const SEED_CANDIDATES = [
18
+ // repo-canonical seed (dev case where cli/ sits next to seeds/). Exported so
19
+ // the `rwa edit` instruction path can extract SYSTEM_PROMPTS/TOOL_SCHEMAS
20
+ // from the same seed `rwa new`/`rwa import` use — single source of truth.
21
+ export const SEED_CANDIDATES = [
15
22
  path.join(packageRoot, 'seeds', 'rewritable.html'),
16
23
  path.join(packageRoot, '..', 'seeds', 'rewritable.html'),
17
24
  ];
@@ -53,22 +60,180 @@ function rel(p) {
53
60
  return r || p;
54
61
  }
55
62
 
56
- export async function newCmd({ outPath, force }) {
57
- const out = path.resolve(outPath || './rewritable.html');
63
+ // Parse a single var out of a .env-style file. Minimal — handles KEY=value,
64
+ // surrounding whitespace, optional matched single/double quotes, leading `export`.
65
+ // Skips blank/comment lines. No interpolation, no multiline values.
66
+ async function readEnvKey(name) {
67
+ if (process.env[name]) return process.env[name];
68
+ let text;
69
+ try {
70
+ text = await fs.readFile(path.join(process.cwd(), '.env'), 'utf8');
71
+ } catch (_) { return null; }
72
+ for (const line of text.split('\n')) {
73
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
74
+ if (!m || m[1] !== name) continue;
75
+ let v = m[2];
76
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
77
+ v = v.slice(1, -1);
78
+ }
79
+ return v || null;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // Validate-and-return a backend name. Returns null for invalid input rather
85
+ // than throwing — pre-fill is best-effort; an unknown value just means the
86
+ // user sees the default backend (openrouter) on first paint.
87
+ function validBackend(v) {
88
+ return ['openrouter', 'ollama', 'lmstudio', 'bridge'].includes(v) ? v : null;
89
+ }
90
+
91
+ // Collect URL-param pre-fills from env / ./.env. Returns an object whose keys
92
+ // match the URL params the bootstrap lifts (key, backend, model). Missing or
93
+ // invalid values are omitted; the bootstrap falls back to its defaults.
94
+ async function collectPrefill() {
95
+ const out = {};
96
+ const key = await readEnvKey('OPENROUTER_API_KEY');
97
+ const backend = validBackend(await readEnvKey('RWA_BACKEND'));
98
+ const model = await readEnvKey('RWA_MODEL');
99
+ if (key) out.key = key;
100
+ if (backend) out.backend = backend;
101
+ if (model) out.model = model;
102
+ return out;
103
+ }
104
+
105
+ function openFile(target, prefill) {
106
+ // When any prefill is present we open via a file:// URL with the params so
107
+ // the bootstrap can lift them into sessionStorage on first paint and scrub
108
+ // the URL bar via history.replaceState. Without any prefill we use the bare
109
+ // path so the open command is byte-identical to before.
110
+ let arg;
111
+ const params = prefill || {};
112
+ const hasAny = params.key || params.backend || params.model;
113
+ if (hasAny) {
114
+ const u = pathToFileURL(target);
115
+ if (params.key) u.searchParams.set('key', params.key);
116
+ if (params.backend) u.searchParams.set('backend', params.backend);
117
+ if (params.model) u.searchParams.set('model', params.model);
118
+ arg = u.toString();
119
+ } else {
120
+ arg = target;
121
+ }
122
+ let cmd, args;
123
+ if (process.platform === 'darwin') {
124
+ cmd = 'open'; args = [arg];
125
+ } else if (process.platform === 'win32') {
126
+ cmd = 'cmd'; args = ['/c', 'start', '""', arg];
127
+ } else {
128
+ cmd = 'xdg-open'; args = [arg];
129
+ }
130
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
131
+ child.on('error', err => {
132
+ console.error(`note: could not open file (${err.code || err.message})`);
133
+ });
134
+ child.unref();
135
+ }
136
+
137
+ // Open a freshly-written container, lifting env / .env prefills into the
138
+ // file:// URL (key/backend/model) exactly as the new/import open paths do.
139
+ // Exported so `rwa create` can honor --open without duplicating openFile +
140
+ // collectPrefill. (newCmd/importCmd keep their inline blocks unchanged.)
141
+ export async function openWithPrefill(out) {
142
+ const prefill = await collectPrefill();
143
+ if (prefill.key) console.error('note: passing OPENROUTER_API_KEY via ?key= URL parameter');
144
+ if (prefill.backend) console.error(`note: passing RWA_BACKEND=${prefill.backend} via ?backend= URL parameter`);
145
+ if (prefill.model) console.error(`note: passing RWA_MODEL=${prefill.model} via ?model= URL parameter`);
146
+ openFile(out, prefill);
147
+ }
148
+
149
+ export async function newCmd({ outPath, force, open, kind, templateName, skin }) {
150
+ // Two body sources funnel through one seed-subs path. Default: a built-in
151
+ // starter (kindOverrides). `templateName` set: clone a data-rwa-template-labeled
152
+ // file from cwd — pristine seed + the template's INLINE_DOC (label stripped),
153
+ // fresh UUID. A cloned instance is a document with the template's body (the
154
+ // template `kind` is a discovery label, not a PRODUCT_KIND).
155
+ let out, bodyOverride, fromMsg = '';
156
+ let resolvedKind = kind || 'document';
157
+ if (templateName) {
158
+ // Template-first, kind-fallback (design 2026-05-31 §3.2), via the ONE resolver
159
+ // shared with `rwa create`: a bare word is first a cwd template label to clone;
160
+ // on a miss, if it names a built-in kind, emit that kind; otherwise error naming
161
+ // both misses. A user's labeled file thus overrides the built-in starter, and
162
+ // `rwa new presentation` makes the deck.
163
+ const frame = await resolveBareWord(templateName, process.cwd());
164
+ const dated = `./${templateName}-${new Date().toISOString().slice(0, 10)}.html`;
165
+ if (frame && frame.source === 'template') {
166
+ if (frame.ambiguous) console.error(`note: multiple "${templateName}" templates in ./; using ${rel(frame.templatePath)} (most recent)`);
167
+ out = path.resolve(outPath || dated);
168
+ bodyOverride = frame.body; // already label-stripped by the resolver
169
+ resolvedKind = 'document';
170
+ fromMsg = ` (from template ${rel(frame.templatePath)})`;
171
+ } else if (frame && frame.source === 'kind') {
172
+ resolvedKind = frame.kind;
173
+ out = path.resolve(outPath || dated);
174
+ // bodyOverride stays unset → kindOverrides(resolvedKind) supplies the body.
175
+ } else {
176
+ const e = new Error(`no rwa file in ./ is labeled "${templateName}", and "${templateName}" is not a known kind (${KNOWN_KINDS.join(', ')}). Add data-rwa-template="${templateName}" to a doc's root element to make it a template, or use a known kind.`);
177
+ e.exitCode = 2;
178
+ throw e;
179
+ }
180
+ } else {
181
+ out = path.resolve(outPath || './rewritable.html');
182
+ }
58
183
  await ensureWritable(out, force);
59
184
  const seed = await loadSeed(SEED_CANDIDATES);
60
185
  const fileMeta = path.basename(out);
61
186
  const title = titleFromBasename(path.basename(out, path.extname(out)));
62
- const result = applySeedSubs(seed, {
187
+ // R9-minimal: kind defaults to 'document' (current behavior — no overrides
188
+ // applied, byte-identical to pre-flag emit). For other kinds, kindOverrides
189
+ // supplies the INLINE_DOC body and lens placeholder; SYSTEM_PROMPT is
190
+ // intentionally left alone (audit R1).
191
+ const overrides = kindOverrides(resolvedKind);
192
+ let result = applySeedSubs(seed, {
63
193
  uuid: crypto.randomUUID(),
64
194
  title,
65
195
  fileMeta,
196
+ lensPlaceholder: overrides.lensPlaceholder,
197
+ palPlaceholder: overrides.palPlaceholder,
198
+ productHeader: overrides.productHeader,
199
+ productKind: resolvedKind, // audit R1
200
+ lensClickToAnchor: overrides.lensClickToAnchor, // audit R3 scoped
66
201
  });
202
+ let body = bodyOverride != null ? bodyOverride : overrides.body;
203
+ // --skin: prepend the preset's <style data-rwa-skin> block as the leading child
204
+ // of INLINE_DOC. Skin is orthogonal to kind (a skinned document/presentation),
205
+ // and the inject runs AFTER applySeedSubs (the `rwa import` ordering lesson) so
206
+ // the skin CSS can't false-match a substitution regex. Deterministic, offline,
207
+ // model-free — the L1 restyle is a later phase. skinByName throws exit-2 on an
208
+ // unknown name (caught by the bin's outer handler).
209
+ if (skin) {
210
+ const { theme } = skinByName(skin);
211
+ const base = body != null ? body : extractInlineDoc(result);
212
+ body = theme + '\n' + base;
213
+ }
214
+ if (body != null) result = replaceInlineDoc(result, body);
67
215
  await fs.writeFile(out, result, 'utf8');
68
- console.log(`wrote ${rel(out)}`);
216
+ // Annotate with the resolved kind (covers both `--kind presentation` and the
217
+ // bare-word `rwa new presentation` fallback); a template clone reports its source.
218
+ const kindMsg = resolvedKind !== 'document' ? ` (kind: ${resolvedKind})` : '';
219
+ console.log(`wrote ${rel(out)}${fromMsg || kindMsg}`);
220
+ if (open) {
221
+ const prefill = await collectPrefill();
222
+ if (prefill.key) console.error('note: passing OPENROUTER_API_KEY via ?key= URL parameter');
223
+ if (prefill.backend) console.error(`note: passing RWA_BACKEND=${prefill.backend} via ?backend= URL parameter`);
224
+ if (prefill.model) console.error(`note: passing RWA_MODEL=${prefill.model} via ?model= URL parameter`);
225
+ openFile(out, prefill);
226
+ }
69
227
  }
70
228
 
71
- export async function importCmd({ inputPath, outPath, force }) {
229
+ export { KNOWN_KINDS };
230
+
231
+ export async function importCmd({ inputPath, outPath, force, open, vision, claude, trustInput, model, timeoutSec }) {
232
+ if (vision && claude) {
233
+ const e = new Error('--vision and --claude are mutually exclusive');
234
+ e.exitCode = 2;
235
+ throw e;
236
+ }
72
237
  const input = path.resolve(inputPath);
73
238
  const inputDir = path.dirname(input);
74
239
  const inputBasename = path.basename(input, path.extname(input));
@@ -76,8 +241,32 @@ export async function importCmd({ inputPath, outPath, force }) {
76
241
  await ensureWritable(out, force);
77
242
 
78
243
  const ext = path.extname(input).toLowerCase().replace(/^\./, '');
79
- const contents = await fs.readFile(input, 'utf8');
80
- const { html, warnings } = await convert(ext, contents);
244
+ let html, warnings;
245
+ if (vision) {
246
+ if (ext !== 'pdf') {
247
+ const e = new Error(`--vision is currently only supported for .pdf (got .${ext})`);
248
+ e.exitCode = 2;
249
+ throw e;
250
+ }
251
+ console.error('note: vision: posting to openrouter…');
252
+ // Buffer for HTTP base64 encoding.
253
+ const contents = await fs.readFile(input);
254
+ ({ html, warnings } = await convertPdfViaVision(contents, { model }));
255
+ } else if (claude) {
256
+ if (trustInput) {
257
+ console.error(`note: claude: --trust-input set — running the agent with bypassPermissions on ${path.basename(input)}. Only safe for files you trust.`);
258
+ }
259
+ // Pass the path; the skill reads the file itself via its own tools.
260
+ // trustInput gates the bypassPermissions agent (see import-claude.mjs); the
261
+ // consent gate there throws with exitCode 2 when it is absent.
262
+ const claudeOpts = { trustInput, ...(timeoutSec ? { timeoutMs: timeoutSec * 1000 } : {}) };
263
+ ({ html, warnings } = await convertViaClaudeCli(input, ext, claudeOpts));
264
+ } else {
265
+ // Buffer (not utf8 string) — docx and pdf are binary, and text formats
266
+ // decode internally inside convert().
267
+ const contents = await fs.readFile(input);
268
+ ({ html, warnings } = await convert(ext, contents));
269
+ }
81
270
  for (const w of warnings) console.error(`note: ${w}`);
82
271
 
83
272
  const seed = await loadSeed(SEED_CANDIDATES);
@@ -97,4 +286,11 @@ export async function importCmd({ inputPath, outPath, force }) {
97
286
  const result = replaceInlineDoc(subbed, html);
98
287
  await fs.writeFile(out, result, 'utf8');
99
288
  console.log(`wrote ${rel(out)}`);
289
+ if (open) {
290
+ const prefill = await collectPrefill();
291
+ if (prefill.key) console.error('note: passing OPENROUTER_API_KEY via ?key= URL parameter');
292
+ if (prefill.backend) console.error(`note: passing RWA_BACKEND=${prefill.backend} via ?backend= URL parameter`);
293
+ if (prefill.model) console.error(`note: passing RWA_MODEL=${prefill.model} via ?model= URL parameter`);
294
+ openFile(out, prefill);
295
+ }
100
296
  }
package/src/create.mjs ADDED
@@ -0,0 +1,256 @@
1
+ // `rwa create <task…>` (and the `draft` alias) — scaffold + agent-fill into a
2
+ // SELF-CONTAINED rewritable in one shot (design 2026-05-31 §4). The task is a CLI
3
+ // INPUT, never a file capability: the CLI bakes the generated content (and any
4
+ // --data) into the INLINE_DOC snapshot, and the emitted file is thereafter an
5
+ // ordinary, dependency-free rewritable. Recurrence = re-run the CLI.
6
+ //
7
+ // Pipeline (§4.6): scaffold in memory → runAgentLoop (authoring) → apply the
8
+ // envelope to a temp file → assertSelfContained → write ONCE atomically. Nothing
9
+ // is written to the destination unless the whole pipeline succeeds, so a failed
10
+ // run never leaves a half-baked file on disk.
11
+
12
+ import crypto from 'node:crypto';
13
+ import path from 'node:path';
14
+ import { readFile, rm, stat } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { loadSeed, applySeedSubs, replaceInlineDoc, extractInlineDoc, kindOverrides, KNOWN_KINDS } from './seed.mjs';
17
+ import { resolveBareWord } from './template.mjs';
18
+ import { extractFromSeed } from './seed-extract.mjs';
19
+ import { runAgentLoop } from './agent-loop.mjs';
20
+ import { applyPlan, CliError } from './edit.mjs';
21
+ import { assertSelfContained } from './self-contained.mjs';
22
+ import { findFrozenZones } from './apply-edits.mjs';
23
+ import { resolveApiKey, envBaseUrl } from './backend.mjs';
24
+ import { atomicWrite } from './atomic-write.mjs';
25
+
26
+ // Hard cap on --data baked into the snapshot. The dataset lands inside INLINE_DOC
27
+ // of a single self-contained file the user will ship; an unbounded paste would
28
+ // bloat the artifact and the model context. Over the cap → fail loud (§4.3).
29
+ const DATA_CAP = 200_000;
30
+
31
+ const VALUE_FLAGS = new Set([
32
+ '--kind', '--from', '--data', '--out',
33
+ '--backend', '--model', '--base-url', '--api-key',
34
+ ]);
35
+
36
+ /**
37
+ * Parse `rwa create` argv into flags + the positional task words. Pure: no IO,
38
+ * no kind resolution (that is resolveBareWord's job, §4.2 Stage 1). A value
39
+ * flag's argument is never collected as a task word.
40
+ *
41
+ * @param {string[]} argv — args after the `create`/`draft` verb
42
+ * @returns {{kind:string|null, from:string|null, data:string|null, out:string|null,
43
+ * force:boolean, open:boolean,
44
+ * backend:{name:string|null, model:string|null, baseUrl:string|null, apiKey:string|null},
45
+ * words:string[]}}
46
+ */
47
+ export function parseCreateArgs(argv) {
48
+ const get = (name) => {
49
+ const i = argv.indexOf(name);
50
+ return i >= 0 ? (argv[i + 1] ?? null) : null;
51
+ };
52
+ const words = argv.filter((a, i) => {
53
+ if (a.startsWith('-')) return false; // a flag itself
54
+ if (VALUE_FLAGS.has(argv[i - 1])) return false; // a value-flag's argument
55
+ return true;
56
+ });
57
+ return {
58
+ kind: get('--kind'),
59
+ from: get('--from'),
60
+ data: get('--data'),
61
+ out: get('--out'),
62
+ force: argv.includes('--force') || argv.includes('-f'),
63
+ open: argv.includes('--open') || argv.includes('-o'),
64
+ backend: {
65
+ name: get('--backend'),
66
+ model: get('--model'),
67
+ baseUrl: get('--base-url'),
68
+ apiKey: get('--api-key'),
69
+ },
70
+ words,
71
+ };
72
+ }
73
+
74
+ // The create-only generation contract (design §4.5): output must run with ZERO
75
+ // external runtime dependencies. Appended to whichever per-kind system prompt the
76
+ // resolved frame selects — this is CLI-exclusive framing, never shipped in the
77
+ // seed bytes. assertSelfContained (below) is the code-level tripwire behind it.
78
+ const SELF_CONTAINMENT_DIRECTIVE = `
79
+
80
+ CRITICAL — the document you produce MUST be fully self-contained and run with NO external runtime dependencies:
81
+ - Do NOT reference any external URL: no <script src=...> to a CDN, no <link href=...> stylesheet, no remote <img>, no @import or url() pointing off-document. Everything is inlined.
82
+ - For any chart/graph/visualization, hand-roll it with inline <svg> or <canvas> + plain JavaScript. Do NOT use D3, Chart.js, or any library.
83
+ - Embed every piece of data directly in the document (e.g. a <script type="application/json"> island or a JS const). Never fetch data at runtime.
84
+ - Produce the COMPLETE document for the request — this is authoring from a starter, so a wholesale replace_document is appropriate.`;
85
+
86
+ // Mirror of commands.mjs titleFromBasename (kept local — create.mjs is a peer of
87
+ // commands.mjs, not a dependent). Filename → Title Case, with a safe fallback.
88
+ function titleFromBasename(basename) {
89
+ return basename
90
+ .replace(/[-_]+/g, ' ')
91
+ .split(' ')
92
+ .filter(Boolean)
93
+ .map(w => w[0].toUpperCase() + w.slice(1))
94
+ .join(' ') || 'Untitled';
95
+ }
96
+
97
+ function rel(p, cwd) {
98
+ const r = path.relative(cwd, p);
99
+ return r || p;
100
+ }
101
+
102
+ // Resolve the creation FRAME — kind + scaffold body + the brief words — from the
103
+ // parsed args (design §4.2). --kind wins and disables leading-word detection; an
104
+ // explicit kind keeps the full word list as the brief. Otherwise the leading word
105
+ // is matched template-first via resolveBareWord (the SAME resolver `rwa new` uses,
106
+ // so the two surfaces never diverge), and that word is consumed from the brief.
107
+ // A silent frame (no kind, no template match) defaults to document with the whole
108
+ // word list as the brief — Stage 2 model inference is deferred to v2 (§9.2).
109
+ async function resolveFrame(parsed, cwd) {
110
+ if (parsed.kind) {
111
+ if (!KNOWN_KINDS.includes(parsed.kind)) {
112
+ throw new CliError(1, 'unknown_kind', { kind: parsed.kind, known: KNOWN_KINDS });
113
+ }
114
+ return { kind: parsed.kind, scaffoldBody: kindOverrides(parsed.kind).body, briefWords: parsed.words, fromMsg: '' };
115
+ }
116
+ const lead = parsed.words[0];
117
+ const frame = lead ? await resolveBareWord(lead, cwd) : null;
118
+ if (frame && frame.source === 'template') {
119
+ return {
120
+ kind: 'document', // a cloned instance is a document
121
+ scaffoldBody: frame.body, // already label-stripped by resolveBareWord
122
+ briefWords: parsed.words.slice(1),
123
+ fromMsg: ` (from template ${rel(frame.templatePath, cwd)})`,
124
+ };
125
+ }
126
+ if (frame && frame.source === 'kind') {
127
+ return { kind: frame.kind, scaffoldBody: kindOverrides(frame.kind).body, briefWords: parsed.words.slice(1), fromMsg: '' };
128
+ }
129
+ return { kind: 'document', scaffoldBody: null, briefWords: parsed.words, fromMsg: '' };
130
+ }
131
+
132
+ /**
133
+ * `rwa create` / `rwa draft`: scaffold a fresh container, drive the agent loop to
134
+ * author it, validate self-containment, and write ONCE — atomically. The emitted
135
+ * file is an ordinary self-contained rewritable; the task left no capability in it.
136
+ *
137
+ * @param {object} parsed — parseCreateArgs output
138
+ * @param {object} opts
139
+ * @param {string[]} opts.seedCandidates — seed search paths (loadSeed order)
140
+ * @param {string} [opts.cwd] — base dir for relative paths + template scan
141
+ * @param {string} [opts.stdinData] — content for `--data -` (caller drains stdin)
142
+ * @returns {Promise<{out:string, kind:string, fromMsg:string}>}
143
+ * @throws {CliError} exit 1 usage / 2 file / 3 envelope / 4 agent
144
+ */
145
+ export async function createCmd(parsed, { seedCandidates, cwd = process.cwd(), stdinData } = {}) {
146
+ let { kind, scaffoldBody, briefWords, fromMsg } = await resolveFrame(parsed, cwd);
147
+
148
+ // --from: base the artifact on an existing rewritable's editable body. Reuses
149
+ // the same exit-2 surface (not_found / not_a_rewritable) as the rest of the CLI.
150
+ if (parsed.from) {
151
+ const fromPath = path.resolve(cwd, parsed.from);
152
+ let fromText;
153
+ try {
154
+ fromText = await readFile(fromPath, 'utf8');
155
+ } catch (e) {
156
+ if (e && e.code === 'ENOENT') throw new CliError(2, 'not_found', { path: fromPath });
157
+ throw new CliError(2, 'read_error', { path: fromPath, errno: e && e.code, message: e && e.message });
158
+ }
159
+ try {
160
+ scaffoldBody = extractInlineDoc(fromText);
161
+ } catch {
162
+ throw new CliError(2, 'not_a_rewritable', { path: fromPath });
163
+ }
164
+ fromMsg = ` (from ${rel(fromPath, cwd)})`;
165
+ }
166
+
167
+ // --data: read the dataset to bake into the brief. `-` reads stdin (drained by
168
+ // the caller). Never fetched at runtime; embedded inline by the agent (§4.3).
169
+ let dataContent = null;
170
+ if (parsed.data === '-') {
171
+ dataContent = stdinData == null ? '' : stdinData;
172
+ } else if (parsed.data) {
173
+ const dataPath = path.resolve(cwd, parsed.data);
174
+ try {
175
+ dataContent = await readFile(dataPath, 'utf8');
176
+ } catch (e) {
177
+ if (e && e.code === 'ENOENT') throw new CliError(2, 'not_found', { path: dataPath });
178
+ throw new CliError(2, 'read_error', { path: dataPath, errno: e && e.code, message: e && e.message });
179
+ }
180
+ }
181
+ if (dataContent != null && dataContent.length > DATA_CAP) {
182
+ throw new CliError(1, 'data_too_large', { bytes: dataContent.length, cap: DATA_CAP });
183
+ }
184
+
185
+ // Output path + clobber guard (matches new/import's --force semantics).
186
+ const dated = `./${kind}-${new Date().toISOString().slice(0, 10)}.html`;
187
+ const out = path.resolve(cwd, parsed.out || dated);
188
+ try {
189
+ await stat(out);
190
+ if (!parsed.force) throw new CliError(2, 'dest_exists', { path: out });
191
+ } catch (e) {
192
+ if (e instanceof CliError) throw e;
193
+ // ENOENT is the happy path (file doesn't exist yet); anything else is a real
194
+ // stat error worth surfacing.
195
+ if (!(e && e.code === 'ENOENT')) throw new CliError(2, 'read_error', { path: out, errno: e && e.code });
196
+ }
197
+
198
+ // Build the scaffold in memory — identical subs flow to newCmd.
199
+ const seed = await loadSeed(seedCandidates);
200
+ const overrides = kindOverrides(kind);
201
+ let scaffold = applySeedSubs(seed, {
202
+ uuid: crypto.randomUUID(),
203
+ title: titleFromBasename(path.basename(out, path.extname(out))),
204
+ fileMeta: path.basename(out),
205
+ lensPlaceholder: overrides.lensPlaceholder,
206
+ palPlaceholder: overrides.palPlaceholder,
207
+ productHeader: overrides.productHeader,
208
+ productKind: kind,
209
+ lensClickToAnchor: overrides.lensClickToAnchor,
210
+ });
211
+ const body = scaffoldBody != null ? scaffoldBody : overrides.body;
212
+ if (body != null) scaffold = replaceInlineDoc(scaffold, body);
213
+ const scaffoldDoc = extractInlineDoc(scaffold);
214
+
215
+ // Backend: flag → env → default. The key is used ONLY for the model call here;
216
+ // it is never written into the artifact (the file carries content, not creds).
217
+ const backendName = parsed.backend.name || process.env.RWA_BACKEND || 'openrouter';
218
+ const backend = {
219
+ baseUrl: parsed.backend.baseUrl || envBaseUrl(backendName),
220
+ model: parsed.backend.model || process.env.RWA_MODEL || 'google/gemini-3.5-flash',
221
+ apiKey: resolveApiKey(backendName, parsed.backend.apiKey),
222
+ };
223
+
224
+ // Per-kind system prompt + the create-only self-containment directive; the brief
225
+ // carries any --data inline so the agent embeds it (never fetches).
226
+ const { SYSTEM_PROMPTS, TOOL_SCHEMAS } = extractFromSeed(seed);
227
+ const systemPrompt = (SYSTEM_PROMPTS[kind] || SYSTEM_PROMPTS.document) + SELF_CONTAINMENT_DIRECTIVE;
228
+ const frozenZoneNames = findFrozenZones(scaffoldDoc).map(z => z.name);
229
+ let instruction = briefWords.join(' ').trim() || `Author a complete ${kind} for this document.`;
230
+ if (dataContent != null) {
231
+ instruction += `\n\nUse this data — embed it inline in the document, do NOT fetch it at runtime:\n${dataContent}`;
232
+ }
233
+
234
+ let result;
235
+ try {
236
+ result = await runAgentLoop({ systemPrompt, toolSchemas: TOOL_SCHEMAS, currentDoc: scaffoldDoc, instruction, frozenZoneNames, backend });
237
+ } catch (e) {
238
+ throw new CliError(4, e.subcode || 'agent_error', e.details || { message: e && e.message });
239
+ }
240
+
241
+ // Atomicity (§4.6): apply + validate against a TEMP file, never the destination.
242
+ // The destination is written exactly once, only after self-containment passes —
243
+ // so any failure (envelope, frozen-zone, external-ref) leaves --out untouched.
244
+ const tmp = path.join(tmpdir(), `rwa-create-${crypto.randomUUID()}.html`);
245
+ try {
246
+ await atomicWrite(tmp, scaffold);
247
+ await applyPlan(tmp, result.envelope); // throws CliError on envelope/frozen issues
248
+ const filled = await readFile(tmp, 'utf8');
249
+ assertSelfContained(extractInlineDoc(filled)); // throws CliError(4) → out untouched
250
+ await atomicWrite(out, filled);
251
+ } finally {
252
+ await rm(tmp, { force: true });
253
+ }
254
+
255
+ return { out, kind, fromMsg };
256
+ }
package/src/doc.mjs ADDED
@@ -0,0 +1,69 @@
1
+ // Read-path entry for `rwa doc` — the counterpart to `rwa edit`'s applyPlan.
2
+ // Where applyPlan WRITES the editable body of a rewritable, inspectDoc READS
3
+ // it: it returns the exact LF-canonical text the rwa-edit contract operates
4
+ // on, plus the metadata an agent needs to edit safely (uuid, product kind,
5
+ // frozen-zone names).
6
+ //
7
+ // Error surface mirrors edit.mjs so callers dedupe file-error handling across
8
+ // read and write:
9
+ // exitCode 2 / subcode: 'not_found', 'read_error', 'not_a_rewritable'
10
+
11
+ import { readFile } from 'node:fs/promises';
12
+ import { extractInlineDoc } from './seed.mjs';
13
+ import { findFrozenZones } from './apply-edits.mjs';
14
+ import { resolveSelfDescription } from './identity.mjs';
15
+ import { CliError } from './edit.mjs';
16
+
17
+ // The bootstrap bakes both consts at emit time (cli/src/seed.mjs applySeedSubs).
18
+ // Reading them back is how we recover identity (uuid) and editing framing
19
+ // (kind) without a full HTML parse. Patterns mirror seed.mjs UUID_RE /
20
+ // PRODUCT_KIND_RE and rwa.mjs detectProductKind — keep them in step.
21
+ const UUID_RE = /const DOC_UUID = '([0-9a-f-]{36})';/;
22
+ const PRODUCT_KIND_RE = /const PRODUCT_KIND = '([^']*)';/;
23
+
24
+ /**
25
+ * Read a rewritable's editable document body, contract metadata, and the
26
+ * `self-description/1` projection (the "what is this?" surface, computed from the
27
+ * bytes — kind/affordances/title/blocks/baseline). The projection applies the
28
+ * v1.1 precedence (declared > static): a trustworthy embedded #rwa-affordances
29
+ * declaration (edit-unreachable) wins over the kind-template guess
30
+ * (`source:"declared"`); otherwise the static kind-derived projection
31
+ * (`source:"static"`). No `live` block (the CLI executes no JS). See
32
+ * ./identity.mjs and docs/specs/rwa-self-description-spec.md §3.1.
33
+ *
34
+ * @param {string} filePath — path to the target .html
35
+ * @returns {Promise<{doc: string, uuid: string|null, kind: string, frozenZones: string[], self: object}>}
36
+ * @throws {CliError} exitCode 2 on file / non-rewritable errors
37
+ */
38
+ export async function inspectDoc(filePath) {
39
+ let fileText;
40
+ try {
41
+ fileText = await readFile(filePath, 'utf8');
42
+ } catch (e) {
43
+ if (e && e.code === 'ENOENT') throw new CliError(2, 'not_found', { path: filePath });
44
+ throw new CliError(2, 'read_error', { path: filePath, errno: e && e.code, message: e && e.message });
45
+ }
46
+
47
+ // A plain-text or non-rewritable target throws here — the same gate `rwa
48
+ // edit` uses. Surfacing it as not_a_rewritable gives agents a deterministic
49
+ // "is this a rewritable?" probe (clean non-zero exit, empty stdout).
50
+ let doc;
51
+ try {
52
+ doc = extractInlineDoc(fileText);
53
+ } catch (_e) {
54
+ throw new CliError(2, 'not_a_rewritable', { path: filePath });
55
+ }
56
+
57
+ const uuid = (fileText.match(UUID_RE) || [])[1] || null;
58
+ // Pre-PRODUCT_KIND containers (and any unknown kind) default to 'document',
59
+ // matching how the runtime and `rwa edit` resolve SYSTEM_PROMPTS.
60
+ const kind = (fileText.match(PRODUCT_KIND_RE) || [])[1] || 'document';
61
+ const frozenZones = findFrozenZones(doc).map(z => z.name);
62
+ // The self-description/1 projection — "what is this, what can be done with it".
63
+ // resolveSelfDescription applies the v1.1 precedence (declared > static): a
64
+ // trustworthy embedded #rwa-affordances declaration (edit-unreachable) wins over
65
+ // the kind-template guess; otherwise the static kind-derived projection.
66
+ const self = resolveSelfDescription({ fileText, doc, uuid, kind, frozenZones });
67
+
68
+ return { doc, uuid, kind, frozenZones, self };
69
+ }