rewritable 0.3.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 +261 -5
- package/bin/rwa.mjs +1000 -9
- package/package.json +2 -2
- package/seeds/rewritable.html +4065 -207
- 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 +90 -10
- 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 +28 -4
- package/src/import-vision.mjs +1 -1
- package/src/import.mjs +76 -10
- 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 +1387 -5
- 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/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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|