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/README.md +263 -5
- package/bin/rwa.mjs +1033 -6
- package/package.json +7 -4
- package/seeds/rewritable.html +6989 -156
- package/src/agent-loop.mjs +155 -0
- package/src/apply-edits.mjs +664 -0
- package/src/atomic-write.mjs +38 -0
- package/src/backend.mjs +43 -0
- package/src/clone-extract.mjs +249 -0
- package/src/clone.mjs +161 -0
- package/src/commands.mjs +207 -11
- package/src/create.mjs +256 -0
- package/src/doc.mjs +69 -0
- package/src/dsl-compiler.mjs +357 -0
- package/src/edit.mjs +300 -0
- package/src/fetch-page.mjs +346 -0
- package/src/host.mjs +126 -0
- package/src/identity.mjs +257 -0
- package/src/import-claude.mjs +360 -0
- package/src/import-vision.mjs +156 -0
- package/src/import.mjs +357 -8
- package/src/ls.mjs +105 -0
- package/src/publish-site.mjs +85 -0
- package/src/publish.mjs +98 -0
- package/src/seed-extract.mjs +40 -0
- package/src/seed.mjs +1399 -6
- package/src/self-contained.mjs +115 -0
- package/src/skill-manifest.mjs +227 -0
- package/src/skin.mjs +350 -0
- package/src/skins.mjs +274 -0
- package/src/template.mjs +109 -0
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
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
|
+
}
|