rewritable 0.3.0 → 0.6.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/bin/rwa.mjs CHANGED
@@ -1,15 +1,102 @@
1
1
  #!/usr/bin/env node
2
- import { newCmd, importCmd, version } from '../src/commands.mjs';
2
+ import { newCmd, importCmd, version, KNOWN_KINDS, openWithPrefill, SEED_CANDIDATES } from '../src/commands.mjs';
3
+ import { resolveApiKey } from '../src/backend.mjs';
4
+ import { parseCreateArgs, createCmd } from '../src/create.mjs';
5
+ import { relative } from 'node:path';
3
6
 
4
7
  const HELP = `rwa — single-file re-writeable documents
5
8
 
6
9
  Usage:
7
10
  rwa new [path] create a fresh rwa document
8
- (default: ./rewritable.html)
11
+ (default: ./rewritable.html, --kind=document)
12
+ rwa new <name> [path] a bare <name> resolves template-first: clone a cwd
13
+ file labeled data-rwa-template="<name>" (fresh UUID,
14
+ label stripped) if one exists; else, if <name> is a
15
+ known kind (document/workflow/presentation/skill-host), create
16
+ that built-in kind. So "rwa new presentation" makes a
17
+ deck, and your own labeled file overrides the builtin.
18
+ Default out: ./<name>-YYYY-MM-DD.html.
9
19
  rwa import <input> [path] convert a md/html/txt file into an rwa document
10
20
  (default: <input-basename>.html, in input's dir)
21
+ rwa clone <url> [path] clone a public webpage into a rewritable (fetches;
22
+ SSRF-guarded). Extracts the main article + title.
23
+ Unlike \`import\`, this REQUIRES the network.
24
+ (default: ./<url-slug>.html)
25
+ rwa create <task...> scaffold + agent-fill a new rewritable from a
26
+ rwa draft <task...> natural-language task, baked into a self-contained
27
+ file. Leading word picks a frame (template/kind)
28
+ like 'rwa new'; the rest is the brief. Flags:
29
+ --kind/--from/--data (- = stdin)/--out plus the
30
+ --backend/--model/--base-url/--api-key backend flags.
31
+ Output is held to a strict no-external-dependency
32
+ bar (exit 4 on a CDN/remote ref). 'draft' = 'create'.
33
+ rwa edit <path> [...] apply a tool-envelope or instruction to a
34
+ rewritable in place. Plan path: pipe an
35
+ apply_edits / apply_dsl_plan / replace_document
36
+ envelope on stdin, or pass --plan <file>.
37
+ Instruction path: pass a plain-text instruction
38
+ as the second positional and the CLI runs the
39
+ agent loop (backend-configurable below).
40
+ rwa doc <path> print the editable document body (the exact
41
+ LF-canonical text the edit contract operates on).
42
+ The read counterpart to \`rwa edit\`. With --json,
43
+ print the self-description/1 superset instead —
44
+ the edit contract plus "what is this, what can be
45
+ done with it": {rwa, kind, title, affordances,
46
+ baseline, frozenZones, …, doc}.
47
+ Exit 2 on a non-rewritable file — a clean
48
+ "is this a rewritable?" probe.
49
+ rwa ls [paths...] list the rewritables in a folder (or file list;
50
+ default: ./), one line each: kind · title ·
51
+ affordances. The "what are all these?" counterpart
52
+ to \`rwa doc\`. Non-rewritables are counted, not
53
+ hidden. With --json, an array of self-description
54
+ rows. Lenient: a completed scan exits 0.
55
+ rwa publish <path> publish a local rewritable to the share service
56
+ and print the hosted URL. POSTs your edited bytes;
57
+ the hosted snapshot is anonymous, 24h, with a fresh
58
+ DOC_UUID. Target: --url > \$RWA_PUBLISH_URL >
59
+ https://rewritable.ikangai.com. --json emits
60
+ {short,url,expiresAt}.
61
+ rwa publish-site <path> scp a rewritable to a static site (needs RWA_SITE_* env)
62
+ rwa host <path> ingest a rewritable into a hosted runtime (POST /r)
63
+ and print {id, token, url}. The network-bearing
64
+ counterpart of \`publish\` for round-trip hosted
65
+ editing — the returned url carries the capability
66
+ token in its #k= fragment. Target: --url >
67
+ \$RWA_HOST_URL. --json emits {id,token,url}.
68
+ rwa skin <path> <name> apply a named style preset to a rewritable in
69
+ place (deterministic, offline, model-free). Names:
70
+ notion-clean, linear-dark, editorial-serif,
71
+ stripe-docs, terminal-mono.
72
+ \`rwa skin <path> reset\` removes the skin (and any
73
+ sk-* wrappers a prior --l1 restyle left). The
74
+ preset's <style data-rwa-skin> block rides inside
75
+ the document, so it ships in the exported file and
76
+ one undo (⌘Z in the app) reverts it. --json emits
77
+ {exitCode,mode,skin}.
78
+ --l1 opts into the agent-driven content-aware
79
+ restyle: the model adds sk-* class hooks/wrappers,
80
+ then theme + wrappers commit together (needs a
81
+ backend — see the --backend flags below; a missing
82
+ backend exits 4). Agent decline degrades to
83
+ theme-only; --json emits {exitCode,mode,skin,degraded}.
11
84
 
12
85
  Flags:
86
+ --kind <name> (new only) starter kind: document (default), workflow,
87
+ presentation, or skill-host. 'document' is the canonical prose
88
+ container. 'workflow' scaffolds three stages (Inbox / In
89
+ progress / Done). 'presentation' scaffolds a prose deck that the
90
+ 'Present' toggle displays as slides (split on h1/h2) without
91
+ changing the stored text. 'skill-host' hosts permission-gated
92
+ skills installed from .rwa-skill.json files (v0.8 actions spec).
93
+ See docs/specs/rwa-product-types.md.
94
+ --skin <name> (new only) bake a style preset into the new container:
95
+ notion-clean, linear-dark, editorial-serif, stripe-docs,
96
+ terminal-mono. Orthogonal to
97
+ --kind (a skinned document or presentation). Deterministic and
98
+ offline; change or remove it later with
99
+ \`rwa skin <file> <name|reset>\`.
13
100
  --force, -f overwrite the destination if it exists
14
101
  --open, -o open the resulting file in the default app. First-paint
15
102
  sessionStorage is pre-populated from env / ./.env:
@@ -26,14 +113,49 @@ Flags:
26
113
  the file using the local pdf/docx skills (Anthropic
27
114
  official). Best fidelity for documents that benefit from
28
115
  skill-driven extraction (multi-column, tables, tracked
29
- changes). Requires the \`claude\` CLI installed and runs
30
- with --permission-mode bypassPermissions; only use on
31
- files you trust.
116
+ changes). Requires the \`claude\` CLI installed. The agent
117
+ reads the file's contents, so a malicious file could
118
+ hijack it: this refuses to run unless you also pass
119
+ --trust-input. (Default import, without --claude, parses
120
+ the file safely and never executes its contents.)
121
+ --trust-input (with --claude) consent to run the extraction agent with
122
+ --permission-mode bypassPermissions on this file. Only use
123
+ on files whose source you trust — prompt-injection text in
124
+ an untrusted file becomes code execution.
32
125
  --model <id> (with --vision) override the OpenRouter model id.
33
- Default: google/gemini-3-flash-preview.
126
+ Default: google/gemini-3.5-flash.
34
127
  --timeout <s> (with --claude) wall-clock cap for the subprocess in
35
128
  seconds. Default: 1200 (20 minutes). Long academic
36
129
  papers may need more.
130
+ --plan <file> (edit only) read the tool-envelope from <file> instead of
131
+ stdin. Use \`--plan -\` to force stdin even when stdin is
132
+ not a pipe.
133
+ --json (edit) emit one JSON object per line on stderr for
134
+ structured failure reporting — each line a single
135
+ \`{code, subcode, details}\` object.
136
+ (doc) emit the editing-contract object on stdout instead of
137
+ the raw body; on failure, the \`{code, subcode, details}\`
138
+ object goes to stderr.
139
+ --backend <n> (edit instruction path / skin --l1) backend name. One of:
140
+ openrouter (default), ollama, lmstudio. Falls back to
141
+ \$RWA_BACKEND if unset.
142
+ --model <id> (edit instruction path / skin --l1) model id passed to the
143
+ backend. Falls back to \$RWA_MODEL, then a
144
+ sensible default for the backend.
145
+ --base-url <u> (edit instruction path / skin --l1) override the OpenAI-
146
+ compatible base URL. Defaults: openrouter →
147
+ https://openrouter.ai/api/v1, ollama →
148
+ http://localhost:11434/v1 (or \$RWA_OLLAMA_URL),
149
+ lmstudio → http://localhost:1234/v1 (or
150
+ \$RWA_LMSTUDIO_URL).
151
+ --api-key <k> (edit instruction path / skin --l1) API key for the backend.
152
+ Openrouter: required, falls back to \$RWA_OPENROUTER_KEY
153
+ then \$OPENROUTER_API_KEY. Other backends ignore this flag.
154
+ --l1 (skin only) opt into the agent-driven content-aware restyle
155
+ (needs a backend; missing backend exits 4). Default skin is
156
+ deterministic theme-only.
157
+ --theme-only (skin only) apply just the preset's deterministic theme block
158
+ — the default behavior — and silence the "theme-only" note.
37
159
  --version print version and exit
38
160
  --help, -h this help
39
161
 
@@ -43,6 +165,103 @@ Supported import formats: .md, .markdown, .html, .htm, .csv, .txt, .docx, .pdf
43
165
  const args = process.argv.slice(2);
44
166
  const verb = args[0];
45
167
 
168
+ // `rwa edit` failure surface — one line per emit. Plain mode: short
169
+ // human-readable string. JSON mode: a single JSON object per line so
170
+ // callers (CI, agent loops) can parse without regex.
171
+ function emitEdit(payload, jsonMode) {
172
+ if (jsonMode) {
173
+ process.stderr.write(JSON.stringify(payload) + '\n');
174
+ } else {
175
+ const parts = [payload.code, payload.subcode].filter(Boolean);
176
+ let line = 'rwa edit: ' + parts.join('/');
177
+ if (payload.details && Object.keys(payload.details).length) {
178
+ line += ' ' + JSON.stringify(payload.details);
179
+ }
180
+ process.stderr.write(line + '\n');
181
+ }
182
+ }
183
+
184
+ // Drain stdin to a UTF-8 string. Used by `rwa edit` when stdin is piped
185
+ // without an explicit --plan file argument.
186
+ function readStdin() {
187
+ return new Promise((resolve, reject) => {
188
+ let buf = '';
189
+ process.stdin.setEncoding('utf8');
190
+ process.stdin.on('data', d => { buf += d; });
191
+ process.stdin.on('end', () => resolve(buf));
192
+ process.stdin.on('error', reject);
193
+ });
194
+ }
195
+
196
+ // Map exit codes to category names for the --json payload's `code` field.
197
+ // Subcodes (specific failure reasons) come from edit.mjs / underlying modules.
198
+ // Throws on unknown codes — Rule 12 (fail loud): a synthetic fallback would
199
+ // mask future programmer bugs (e.g. someone adds `new CliError(5, ...)` and
200
+ // forgets to extend this switch).
201
+ function codeName(n) {
202
+ switch (n) {
203
+ case 1: return 'usage_error';
204
+ case 2: return 'file_error';
205
+ case 3: return 'envelope_error';
206
+ case 4: return 'agent_error';
207
+ default: throw new Error(`codeName: unexpected exit code ${n}`);
208
+ }
209
+ }
210
+
211
+ // Generic single-value flag extractor. Returns `{present, value}` so callers
212
+ // can distinguish "flag absent" (use env / default) from "flag present with a
213
+ // bad value" (must surface usage_error). A bad value is either missing (flag
214
+ // is the last token) or another flag (starts with `-`). Silently falling back
215
+ // to env in the bad-value case lets typos like `--api-key --json` route
216
+ // `--json` into the Authorization header — fail loud instead (Rule 12).
217
+ function getFlag(name, rest) {
218
+ const i = rest.indexOf(name);
219
+ if (i < 0) return { present: false };
220
+ const value = rest[i + 1];
221
+ return { present: true, value };
222
+ }
223
+
224
+ // Validate a flag returned by getFlag. If present-with-bad-value, emit
225
+ // usage_error/missing_flag_value and signal the caller to bail. Returns the
226
+ // usable string when present-and-good, or undefined when absent (caller
227
+ // resolves via env / default chain).
228
+ function resolveFlag(flagResult, name, jsonMode) {
229
+ if (!flagResult.present) return { ok: true, value: undefined };
230
+ const v = flagResult.value;
231
+ if (v === undefined || v.startsWith('-')) {
232
+ emitEdit(
233
+ { code: 'usage_error', subcode: 'missing_flag_value', details: { flag: name } },
234
+ jsonMode,
235
+ );
236
+ return { ok: false };
237
+ }
238
+ return { ok: true, value: v };
239
+ }
240
+
241
+ // Default base URLs per backend — mirrors seeds/rewritable.html
242
+ // resolveBackendConfig (openrouter:2275, ollama:2243, lmstudio:2259).
243
+ // ollama and lmstudio honor RWA_*_URL overrides so the user can point at a
244
+ // remote host or non-standard port. openrouter is fixed (the URL has never
245
+ // drifted in the seed) so no override.
246
+ function envBaseUrl(name) {
247
+ switch (name) {
248
+ case 'openrouter': return 'https://openrouter.ai/api/v1';
249
+ case 'ollama': return process.env.RWA_OLLAMA_URL || 'http://localhost:11434/v1';
250
+ case 'lmstudio': return process.env.RWA_LMSTUDIO_URL || 'http://localhost:1234/v1';
251
+ default: return undefined;
252
+ }
253
+ }
254
+
255
+ // Extract `const PRODUCT_KIND = '...';` from the bootstrap. The seed bakes
256
+ // this at emit time (cli/src/seed.mjs applySeedSubs); reading it back lets
257
+ // us select the right SYSTEM_PROMPTS entry. Falls back to 'document' if the
258
+ // regex doesn't match — pre-PRODUCT_KIND containers all rendered as
259
+ // document-kind in the runtime, so defaulting matches that history.
260
+ function detectProductKind(fileText) {
261
+ const m = fileText.match(/const PRODUCT_KIND = '([^']*)';/);
262
+ return m ? m[1] : null;
263
+ }
264
+
46
265
  (async () => {
47
266
  try {
48
267
  if (verb === '--version' || verb === '-V') {
@@ -55,10 +274,750 @@ const verb = args[0];
55
274
  return;
56
275
  }
57
276
  const rest = args.slice(1);
277
+
278
+ if (verb === 'edit') {
279
+ // `--json` is opt-in and only applies to `edit` — other verbs ignore it.
280
+ const jsonMode = rest.includes('--json');
281
+ // `--plan <file>` or `--plan -` (force stdin). Default: stdin if piped.
282
+ const planIdx = rest.indexOf('--plan');
283
+ const planArg = planIdx >= 0 ? rest[planIdx + 1] : undefined;
284
+ if (planIdx >= 0 && (planArg === undefined || (planArg.startsWith('-') && planArg !== '-'))) {
285
+ emitEdit({ code: 'usage_error', subcode: 'missing_plan_value' }, jsonMode);
286
+ process.exitCode = 1;
287
+ return;
288
+ }
289
+ // Backend flags carry a value — keep them out of `positionals` so
290
+ // their argument doesn't get parsed as a stray instruction word.
291
+ const FLAG_WITH_VALUE = new Set(['--plan', '--backend', '--model', '--base-url', '--api-key']);
292
+ const positionals = rest.filter((a, i) =>
293
+ !a.startsWith('-') && !FLAG_WITH_VALUE.has(rest[i - 1])
294
+ );
295
+ const filePath = positionals[0];
296
+ const instruction = positionals.slice(1).join(' ');
297
+ if (!filePath) {
298
+ emitEdit({ code: 'usage_error', subcode: 'missing_file_arg' }, jsonMode);
299
+ process.exitCode = 1;
300
+ return;
301
+ }
302
+
303
+ // Input-source detection. Three mutually exclusive ways to supply
304
+ // the plan/instruction:
305
+ // 1. positional instruction string
306
+ // 2. piped stdin (without --plan, or with explicit `--plan -`)
307
+ // 3. --plan <file>
308
+ //
309
+ // Stdin probing is content-based, not TTY-based. `process.stdin.isTTY`
310
+ // is unreliable: child_process.spawn() (used by our tests + every CI
311
+ // harness) always leaves stdin as a non-TTY pipe even when the parent
312
+ // sends nothing. So we drain stdin eagerly and treat empty bytes as
313
+ // "no stdin input". `--plan -` overrides this and forces stdin even
314
+ // when empty (caller said so explicitly).
315
+ const hasPositionalInstruction = instruction.length > 0;
316
+ const hasPlanFile = typeof planArg === 'string' && planArg !== '-';
317
+ const hasPlanDash = planArg === '-';
318
+
319
+ // Read stdin only when there's no positional instruction AND no --plan <file>.
320
+ // We accept that this means we cannot detect `pipe | rwa edit X "instruction"` as
321
+ // `conflicting_input` — that combination is rare, and detecting it would require
322
+ // either eagerly draining stdin (which hangs on slow upstreams) or a non-blocking
323
+ // peek (platform-specific). Strict-and-loud beats hang-and-then-loud.
324
+ //
325
+ // Note: when --plan <file> is set, piped stdin is intentionally ignored. The design
326
+ // treats explicit-file as the unambiguous source of intent; detecting "stdin happens
327
+ // to have bytes too" would require eagerly draining (defeats the file-only fast path)
328
+ // or a non-blocking peek. We accept the trade-off.
329
+ let stdinBuf = '';
330
+ let stdinHasContent = false;
331
+ if (!hasPlanFile && !hasPositionalInstruction) {
332
+ stdinBuf = await readStdin();
333
+ stdinHasContent = stdinBuf.length > 0;
334
+ }
335
+ const planFromStdin = hasPlanDash || (stdinHasContent && !hasPlanFile);
336
+
337
+ const sources =
338
+ (hasPositionalInstruction ? 1 : 0) +
339
+ (planFromStdin ? 1 : 0) +
340
+ (hasPlanFile ? 1 : 0);
341
+ if (sources === 0) {
342
+ emitEdit({ code: 'usage_error', subcode: 'missing_input' }, jsonMode);
343
+ process.exitCode = 1;
344
+ return;
345
+ }
346
+ if (sources > 1) {
347
+ emitEdit({ code: 'usage_error', subcode: 'conflicting_input' }, jsonMode);
348
+ process.exitCode = 1;
349
+ return;
350
+ }
351
+
352
+ // Instruction path: run the agent loop and apply the resulting envelope.
353
+ if (hasPositionalInstruction) {
354
+ // Resolve backend config: explicit flag wins, then env, then default.
355
+ // The default model id matches the seed (seeds/rewritable.html
356
+ // const RWA.MODEL) so first-paint behavior is consistent across CLI
357
+ // and browser surfaces.
358
+ //
359
+ // Each flag is validated explicitly: present-with-bad-value (e.g.
360
+ // `--api-key --json` or `--backend` with no following token) errors
361
+ // with usage_error/missing_flag_value rather than silently falling
362
+ // back to env (which would, e.g., route `--json` into the
363
+ // Authorization header). Absent flags resolve via env / default.
364
+ const backendFlag = resolveFlag(getFlag('--backend', rest), '--backend', jsonMode);
365
+ if (!backendFlag.ok) { process.exitCode = 1; return; }
366
+ const modelFlag = resolveFlag(getFlag('--model', rest), '--model', jsonMode);
367
+ if (!modelFlag.ok) { process.exitCode = 1; return; }
368
+ const baseUrlFlag = resolveFlag(getFlag('--base-url', rest), '--base-url', jsonMode);
369
+ if (!baseUrlFlag.ok) { process.exitCode = 1; return; }
370
+ const apiKeyFlag = resolveFlag(getFlag('--api-key', rest), '--api-key', jsonMode);
371
+ if (!apiKeyFlag.ok) { process.exitCode = 1; return; }
372
+
373
+ const backendName = backendFlag.value || process.env.RWA_BACKEND || 'openrouter';
374
+ const modelId = modelFlag.value || process.env.RWA_MODEL || 'google/gemini-3.5-flash';
375
+ const baseUrl = baseUrlFlag.value || envBaseUrl(backendName);
376
+ const apiKey = resolveApiKey(backendName, apiKeyFlag.value);
377
+
378
+ // Reject unknown backends fast. `bridge` is browser-only by design
379
+ // (single-shot via web_cli_bridge); the CLI has no equivalent.
380
+ if (!['openrouter', 'ollama', 'lmstudio'].includes(backendName)) {
381
+ emitEdit({ code: 'usage_error', subcode: 'unknown_backend', details: { backend: backendName } }, jsonMode);
382
+ process.exitCode = 1;
383
+ return;
384
+ }
385
+ if (backendName === 'openrouter' && !apiKey) {
386
+ emitEdit({ code: 'agent_error', subcode: 'no_api_key', details: { backend: 'openrouter' } }, jsonMode);
387
+ process.exitCode = 4;
388
+ return;
389
+ }
390
+
391
+ // Read the target file. Same file_error shape as the plan path so
392
+ // callers can dedupe `not_found` / `read_error` handling across
393
+ // both code paths.
394
+ const { readFile } = await import('node:fs/promises');
395
+ let fileText;
396
+ try {
397
+ fileText = await readFile(filePath, 'utf8');
398
+ } catch (e) {
399
+ if (e && e.code === 'ENOENT') {
400
+ emitEdit({ code: 'file_error', subcode: 'not_found', details: { path: filePath } }, jsonMode);
401
+ process.exitCode = 2; return;
402
+ }
403
+ emitEdit({
404
+ code: 'file_error', subcode: 'read_error',
405
+ details: { path: filePath, errno: e && e.code, message: e && e.message },
406
+ }, jsonMode);
407
+ process.exitCode = 2; return;
408
+ }
409
+
410
+ const { extractInlineDoc } = await import('../src/seed.mjs');
411
+ let currentDoc;
412
+ try {
413
+ currentDoc = extractInlineDoc(fileText);
414
+ } catch (_e) {
415
+ emitEdit({ code: 'file_error', subcode: 'not_a_rewritable', details: { path: filePath } }, jsonMode);
416
+ process.exitCode = 2; return;
417
+ }
418
+
419
+ // Detect product kind from the bootstrap so we pick the right
420
+ // SYSTEM_PROMPTS entry. Pre-PRODUCT_KIND containers and unknown
421
+ // kinds both fall through to the 'document' entry below.
422
+ const productKind = detectProductKind(fileText) || 'document';
423
+
424
+ // Load the seed and extract SYSTEM_PROMPTS / TOOL_SCHEMAS — same
425
+ // in-package-first lookup `rwa new` uses. Per-kind SYSTEM_PROMPTS
426
+ // entries already interpolate ${SYSTEM_PROMPT_RULES} internally
427
+ // (see seeds/rewritable.html lines 1369-1370 and 1481), so we use
428
+ // the resolved string verbatim — concatenating SYSTEM_PROMPT_RULES
429
+ // again would duplicate ~4.5KB on every request.
430
+ const { loadSeed } = await import('../src/seed.mjs');
431
+ const { SEED_CANDIDATES } = await import('../src/commands.mjs');
432
+ const seedText = await loadSeed(SEED_CANDIDATES);
433
+ const { extractFromSeed } = await import('../src/seed-extract.mjs');
434
+ const { SYSTEM_PROMPTS, TOOL_SCHEMAS } = extractFromSeed(seedText);
435
+ const systemPrompt = SYSTEM_PROMPTS[productKind] || SYSTEM_PROMPTS.document;
436
+
437
+ // Compute marker-form frozen-zone names from the CURRENT doc so the
438
+ // model sees the same list the apply-edits guard will enforce.
439
+ const { findFrozenZones, virtualizeImages } = await import('../src/apply-edits.mjs');
440
+ const frozenZoneNames = findFrozenZones(currentDoc).map(z => z.name);
441
+
442
+ // images-v1 (rwa-edit-spec.md §19): the model never sees image bytes.
443
+ // The prompt carries the VIRTUAL doc (data:image src → rwa-asset
444
+ // tokens); applyPlan({virtualImages}) re-derives the same hash-keyed
445
+ // map and expands the model's token-form envelope back to real bytes.
446
+ const promptDoc = virtualizeImages(currentDoc).doc;
447
+
448
+ // Run the agent loop. Retry telemetry goes to stderr (plain or
449
+ // JSON depending on mode) so CI / wrapper scripts can observe
450
+ // progress without parsing stdout.
451
+ const { runAgentLoop } = await import('../src/agent-loop.mjs');
452
+ let envelope;
453
+ try {
454
+ const result = await runAgentLoop({
455
+ systemPrompt,
456
+ toolSchemas: TOOL_SCHEMAS,
457
+ currentDoc: promptDoc,
458
+ instruction,
459
+ frozenZoneNames,
460
+ backend: { baseUrl, model: modelId, apiKey },
461
+ onRetry: r => {
462
+ if (jsonMode) {
463
+ process.stderr.write(JSON.stringify({ phase: 'retry', attempt: r.attempt, reason: r.reason }) + '\n');
464
+ } else {
465
+ process.stderr.write(`rwa edit: attempt ${r.attempt}/3 retrying — ${r.reason}\n`);
466
+ }
467
+ },
468
+ });
469
+ envelope = result.envelope;
470
+ } catch (e) {
471
+ if (e && (e.subcode === 'no_envelope_after_retries' || e.subcode === 'backend_error')) {
472
+ emitEdit({ code: 'agent_error', subcode: e.subcode, details: e.details }, jsonMode);
473
+ process.exitCode = 4; return;
474
+ }
475
+ throw e;
476
+ }
477
+
478
+ // Apply the envelope through the same applyPlan used by the plan
479
+ // path — single splice/write code path, single error surface. The
480
+ // model saw the virtual doc, so the envelope is token-form.
481
+ const { applyPlan } = await import('../src/edit.mjs');
482
+ try {
483
+ await applyPlan(filePath, envelope, { virtualImages: true });
484
+ return;
485
+ } catch (e) {
486
+ if (e && typeof e.exitCode === 'number') {
487
+ emitEdit({ code: codeName(e.exitCode), subcode: e.subcode, details: e.details }, jsonMode);
488
+ process.exitCode = e.exitCode;
489
+ return;
490
+ }
491
+ throw e;
492
+ }
493
+ }
494
+
495
+ // Plan path: envelope comes from --plan file OR the stdin buffer we
496
+ // already drained above.
497
+ let envelopeJson;
498
+ if (hasPlanFile) {
499
+ const fs = await import('node:fs/promises');
500
+ try {
501
+ envelopeJson = await fs.readFile(planArg, 'utf8');
502
+ } catch (e) {
503
+ const subcode = e && e.code === 'ENOENT' ? 'plan_not_found' : 'plan_read_error';
504
+ emitEdit({ code: 'file_error', subcode, details: { path: planArg, errno: e && e.code } }, jsonMode);
505
+ process.exitCode = 2;
506
+ return;
507
+ }
508
+ } else {
509
+ envelopeJson = stdinBuf;
510
+ }
511
+
512
+ let envelope;
513
+ try {
514
+ envelope = JSON.parse(envelopeJson);
515
+ } catch (e) {
516
+ emitEdit({ code: 'envelope_error', subcode: 'malformed_json', details: { message: e.message } }, jsonMode);
517
+ process.exitCode = 3;
518
+ return;
519
+ }
520
+
521
+ const { applyPlan } = await import('../src/edit.mjs');
522
+ try {
523
+ await applyPlan(filePath, envelope);
524
+ return;
525
+ } catch (e) {
526
+ if (e && typeof e.exitCode === 'number') {
527
+ emitEdit({ code: codeName(e.exitCode), subcode: e.subcode, details: e.details }, jsonMode);
528
+ process.exitCode = e.exitCode;
529
+ return;
530
+ }
531
+ throw e;
532
+ }
533
+ }
534
+
535
+ // `rwa doc <path> [--json]` — the READ counterpart to `rwa edit`. Prints
536
+ // the LF-canonical editable body (plain mode) or the full editing contract
537
+ // (--json). stdout is reserved for the document/contract so pipes stay
538
+ // clean; errors go to stderr, mirroring `rwa edit`'s file_error surface.
539
+ if (verb === 'doc') {
540
+ const jsonMode = rest.includes('--json');
541
+ const filePath = rest.find(a => !a.startsWith('-'));
542
+ const emitDoc = (payload) => {
543
+ if (jsonMode) {
544
+ process.stderr.write(JSON.stringify(payload) + '\n');
545
+ } else {
546
+ const parts = [payload.code, payload.subcode].filter(Boolean);
547
+ let line = 'rwa doc: ' + parts.join('/');
548
+ if (payload.details && Object.keys(payload.details).length) {
549
+ line += ' ' + JSON.stringify(payload.details);
550
+ }
551
+ process.stderr.write(line + '\n');
552
+ }
553
+ };
554
+ if (!filePath) {
555
+ emitDoc({ code: 'usage_error', subcode: 'missing_file_arg' });
556
+ process.exitCode = 1;
557
+ return;
558
+ }
559
+ const { inspectDoc } = await import('../src/doc.mjs');
560
+ let info;
561
+ try {
562
+ info = await inspectDoc(filePath);
563
+ } catch (e) {
564
+ if (e && typeof e.exitCode === 'number') {
565
+ emitDoc({ code: codeName(e.exitCode), subcode: e.subcode, details: e.details });
566
+ process.exitCode = e.exitCode;
567
+ return;
568
+ }
569
+ throw e;
570
+ }
571
+ if (jsonMode) {
572
+ // One call gives an agent: the read (doc), the write-contract (kind,
573
+ // frozen zones, uuid), AND the self-description ("what is this, what can
574
+ // be done with it" — kind/affordances/title/blocks/baseline). The payload
575
+ // is the minimal SUPERSET of the static self-description/1 object (spec
576
+ // §3) plus the edit-contract extras, so it validates as self-description/1
577
+ // (validateSelfDescription ignores the extras) while staying one call.
578
+ // `rewritable:true` is an explicit parsed-field marker, not just an exit
579
+ // code. Field-pinned to tools/self-description.mjs by doc.test.mjs.
580
+ process.stdout.write(JSON.stringify({
581
+ ...info.self,
582
+ rewritable: true,
583
+ length: info.doc.length,
584
+ doc: info.doc,
585
+ }) + '\n');
586
+ } else {
587
+ // Terminal/pipe friendly: the body with a single trailing newline.
588
+ // The byte-exact path is --json's `doc` field.
589
+ process.stdout.write(info.doc.endsWith('\n') ? info.doc : info.doc + '\n');
590
+ }
591
+ return;
592
+ }
593
+
594
+ // `rwa ls [paths...] [--json]` — collection-scale self-description: the
595
+ // "what are all these?" counterpart to `rwa doc`'s "what is this?". Reports
596
+ // each rewritable's identity (kind/title/affordances) across a folder or
597
+ // file list, flagging non-rewritables and bad paths as rows. Lenient like
598
+ // its namesake — a completed scan exits 0; per-file issues live in the rows.
599
+ if (verb === 'ls') {
600
+ const jsonMode = rest.includes('--json');
601
+ const paths = rest.filter(a => !a.startsWith('-'));
602
+ const { listRewritables, formatRows } = await import('../src/ls.mjs');
603
+ const rows = await listRewritables(paths);
604
+ process.stdout.write((jsonMode ? JSON.stringify(rows) : formatRows(rows)) + '\n');
605
+ return;
606
+ }
607
+
608
+ // `rwa publish <file> [--url <base>] [--json]` — publish a local rewritable
609
+ // to the service's snapshot endpoint and print the share URL. Thin client
610
+ // for `POST /publish`; see src/publish.mjs. Intentionally online (the
611
+ // offline-first invariant of new/import does not apply to a publish action).
612
+ if (verb === 'publish') {
613
+ const jsonMode = rest.includes('--json');
614
+ // `--url` takes a value, so its value token must NOT be mistaken for the
615
+ // positional file. Skip the index right after `--url` when finding it.
616
+ const urlFlag = getFlag('--url', rest);
617
+ const urlIdx = rest.indexOf('--url');
618
+ const skip = urlIdx >= 0 ? urlIdx + 1 : -1;
619
+ const filePath = rest.find((a, i) => !a.startsWith('-') && i !== skip);
620
+ // Publish has its OWN exit-4 label: a network/remote failure is a
621
+ // `publish_error`, not the shared codeName(4)='agent_error'. File (2) and
622
+ // usage (1) errors reuse codeName — they mean the same across verbs.
623
+ const emitPublish = (payload) => {
624
+ if (jsonMode) {
625
+ process.stderr.write(JSON.stringify(payload) + '\n');
626
+ } else {
627
+ const parts = [payload.code, payload.subcode].filter(Boolean);
628
+ let line = 'rwa publish: ' + parts.join('/');
629
+ if (payload.details && Object.keys(payload.details).length) {
630
+ line += ' ' + JSON.stringify(payload.details);
631
+ }
632
+ process.stderr.write(line + '\n');
633
+ }
634
+ };
635
+ if (!filePath) {
636
+ emitPublish({ code: 'usage_error', subcode: 'missing_file_arg' });
637
+ process.exitCode = 1;
638
+ return;
639
+ }
640
+ if (urlFlag.present && (urlFlag.value === undefined || urlFlag.value.startsWith('-'))) {
641
+ emitPublish({ code: 'usage_error', subcode: 'missing_flag_value', details: { flag: '--url' } });
642
+ process.exitCode = 1;
643
+ return;
644
+ }
645
+ // Resolution: --url > RWA_PUBLISH_URL > hardcoded default (in publish.mjs).
646
+ const baseUrl = urlFlag.value || process.env.RWA_PUBLISH_URL || undefined;
647
+ const { publishCmd } = await import('../src/publish.mjs');
648
+ let result;
649
+ try {
650
+ result = await publishCmd(filePath, { baseUrl });
651
+ } catch (e) {
652
+ if (e && typeof e.exitCode === 'number') {
653
+ const code = e.exitCode === 4 ? 'publish_error' : codeName(e.exitCode);
654
+ emitPublish({ code, subcode: e.subcode, details: e.details });
655
+ process.exitCode = e.exitCode;
656
+ return;
657
+ }
658
+ throw e;
659
+ }
660
+ if (jsonMode) {
661
+ process.stdout.write(JSON.stringify(result) + '\n');
662
+ } else {
663
+ process.stdout.write(
664
+ '✓ Published!\n' +
665
+ ` URL: ${result.url}\n` +
666
+ ' Expires: in 24 hours (anonymous share)\n' +
667
+ ' Note: the hosted copy gets a fresh DOC_UUID (distinct container)\n',
668
+ );
669
+ }
670
+ return;
671
+ }
672
+
673
+ // `rwa publish-site <file> [--host h] [--path p] [--url base] [--json]` —
674
+ // copy a rewritable VERBATIM onto a static site over scp; print the live URL.
675
+ // Durable counterpart to `rwa publish` (ephemeral share). Online by design.
676
+ // Config: flags > RWA_SITE_HOST / RWA_SITE_PATH / RWA_SITE_URL. See
677
+ // src/publish-site.mjs. Exit 4 is labeled `publish_error` (like `publish`).
678
+ if (verb === 'publish-site') {
679
+ const jsonMode = rest.includes('--json');
680
+ const hostFlag = getFlag('--host', rest);
681
+ const pathFlag = getFlag('--path', rest);
682
+ const urlFlag = getFlag('--url', rest);
683
+ // Flag VALUE tokens must not be mistaken for the positional file.
684
+ const skip = new Set();
685
+ for (const f of ['--host', '--path', '--url']) {
686
+ const i = rest.indexOf(f); if (i >= 0) skip.add(i + 1);
687
+ }
688
+ const filePath = rest.find((a, i) => !a.startsWith('-') && !skip.has(i));
689
+ const emitPS = (payload) => {
690
+ if (jsonMode) { process.stderr.write(JSON.stringify(payload) + '\n'); return; }
691
+ const parts = [payload.code, payload.subcode].filter(Boolean);
692
+ let line = 'rwa publish-site: ' + parts.join('/');
693
+ if (payload.details && Object.keys(payload.details).length) line += ' ' + JSON.stringify(payload.details);
694
+ process.stderr.write(line + '\n');
695
+ };
696
+ if (!filePath) { emitPS({ code: 'usage_error', subcode: 'missing_file_arg' }); process.exitCode = 1; return; }
697
+ for (const [name, flag] of [['--host', hostFlag], ['--path', pathFlag], ['--url', urlFlag]]) {
698
+ if (flag.present && (flag.value === undefined || flag.value.startsWith('-'))) {
699
+ emitPS({ code: 'usage_error', subcode: 'missing_flag_value', details: { flag: name } });
700
+ process.exitCode = 1; return;
701
+ }
702
+ }
703
+ const { publishSite } = await import('../src/publish-site.mjs');
704
+ let result;
705
+ try {
706
+ result = await publishSite(filePath, { host: hostFlag.value, path: pathFlag.value, url: urlFlag.value });
707
+ } catch (e) {
708
+ if (e && typeof e.exitCode === 'number') {
709
+ const code = e.exitCode === 4 ? 'publish_error' : codeName(e.exitCode);
710
+ emitPS({ code, subcode: e.subcode, details: e.details });
711
+ process.exitCode = e.exitCode; return;
712
+ }
713
+ throw e;
714
+ }
715
+ if (jsonMode) process.stdout.write(JSON.stringify(result) + '\n');
716
+ else process.stdout.write(`✓ Published to ${result.url}\n`);
717
+ return;
718
+ }
719
+
720
+ // `rwa host <file> [--url <base>] [--json]` — ingest a local rewritable into
721
+ // a hosted runtime's `POST /r` and print the `{id, token, url}` it mints. The
722
+ // network-bearing INGEST client (the round-trip-edit foundation), the way
723
+ // `rwa publish` is the ephemeral-share client. Online by design (offline-first
724
+ // excludes it, like clone/publish-site). Config: --url > $RWA_HOST_URL. See
725
+ // src/host.mjs. Exit 4 is labeled `host_error` (like publish's `publish_error`).
726
+ if (verb === 'host') {
727
+ const jsonMode = rest.includes('--json');
728
+ // `--url` takes a value, so its value token must NOT be mistaken for the
729
+ // positional file. Skip the index right after `--url`.
730
+ const urlFlag = getFlag('--url', rest);
731
+ const urlIdx = rest.indexOf('--url');
732
+ const skip = urlIdx >= 0 ? urlIdx + 1 : -1;
733
+ const filePath = rest.find((a, i) => !a.startsWith('-') && i !== skip);
734
+ // Host has its OWN exit-4 label: a transport/HTTP failure is a `host_error`,
735
+ // not the shared codeName(4)='agent_error'. File (2) and usage (1) errors
736
+ // reuse codeName — they mean the same across verbs.
737
+ const emitHost = (payload) => {
738
+ if (jsonMode) { process.stderr.write(JSON.stringify(payload) + '\n'); return; }
739
+ const parts = [payload.code, payload.subcode].filter(Boolean);
740
+ let line = 'rwa host: ' + parts.join('/');
741
+ if (payload.details && Object.keys(payload.details).length) line += ' ' + JSON.stringify(payload.details);
742
+ process.stderr.write(line + '\n');
743
+ };
744
+ if (!filePath) {
745
+ emitHost({ code: 'usage_error', subcode: 'missing_file_arg' });
746
+ process.exitCode = 1;
747
+ return;
748
+ }
749
+ if (urlFlag.present && (urlFlag.value === undefined || urlFlag.value.startsWith('-'))) {
750
+ emitHost({ code: 'usage_error', subcode: 'missing_flag_value', details: { flag: '--url' } });
751
+ process.exitCode = 1;
752
+ return;
753
+ }
754
+ const { hostFile } = await import('../src/host.mjs');
755
+ let result;
756
+ try {
757
+ result = await hostFile(filePath, { url: urlFlag.value });
758
+ } catch (e) {
759
+ if (e && typeof e.exitCode === 'number') {
760
+ // config_error is usage-class (exit 1) → render under usage_error, like
761
+ // publish-site's config_error. Only the exit-4 transport class becomes
762
+ // `host_error`.
763
+ const code = e.exitCode === 4 ? 'host_error' : codeName(e.exitCode);
764
+ emitHost({ code, subcode: e.subcode, details: e.details });
765
+ process.exitCode = e.exitCode;
766
+ return;
767
+ }
768
+ throw e;
769
+ }
770
+ if (jsonMode) {
771
+ process.stdout.write(JSON.stringify(result) + '\n');
772
+ } else {
773
+ process.stdout.write(
774
+ '✓ Hosted!\n' +
775
+ ` id: ${result.id}\n` +
776
+ ` token: ${result.token}\n` +
777
+ ` url: ${result.url}\n` +
778
+ ' Note: the url carries your capability token in its #k= fragment — keep it to keep editing.\n',
779
+ );
780
+ }
781
+ return;
782
+ }
783
+
784
+ // `rwa skin <file> <name|reset> [--l1] [--theme-only] [--json]`.
785
+ //
786
+ // DEFAULT (no --l1): deterministic, model-free theme swap. Applies a preset's
787
+ // <style data-rwa-skin> block in place: first skin INSERTS via replace_document
788
+ // (adding a <style> changes the structural shape), re-skin SWAPS via apply_edits,
789
+ // reset removes it (deskinDoc — clears wrappers too) — all routed through the
790
+ // same applyPlan write path as `rwa edit` for atomic write + frozen-zone safety
791
+ // + the shared file_error surface. Offline, no key.
792
+ //
793
+ // --l1 (opt-in): the always-on content-aware restyle. De-skin the doc, drive
794
+ // the agent over a multi-turn backend with the preset recipe to add additive
795
+ // sk-* class hooks + wrappers (NO write yet), splice the theme block onto the
796
+ // agent's output, then commit ONCE (theme + wrappers together). Agent
797
+ // decline/invalid-edit degrades to a theme-only commit (the skin always
798
+ // lands); a missing/unreachable backend fails LOUD (exit 4) like `rwa edit`.
799
+ // Mirrors seeds/rewritable.html applySkinL1 (docs/plans/2026-06-03-skinning-design.md).
800
+ if (verb === 'skin') {
801
+ const jsonMode = rest.includes('--json');
802
+ const themeOnly = rest.includes('--theme-only');
803
+ const l1 = rest.includes('--l1');
804
+ // Drop the flags that take a following value from the positional scan so a
805
+ // `rwa skin doc.html notion-clean --l1 --model foo` doesn't read `foo` as
806
+ // a positional. Mirrors the edit path's flag-aware positional handling.
807
+ const SKIN_FLAG_WITH_VALUE = new Set(['--backend', '--model', '--base-url', '--api-key']);
808
+ const positionals = rest.filter((a, i) => !a.startsWith('-') && !SKIN_FLAG_WITH_VALUE.has(rest[i - 1]));
809
+ const filePath = positionals[0];
810
+ const action = positionals[1];
811
+ const emitSkin = (payload) => {
812
+ if (jsonMode) {
813
+ process.stderr.write(JSON.stringify(payload) + '\n');
814
+ } else {
815
+ const parts = [payload.code, payload.subcode].filter(Boolean);
816
+ let line = 'rwa skin: ' + parts.join('/');
817
+ if (payload.details && Object.keys(payload.details).length) {
818
+ line += ' ' + JSON.stringify(payload.details);
819
+ }
820
+ process.stderr.write(line + '\n');
821
+ }
822
+ };
823
+ if (!filePath || !action) {
824
+ emitSkin({ code: 'usage_error', subcode: 'missing_args', details: { usage: 'rwa skin <file> <name|reset> [--l1] [--theme-only]' } });
825
+ process.exitCode = 1;
826
+ return;
827
+ }
828
+
829
+ // ── L1 path: agent-driven restyle. `reset` is deterministic — never L1. ──
830
+ if (l1 && action !== 'reset') {
831
+ // Resolve backend config exactly like `rwa edit`'s instruction path so
832
+ // --l1 inherits the same flags / env / default chain and the same
833
+ // missing-backend behavior (openrouter with no key → exit 4).
834
+ const backendFlag = resolveFlag(getFlag('--backend', rest), '--backend', jsonMode);
835
+ if (!backendFlag.ok) { process.exitCode = 1; return; }
836
+ const modelFlag = resolveFlag(getFlag('--model', rest), '--model', jsonMode);
837
+ if (!modelFlag.ok) { process.exitCode = 1; return; }
838
+ const baseUrlFlag = resolveFlag(getFlag('--base-url', rest), '--base-url', jsonMode);
839
+ if (!baseUrlFlag.ok) { process.exitCode = 1; return; }
840
+ const apiKeyFlag = resolveFlag(getFlag('--api-key', rest), '--api-key', jsonMode);
841
+ if (!apiKeyFlag.ok) { process.exitCode = 1; return; }
842
+
843
+ const backendName = backendFlag.value || process.env.RWA_BACKEND || 'openrouter';
844
+ const modelId = modelFlag.value || process.env.RWA_MODEL || 'google/gemini-3.5-flash';
845
+ const baseUrl = baseUrlFlag.value || envBaseUrl(backendName);
846
+ const apiKey = resolveApiKey(backendName, apiKeyFlag.value);
847
+
848
+ if (!['openrouter', 'ollama', 'lmstudio'].includes(backendName)) {
849
+ emitSkin({ code: 'usage_error', subcode: 'unknown_backend', details: { backend: backendName } });
850
+ process.exitCode = 1; return;
851
+ }
852
+ if (backendName === 'openrouter' && !apiKey) {
853
+ emitSkin({ code: 'agent_error', subcode: 'no_api_key', details: { backend: 'openrouter' } });
854
+ process.exitCode = 4; return;
855
+ }
856
+
857
+ // Read the target to pick the right SYSTEM_PROMPTS entry by product kind
858
+ // (file errors surface as file_error/exit 2, same as skinCmd / edit).
859
+ const { readFile } = await import('node:fs/promises');
860
+ let fileText;
861
+ try {
862
+ fileText = await readFile(filePath, 'utf8');
863
+ } catch (e) {
864
+ if (e && e.code === 'ENOENT') { emitSkin({ code: 'file_error', subcode: 'not_found', details: { path: filePath } }); process.exitCode = 2; return; }
865
+ emitSkin({ code: 'file_error', subcode: 'read_error', details: { path: filePath, errno: e && e.code, message: e && e.message } });
866
+ process.exitCode = 2; return;
867
+ }
868
+ const productKind = detectProductKind(fileText) || 'document';
869
+
870
+ // Load SYSTEM_PROMPTS / TOOL_SCHEMAS from the seed — same in-package-first
871
+ // lookup `rwa edit` uses.
872
+ const { loadSeed } = await import('../src/seed.mjs');
873
+ const { SEED_CANDIDATES: SC } = await import('../src/commands.mjs');
874
+ const seedText = await loadSeed(SC);
875
+ const { extractFromSeed } = await import('../src/seed-extract.mjs');
876
+ const { SYSTEM_PROMPTS, TOOL_SCHEMAS } = extractFromSeed(seedText);
877
+ const systemPrompt = SYSTEM_PROMPTS[productKind] || SYSTEM_PROMPTS.document;
878
+
879
+ const { skinCmdL1 } = await import('../src/skin.mjs');
880
+ let result;
881
+ try {
882
+ result = await skinCmdL1(filePath, action, {
883
+ systemPrompt,
884
+ toolSchemas: TOOL_SCHEMAS,
885
+ backend: { baseUrl, model: modelId, apiKey },
886
+ onRetry: r => {
887
+ if (jsonMode) process.stderr.write(JSON.stringify({ phase: 'retry', attempt: r.attempt, reason: r.reason }) + '\n');
888
+ else process.stderr.write(`rwa skin: attempt ${r.attempt}/3 retrying — ${r.reason}\n`);
889
+ },
890
+ });
891
+ } catch (e) {
892
+ if (e && typeof e.exitCode === 'number') {
893
+ if (!jsonMode && e.subcode === 'unknown_skin') process.stderr.write('rwa skin: ' + e.message + '\n');
894
+ else emitSkin({ code: codeName(e.exitCode), subcode: e.subcode, details: e.details });
895
+ process.exitCode = e.exitCode;
896
+ return;
897
+ }
898
+ throw e;
899
+ }
900
+ if (jsonMode) {
901
+ process.stdout.write(JSON.stringify(result) + '\n');
902
+ } else if (result.degraded) {
903
+ process.stdout.write(`✓ skin "${result.skin}" applied (theme-only)\n`);
904
+ process.stderr.write('note: the model did not contribute a usable restyle — applied the deterministic theme only.\n');
905
+ } else {
906
+ process.stdout.write(`✓ skin "${result.skin}" applied (theme + content-aware restyle)\n`);
907
+ }
908
+ return;
909
+ }
910
+
911
+ const { skinCmd } = await import('../src/skin.mjs');
912
+ let result;
913
+ try {
914
+ result = await skinCmd(filePath, action);
915
+ } catch (e) {
916
+ if (e && typeof e.exitCode === 'number') {
917
+ // unknown_skin carries a known-list message; surface it verbatim in
918
+ // plain mode so the user sees their options (mirrors unknown --kind).
919
+ if (!jsonMode && e.subcode === 'unknown_skin') {
920
+ process.stderr.write('rwa skin: ' + e.message + '\n');
921
+ } else {
922
+ emitSkin({ code: codeName(e.exitCode), subcode: e.subcode, details: e.details });
923
+ }
924
+ process.exitCode = e.exitCode;
925
+ return;
926
+ }
927
+ throw e;
928
+ }
929
+ if (jsonMode) {
930
+ process.stdout.write(JSON.stringify(result) + '\n');
931
+ } else if (result.mode === 'noop') {
932
+ process.stdout.write(`note: ${filePath} has no skin — nothing to reset\n`);
933
+ } else if (result.mode === 'reset') {
934
+ process.stdout.write(`✓ skin removed from ${filePath}\n`);
935
+ } else {
936
+ const word = result.mode === 'insert' ? 'applied' : 'changed to';
937
+ process.stdout.write(`✓ skin ${word} "${result.skin}" (theme-only)\n`);
938
+ // Not a silent downgrade (Rule 12): tell the user this is the
939
+ // deterministic theme only. --theme-only signals "I know"; --l1 opts
940
+ // into the content-aware restyle. Either silences the note.
941
+ if (!themeOnly) {
942
+ process.stderr.write('note: applied the deterministic theme only — pass --l1 for the content-aware restyle, or --theme-only to silence this note.\n');
943
+ }
944
+ }
945
+ return;
946
+ }
947
+
948
+ // `rwa clone <url> [path] [--force]` — fetch a public webpage and write it
949
+ // as a self-contained rewritable. Unlike every other verb this REQUIRES the
950
+ // network (it fetches). The fetch layer is SSRF-guarded (src/fetch-page.mjs);
951
+ // all of its failures plus the destination-exists check surface as a
952
+ // CloneError carrying a numeric exitCode (2 = file/fetch class) + subcode.
953
+ // We mirror `emitEdit`'s plain stderr format ("rwa clone: <codeName>/<subcode>
954
+ // <details?>") so the failure surface is consistent with `rwa edit`.
955
+ if (verb === 'clone') {
956
+ const force = rest.includes('--force') || rest.includes('-f');
957
+ const localizeImages = rest.includes('--localize-images');
958
+ const positionals = rest.filter(a => !a.startsWith('-'));
959
+ const url = positionals[0];
960
+ const outPath = positionals[1];
961
+ const emitClone = (payload) => {
962
+ const parts = [payload.code, payload.subcode].filter(Boolean);
963
+ let line = 'rwa clone: ' + parts.join('/');
964
+ if (payload.details && Object.keys(payload.details).length) {
965
+ line += ' ' + JSON.stringify(payload.details);
966
+ }
967
+ process.stderr.write(line + '\n');
968
+ };
969
+ if (!url) {
970
+ emitClone({ code: 'usage_error', subcode: 'missing_url_arg' });
971
+ process.exitCode = 1;
972
+ return;
973
+ }
974
+ const { cloneCmd } = await import('../src/clone.mjs');
975
+ try {
976
+ await cloneCmd({ url, outPath, force, localizeImages });
977
+ } catch (e) {
978
+ if (e && typeof e.exitCode === 'number') {
979
+ emitClone({ code: codeName(e.exitCode), subcode: e.subcode, details: e.details });
980
+ process.exitCode = e.exitCode;
981
+ return;
982
+ }
983
+ throw e;
984
+ }
985
+ return;
986
+ }
987
+
988
+ // `rwa create <task…>` / `rwa draft <task…>` (design 2026-05-31 §4): scaffold
989
+ // + agent-fill a self-contained rewritable in one shot. Its own flag grammar
990
+ // (parseCreateArgs), so it returns before the new/import positional handling.
991
+ if (verb === 'create' || verb === 'draft') {
992
+ const parsed = parseCreateArgs(rest);
993
+ if (!parsed.words.length && !parsed.from && !parsed.kind) {
994
+ console.error('rwa create: missing <task> (e.g. "rwa create a presentation about Q3")');
995
+ process.exitCode = 2;
996
+ return;
997
+ }
998
+ // --data - reads stdin (design §4.3); drain it here so createCmd stays IO-pure
999
+ // about its data source.
1000
+ let stdinData;
1001
+ if (parsed.data === '-') stdinData = await readStdin();
1002
+ try {
1003
+ const { out, kind: rk, fromMsg } = await createCmd(parsed, { seedCandidates: SEED_CANDIDATES, cwd: process.cwd(), stdinData });
1004
+ const kindMsg = rk !== 'document' ? ` (kind: ${rk})` : '';
1005
+ console.log(`wrote ${relative(process.cwd(), out) || out}${fromMsg || kindMsg}`);
1006
+ if (parsed.open) await openWithPrefill(out);
1007
+ } catch (e) {
1008
+ const label = [e && e.subcode].filter(Boolean).join('/') || (e && e.message) || String(e);
1009
+ const details = e && e.details && Object.keys(e.details).length ? ' ' + JSON.stringify(e.details) : '';
1010
+ console.error('rwa create: ' + label + details);
1011
+ process.exitCode = (e && e.exitCode) || 1;
1012
+ }
1013
+ return;
1014
+ }
1015
+
58
1016
  const force = rest.includes('--force') || rest.includes('-f');
59
1017
  const open = rest.includes('--open') || rest.includes('-o');
60
1018
  const vision = rest.includes('--vision');
61
1019
  const claude = rest.includes('--claude');
1020
+ const trustInput = rest.includes('--trust-input');
62
1021
  // --model and --timeout take a value: find the index, then take the next arg.
63
1022
  const modelIdx = rest.indexOf('--model');
64
1023
  const model = modelIdx >= 0 ? rest[modelIdx + 1] : undefined;
@@ -69,16 +1028,48 @@ const verb = args[0];
69
1028
  process.exitCode = 2;
70
1029
  return;
71
1030
  }
72
- const positional = rest.filter((a, i) => !a.startsWith('-') && rest[i - 1] !== '--model' && rest[i - 1] !== '--timeout');
1031
+ const kindIdx = rest.indexOf('--kind');
1032
+ const kind = kindIdx >= 0 ? rest[kindIdx + 1] : undefined;
1033
+ if (kindIdx >= 0 && (!kind || kind.startsWith('-'))) {
1034
+ console.error('rwa: --kind requires a name (e.g. --kind workflow)');
1035
+ process.exitCode = 2;
1036
+ return;
1037
+ }
1038
+ if (kind && !KNOWN_KINDS.includes(kind)) {
1039
+ console.error(`rwa: unknown --kind "${kind}". Known: ${KNOWN_KINDS.join(', ')}.`);
1040
+ process.exitCode = 2;
1041
+ return;
1042
+ }
1043
+ // `rwa new --skin <name>` bakes a preset's <style data-rwa-skin> block into
1044
+ // the emitted container (deterministic, offline). Orthogonal to --kind. An
1045
+ // unknown name is caught by newCmd (skinByName throws exit-2 with the list).
1046
+ const skinIdx = rest.indexOf('--skin');
1047
+ const skinName = skinIdx >= 0 ? rest[skinIdx + 1] : undefined;
1048
+ if (skinIdx >= 0 && (!skinName || skinName.startsWith('-'))) {
1049
+ console.error('rwa: --skin requires a name (e.g. --skin notion-clean)');
1050
+ process.exitCode = 2;
1051
+ return;
1052
+ }
1053
+ const positional = rest.filter((a, i) => !a.startsWith('-') && rest[i - 1] !== '--model' && rest[i - 1] !== '--timeout' && rest[i - 1] !== '--kind' && rest[i - 1] !== '--skin');
73
1054
  if (verb === 'new') {
74
- await newCmd({ outPath: positional[0], force, open });
1055
+ // `rwa new --kind <starter>` selects a built-in starter. Otherwise a bare-word
1056
+ // first positional is a TEMPLATE name (clone a data-rwa-template-labeled file
1057
+ // from cwd); a .html / path-bearing first positional is the output path.
1058
+ let templateName, outPath;
1059
+ if (!kind && positional[0] && !/\.html?$/i.test(positional[0]) && !/[\\/]/.test(positional[0])) {
1060
+ templateName = positional[0];
1061
+ outPath = positional[1];
1062
+ } else {
1063
+ outPath = positional[0];
1064
+ }
1065
+ await newCmd({ outPath, force, open, kind, templateName, skin: skinName });
75
1066
  } else if (verb === 'import') {
76
1067
  if (!positional[0]) {
77
1068
  console.error('rwa import: missing <input> argument');
78
1069
  process.exitCode = 2;
79
1070
  return;
80
1071
  }
81
- await importCmd({ inputPath: positional[0], outPath: positional[1], force, open, vision, claude, model, timeoutSec });
1072
+ await importCmd({ inputPath: positional[0], outPath: positional[1], force, open, vision, claude, trustInput, model, timeoutSec });
82
1073
  } else {
83
1074
  console.error(`rwa: unknown verb "${verb}". Try --help.`);
84
1075
  process.exitCode = 2;