@khanglvm/relay 0.2.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/LICENSE +21 -0
- package/README.md +325 -0
- package/bin/rly.js +9 -0
- package/docs/AGENT.md +317 -0
- package/package.json +46 -0
- package/skills/relay/SKILL.md +156 -0
- package/skills/relay/examples/blocks-showcase.json +118 -0
- package/skills/relay/examples/feature-feedback.json +27 -0
- package/skills/relay/examples/prototype-review.json +25 -0
- package/src/cli.js +629 -0
- package/src/open.js +15 -0
- package/src/server.js +371 -0
- package/src/spec.js +435 -0
- package/src/store.js +147 -0
- package/src/ui/annotate.css +151 -0
- package/src/ui/annotate.js +440 -0
- package/src/ui/app.js +605 -0
- package/src/ui/blocks.css +170 -0
- package/src/ui/blocks.js +719 -0
- package/src/ui/index.html +21 -0
- package/src/ui/kit.js +413 -0
- package/src/ui/style.css +231 -0
- package/src/util.js +19 -0
- package/vendor/VERSIONS.json +5 -0
- package/vendor/chart.umd.js +14 -0
- package/vendor/mermaid.min.js +3405 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { CliError, sleep, pollFor } from './util.js';
|
|
7
|
+
import { normalizeSpec, questionFromInline, SPEC_SCHEMA } from './spec.js';
|
|
8
|
+
import {
|
|
9
|
+
createBoard,
|
|
10
|
+
loadBoard,
|
|
11
|
+
deleteBoard,
|
|
12
|
+
listBoards,
|
|
13
|
+
listRunning,
|
|
14
|
+
loadRunning,
|
|
15
|
+
removeRunning,
|
|
16
|
+
isAlive,
|
|
17
|
+
HOME,
|
|
18
|
+
} from './store.js';
|
|
19
|
+
import { runBoard } from './server.js';
|
|
20
|
+
import { openUrl } from './open.js';
|
|
21
|
+
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
24
|
+
const BIN = path.join(PKG_ROOT, 'bin', 'rly.js');
|
|
25
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
|
|
26
|
+
|
|
27
|
+
const VALUED_FLAGS = new Set([
|
|
28
|
+
'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
|
|
29
|
+
'submit-label', 'height', 'limit', 'target', 'id',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function camel(key) {
|
|
33
|
+
return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseArgs(argv) {
|
|
37
|
+
const args = { _: [], q: [] };
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const t = argv[i];
|
|
40
|
+
if (t === '-q' || t === '--question') {
|
|
41
|
+
const v = argv[++i];
|
|
42
|
+
if (v === undefined) throw new CliError(`${t} needs a value.`);
|
|
43
|
+
args.q.push(v);
|
|
44
|
+
} else if (t === '-') {
|
|
45
|
+
args.stdin = true;
|
|
46
|
+
} else if (t.startsWith('--')) {
|
|
47
|
+
let key = t.slice(2);
|
|
48
|
+
let val = true;
|
|
49
|
+
const eq = key.indexOf('=');
|
|
50
|
+
if (eq >= 0) {
|
|
51
|
+
val = key.slice(eq + 1);
|
|
52
|
+
key = key.slice(0, eq);
|
|
53
|
+
} else if (VALUED_FLAGS.has(key)) {
|
|
54
|
+
val = argv[++i];
|
|
55
|
+
if (val === undefined) throw new CliError(`--${key} needs a value.`);
|
|
56
|
+
}
|
|
57
|
+
if (key === 'no-open') {
|
|
58
|
+
args.open = false;
|
|
59
|
+
} else {
|
|
60
|
+
args[camel(key)] = val;
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
args._.push(t);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return args;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printJson(obj) {
|
|
70
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function exitCodeFor(status) {
|
|
74
|
+
return { submitted: 0, acknowledged: 0, open: 0, timeout: 2, cancelled: 3 }[status] ?? 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseJson(text, where) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(text);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new CliError(`invalid JSON in ${where}: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readStdin() {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
let data = '';
|
|
88
|
+
process.stdin.setEncoding('utf8');
|
|
89
|
+
process.stdin.on('data', (c) => (data += c));
|
|
90
|
+
process.stdin.on('end', () => resolve(data));
|
|
91
|
+
process.stdin.on('error', reject);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mustLoad(id) {
|
|
96
|
+
if (!id) throw new CliError('missing <board-id>. See `rly history` for saved boards.');
|
|
97
|
+
const record = loadBoard(id);
|
|
98
|
+
if (!record) throw new CliError(`board "${id}" not found. See \`rly history\`.`, 5);
|
|
99
|
+
return record;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function resolveSpecInput(args, mode) {
|
|
103
|
+
let raw = {};
|
|
104
|
+
if (args.file) {
|
|
105
|
+
raw =
|
|
106
|
+
args.file === '-'
|
|
107
|
+
? parseJson(await readStdin(), 'stdin')
|
|
108
|
+
: parseJson(readFileOrThrow(args.file), args.file);
|
|
109
|
+
} else if (args.stdin) {
|
|
110
|
+
raw = parseJson(await readStdin(), 'stdin');
|
|
111
|
+
}
|
|
112
|
+
if (args.title) raw.title = args.title;
|
|
113
|
+
if (args.intro) raw.intro = args.intro;
|
|
114
|
+
if (typeof args.html === 'string') raw.html = args.html;
|
|
115
|
+
if (args.htmlFile) raw.htmlFile = args.htmlFile;
|
|
116
|
+
if (args.height) raw.htmlHeight = args.height;
|
|
117
|
+
if (args.submitLabel) raw.submitLabel = args.submitLabel;
|
|
118
|
+
if (args.q.length) {
|
|
119
|
+
raw.questions = [...(raw.questions || []), ...args.q.map((s, i) => questionFromInline(s, i))];
|
|
120
|
+
}
|
|
121
|
+
// If the user supplied a spec (file/stdin) let normalizeSpec report what's
|
|
122
|
+
// wrong with it precisely; the generic usage hint is only for a bare call.
|
|
123
|
+
const suppliedSpec = Boolean(args.file || args.stdin);
|
|
124
|
+
const hasContent = (raw.questions && raw.questions.length) || raw.html || raw.htmlFile;
|
|
125
|
+
if (!suppliedSpec && !hasContent) {
|
|
126
|
+
throw new CliError(
|
|
127
|
+
mode === 'show'
|
|
128
|
+
? 'show needs --html-file <file>, --html "<markup>", or --file <spec.json>. Run `rly agent` for examples.'
|
|
129
|
+
: 'ask needs a spec: --file <spec.json>, --file - (stdin), or -q "label::type::opt1,opt2". Run `rly agent` for examples.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readFileOrThrow(p) {
|
|
136
|
+
try {
|
|
137
|
+
return fs.readFileSync(path.resolve(p), 'utf8');
|
|
138
|
+
} catch {
|
|
139
|
+
throw new CliError(`cannot read file "${p}".`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function runOrDetach(record, args) {
|
|
144
|
+
const timeoutSec = args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800;
|
|
145
|
+
const port = args.port !== undefined ? Number.parseInt(args.port, 10) || 0 : 0;
|
|
146
|
+
const open = args.open !== false;
|
|
147
|
+
|
|
148
|
+
if (args.detach) {
|
|
149
|
+
const child = spawn(
|
|
150
|
+
process.execPath,
|
|
151
|
+
[
|
|
152
|
+
BIN, '__serve',
|
|
153
|
+
'--id', record.id,
|
|
154
|
+
'--port', String(port),
|
|
155
|
+
'--timeout', String(timeoutSec),
|
|
156
|
+
...(open ? [] : ['--no-open']),
|
|
157
|
+
],
|
|
158
|
+
{ detached: true, stdio: 'ignore' }
|
|
159
|
+
);
|
|
160
|
+
child.unref();
|
|
161
|
+
const info = await pollFor(() => loadRunning(record.id), 8000);
|
|
162
|
+
if (!info) throw new CliError(`board ${record.id} failed to start (no server after 8s).`, 1);
|
|
163
|
+
printJson({
|
|
164
|
+
status: 'open',
|
|
165
|
+
boardId: record.id,
|
|
166
|
+
url: info.url,
|
|
167
|
+
port: info.port,
|
|
168
|
+
hint: `block for the answers with: rly wait ${record.id}`,
|
|
169
|
+
});
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const { done } = await runBoard({ id: record.id, port, open, timeoutSec });
|
|
174
|
+
const result = await done;
|
|
175
|
+
printJson(result);
|
|
176
|
+
return exitCodeFor(result.status);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function cmdAsk(args, mode) {
|
|
180
|
+
const raw = await resolveSpecInput(args, mode);
|
|
181
|
+
const spec = normalizeSpec(raw);
|
|
182
|
+
const record = createBoard(spec);
|
|
183
|
+
return runOrDetach(record, args);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function cmdReopen(args) {
|
|
187
|
+
const record = mustLoad(args._[0]);
|
|
188
|
+
const running = loadRunning(record.id);
|
|
189
|
+
if (running && isAlive(running.pid)) {
|
|
190
|
+
openUrl(running.url);
|
|
191
|
+
printJson({ status: 'open', boardId: record.id, url: running.url, note: 'already running — browser re-opened' });
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
return runOrDetach(record, args);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function cmdReuse(args) {
|
|
198
|
+
const src = mustLoad(args._[0]);
|
|
199
|
+
if (args.dump) {
|
|
200
|
+
console.log(JSON.stringify(src.spec, null, 2));
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
const record = createBoard(src.spec);
|
|
204
|
+
return runOrDetach(record, args);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function cmdWait(args) {
|
|
208
|
+
const id = args._[0];
|
|
209
|
+
if (!id) throw new CliError('usage: rly wait <board-id> [--timeout <sec>]');
|
|
210
|
+
const timeoutSec = args.timeout !== undefined ? Math.max(1, Number.parseInt(args.timeout, 10) || 1) : 3600;
|
|
211
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
212
|
+
mustLoad(id);
|
|
213
|
+
while (Date.now() < deadline) {
|
|
214
|
+
const record = mustLoad(id);
|
|
215
|
+
if (record.result && record.result.finishedAt) {
|
|
216
|
+
printJson(record.result);
|
|
217
|
+
return exitCodeFor(record.result.status);
|
|
218
|
+
}
|
|
219
|
+
const running = loadRunning(id);
|
|
220
|
+
if (!running || !isAlive(running.pid)) {
|
|
221
|
+
await sleep(700); // the result write may be racing the process exit
|
|
222
|
+
const again = loadBoard(id);
|
|
223
|
+
if (again?.result?.finishedAt) {
|
|
224
|
+
printJson(again.result);
|
|
225
|
+
return exitCodeFor(again.result.status);
|
|
226
|
+
}
|
|
227
|
+
printJson({
|
|
228
|
+
status: 'lost',
|
|
229
|
+
boardId: id,
|
|
230
|
+
draft: again?.draft ?? null,
|
|
231
|
+
error: 'board server exited without writing a result',
|
|
232
|
+
});
|
|
233
|
+
return 5;
|
|
234
|
+
}
|
|
235
|
+
await sleep(400);
|
|
236
|
+
}
|
|
237
|
+
printJson({
|
|
238
|
+
status: 'wait-timeout',
|
|
239
|
+
boardId: id,
|
|
240
|
+
hint: `board is still open — run \`rly wait ${id}\` again, or \`rly result ${id}\` to peek at the live draft`,
|
|
241
|
+
});
|
|
242
|
+
return 2;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function cmdResult(args) {
|
|
246
|
+
const record = mustLoad(args._[0]);
|
|
247
|
+
if (record.result && record.result.finishedAt) {
|
|
248
|
+
printJson(record.result);
|
|
249
|
+
return exitCodeFor(record.result.status);
|
|
250
|
+
}
|
|
251
|
+
const running = loadRunning(record.id);
|
|
252
|
+
if (running && isAlive(running.pid)) {
|
|
253
|
+
// While open, expose the real-time autosaved draft so agents can peek.
|
|
254
|
+
printJson({ status: 'open', boardId: record.id, url: running.url, draft: record.draft ?? null });
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
printJson({ status: 'lost', boardId: record.id, draft: record.draft ?? null });
|
|
258
|
+
return 5;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function cmdList(args) {
|
|
262
|
+
const running = listRunning();
|
|
263
|
+
if (args.json) {
|
|
264
|
+
printJson(running);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
if (!running.length) {
|
|
268
|
+
console.log('No boards running.');
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
for (const r of running) {
|
|
272
|
+
console.log(`${r.id} ${r.url} pid ${r.pid} "${r.title}" since ${r.startedAt}`);
|
|
273
|
+
}
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function cmdOpen(args) {
|
|
278
|
+
let id = args._[0];
|
|
279
|
+
const running = listRunning();
|
|
280
|
+
if (!id) {
|
|
281
|
+
if (running.length === 1) id = running[0].id;
|
|
282
|
+
else if (running.length === 0) throw new CliError('no boards running. Use `rly reopen <id>` to serve a saved one.', 5);
|
|
283
|
+
else throw new CliError(`multiple boards running — pick one: ${running.map((r) => r.id).join(', ')}`);
|
|
284
|
+
}
|
|
285
|
+
const info = running.find((r) => r.id === id);
|
|
286
|
+
if (!info) throw new CliError(`board "${id}" is not running. Use \`rly reopen ${id}\` to serve it again (with saved answers).`, 5);
|
|
287
|
+
openUrl(info.url);
|
|
288
|
+
printJson({ status: 'open', boardId: id, url: info.url });
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function cmdStop(args) {
|
|
293
|
+
let targets;
|
|
294
|
+
if (args.all) {
|
|
295
|
+
targets = listRunning();
|
|
296
|
+
if (!targets.length) {
|
|
297
|
+
printJson({ stopped: [] });
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
const id = args._[0];
|
|
302
|
+
if (!id) throw new CliError('usage: rly stop <board-id> | rly stop --all');
|
|
303
|
+
const info = loadRunning(id);
|
|
304
|
+
if (!info || !isAlive(info.pid)) {
|
|
305
|
+
removeRunning(id);
|
|
306
|
+
throw new CliError(`board "${id}" is not running.`, 5);
|
|
307
|
+
}
|
|
308
|
+
targets = [info];
|
|
309
|
+
}
|
|
310
|
+
for (const t of targets) {
|
|
311
|
+
try {
|
|
312
|
+
process.kill(t.pid, 'SIGTERM');
|
|
313
|
+
} catch {
|
|
314
|
+
// already gone
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
for (const t of targets) {
|
|
318
|
+
await pollFor(() => (loadRunning(t.id) ? null : true), 5000);
|
|
319
|
+
}
|
|
320
|
+
printJson({
|
|
321
|
+
stopped: targets.map((t) => {
|
|
322
|
+
const record = loadBoard(t.id);
|
|
323
|
+
return { boardId: t.id, status: record?.result?.status ?? 'unknown', draft: record?.draft ?? null };
|
|
324
|
+
}),
|
|
325
|
+
});
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function cmdHistory(args) {
|
|
330
|
+
const limit = args.limit !== undefined ? Number.parseInt(args.limit, 10) || 15 : 15;
|
|
331
|
+
const records = listBoards(limit);
|
|
332
|
+
const runningIds = new Set(listRunning().map((r) => r.id));
|
|
333
|
+
if (args.json) {
|
|
334
|
+
printJson(
|
|
335
|
+
records.map((r) => ({
|
|
336
|
+
id: r.id,
|
|
337
|
+
createdAt: r.createdAt,
|
|
338
|
+
title: r.title,
|
|
339
|
+
status: runningIds.has(r.id) ? 'open' : r.result?.status ?? 'unfinished',
|
|
340
|
+
questions: r.spec.questions.length,
|
|
341
|
+
blocks: (r.spec.blocks || []).length,
|
|
342
|
+
hasDraft: Boolean(r.draft),
|
|
343
|
+
answers: r.result?.answers ?? null,
|
|
344
|
+
}))
|
|
345
|
+
);
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
if (!records.length) {
|
|
349
|
+
console.log(`No saved boards yet (storage: ${HOME}).`);
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
for (const r of records) {
|
|
353
|
+
const status = runningIds.has(r.id) ? 'open' : r.result?.status ?? 'unfinished';
|
|
354
|
+
const nBlocks = (r.spec.blocks || []).length;
|
|
355
|
+
console.log(`${r.id} ${r.createdAt} [${status}] "${r.title}" ${r.spec.questions.length}q${nBlocks ? `+${nBlocks}b` : ''}`);
|
|
356
|
+
}
|
|
357
|
+
console.log(`\nReuse: \`rly reuse <id>\` · spec: \`rly spec <id>\` · reopen w/ answers: \`rly reopen <id>\` · delete: \`rly rm <id>\``);
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function cmdSpec(args) {
|
|
362
|
+
const record = mustLoad(args._[0]);
|
|
363
|
+
console.log(JSON.stringify(record.spec, null, 2));
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function cmdRm(args) {
|
|
368
|
+
const runningIds = new Set(listRunning().map((r) => r.id));
|
|
369
|
+
if (args.all) {
|
|
370
|
+
let removed = 0;
|
|
371
|
+
for (const r of listBoards(0)) {
|
|
372
|
+
if (runningIds.has(r.id)) continue;
|
|
373
|
+
if (deleteBoard(r.id)) removed++;
|
|
374
|
+
}
|
|
375
|
+
printJson({ removed, skippedRunning: runningIds.size });
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
const id = args._[0];
|
|
379
|
+
if (!id) throw new CliError('usage: rly rm <board-id> | rly rm --all');
|
|
380
|
+
if (runningIds.has(id)) throw new CliError(`board "${id}" is running — \`rly stop ${id}\` first.`);
|
|
381
|
+
if (!deleteBoard(id)) throw new CliError(`board "${id}" not found.`, 5);
|
|
382
|
+
printJson({ removed: id });
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const SKILL_SRC = path.join(PKG_ROOT, 'skills', 'relay');
|
|
387
|
+
|
|
388
|
+
const KNOWN_SKILL_DIRS = () => ({
|
|
389
|
+
claude: path.join(os.homedir(), '.claude', 'skills', 'relay'),
|
|
390
|
+
codex: path.join(os.homedir(), '.codex', 'skills', 'relay'),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Pre-rename skill dirs (quest-board). `skill install` removes these so a
|
|
394
|
+
// stale copy doesn't shadow the renamed one.
|
|
395
|
+
const LEGACY_SKILL_DIRS = () => [
|
|
396
|
+
path.join(os.homedir(), '.claude', 'skills', 'quest-board'),
|
|
397
|
+
path.join(os.homedir(), '.codex', 'skills', 'quest-board'),
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
// One-time stderr nudge so agents that only have the CLI discover the skill.
|
|
401
|
+
function firstRunHint() {
|
|
402
|
+
try {
|
|
403
|
+
const marker = path.join(HOME, '.hinted');
|
|
404
|
+
if (fs.existsSync(marker)) return;
|
|
405
|
+
fs.mkdirSync(HOME, { recursive: true });
|
|
406
|
+
fs.writeFileSync(marker, new Date().toISOString());
|
|
407
|
+
const installed = Object.values(KNOWN_SKILL_DIRS()).some((p) => fs.existsSync(path.join(p, 'SKILL.md')));
|
|
408
|
+
if (!installed) {
|
|
409
|
+
process.stderr.write(
|
|
410
|
+
'tip (AI agents): rly bundles a universal skill — install it with `rly skill install`; full guide: `rly agent`\n'
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
// never let the hint break a real command
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Installed skill copies are snapshots — warn when they lag the CLI.
|
|
419
|
+
function skillFreshnessWarning() {
|
|
420
|
+
try {
|
|
421
|
+
for (const [agent, dir] of Object.entries(KNOWN_SKILL_DIRS())) {
|
|
422
|
+
if (!fs.existsSync(path.join(dir, 'SKILL.md'))) continue;
|
|
423
|
+
let installedVersion = null;
|
|
424
|
+
try {
|
|
425
|
+
installedVersion = fs.readFileSync(path.join(dir, '.rly-version'), 'utf8').trim();
|
|
426
|
+
} catch {
|
|
427
|
+
// pre-rename install without a version marker
|
|
428
|
+
}
|
|
429
|
+
if (installedVersion !== VERSION) {
|
|
430
|
+
process.stderr.write(
|
|
431
|
+
`note: the relay skill installed for ${agent} is from rly ${installedVersion ?? '<0.2.0'}, you run ${VERSION} — refresh with \`rly skill install\`\n`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
// best effort only
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function skillTargets(target) {
|
|
441
|
+
const home = os.homedir();
|
|
442
|
+
const known = {
|
|
443
|
+
claude: path.join(home, '.claude', 'skills', 'relay'),
|
|
444
|
+
codex: path.join(home, '.codex', 'skills', 'relay'),
|
|
445
|
+
};
|
|
446
|
+
if (!target || target === true || target === 'auto') {
|
|
447
|
+
const found = Object.values(known).filter((p) => fs.existsSync(path.dirname(path.dirname(p))));
|
|
448
|
+
if (!found.length) {
|
|
449
|
+
throw new CliError('no agent dirs found (~/.claude or ~/.codex). Use --target claude|codex|both|<dir>.');
|
|
450
|
+
}
|
|
451
|
+
return found;
|
|
452
|
+
}
|
|
453
|
+
if (target === 'both') return Object.values(known);
|
|
454
|
+
if (known[target]) return [known[target]];
|
|
455
|
+
return [path.join(path.resolve(target), 'relay')];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Removes stale pre-rename quest-board skill dirs so the renamed skill is the
|
|
459
|
+
// only one an agent sees. Returns the dirs actually removed.
|
|
460
|
+
function removeLegacySkills() {
|
|
461
|
+
const removed = [];
|
|
462
|
+
for (const dir of LEGACY_SKILL_DIRS()) {
|
|
463
|
+
try {
|
|
464
|
+
if (fs.existsSync(dir)) {
|
|
465
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
466
|
+
removed.push(dir);
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
// best effort — a leftover dir we couldn't remove shouldn't fail install
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return removed;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function cmdSkill(rest) {
|
|
476
|
+
const sub = rest[0];
|
|
477
|
+
if (sub === 'path') {
|
|
478
|
+
console.log(SKILL_SRC);
|
|
479
|
+
process.stderr.write('(bundled skill source — copy into your agent with `rly skill install`)\n');
|
|
480
|
+
return 0;
|
|
481
|
+
}
|
|
482
|
+
if (sub === 'install') {
|
|
483
|
+
const args = parseArgs(rest.slice(1));
|
|
484
|
+
const targets = skillTargets(args.target);
|
|
485
|
+
const installed = [];
|
|
486
|
+
for (const t of targets) {
|
|
487
|
+
fs.mkdirSync(path.dirname(t), { recursive: true });
|
|
488
|
+
fs.cpSync(SKILL_SRC, t, { recursive: true });
|
|
489
|
+
fs.writeFileSync(path.join(t, '.rly-version'), VERSION);
|
|
490
|
+
installed.push(t);
|
|
491
|
+
}
|
|
492
|
+
const removedLegacy = removeLegacySkills();
|
|
493
|
+
printJson({ installed, removedLegacy, note: 'most agents pick new skills up immediately; if yours does not, re-list skills or restart the session' });
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
console.log(`relay ships a universal agent skill (Claude Code, Codex, and any SKILL.md-aware agent).
|
|
497
|
+
|
|
498
|
+
bundled at: ${SKILL_SRC}
|
|
499
|
+
install it: rly skill install # auto-detects ~/.claude and ~/.codex
|
|
500
|
+
rly skill install --target claude|codex|both|<dir>
|
|
501
|
+
from repo: npx skills add khanglvm/relay
|
|
502
|
+
|
|
503
|
+
The skill teaches your agent the board spec format (questions + rich blocks +
|
|
504
|
+
annotations), the blocking vs --detach patterns, and visualization sizing.
|
|
505
|
+
Full guide: \`rly agent\`.`);
|
|
506
|
+
return 0;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function cmdAgent() {
|
|
510
|
+
console.log(fs.readFileSync(path.join(PKG_ROOT, 'docs', 'AGENT.md'), 'utf8'));
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function cmdServeInternal(args) {
|
|
515
|
+
const id = args.id;
|
|
516
|
+
if (!id) throw new CliError('__serve: missing --id');
|
|
517
|
+
const { done } = await runBoard({
|
|
518
|
+
id,
|
|
519
|
+
port: args.port !== undefined ? Number.parseInt(args.port, 10) || 0 : 0,
|
|
520
|
+
open: args.open !== false,
|
|
521
|
+
timeoutSec: args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800,
|
|
522
|
+
quiet: true,
|
|
523
|
+
});
|
|
524
|
+
await done;
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function printHelp() {
|
|
529
|
+
console.log(`rly ${VERSION} — relay: browser question boards, rich blocks & annotations for AI agents
|
|
530
|
+
|
|
531
|
+
USAGE
|
|
532
|
+
rly ask --file spec.json create board, open browser, BLOCK until submit, print answers JSON
|
|
533
|
+
rly ask --file - < spec.json spec from stdin
|
|
534
|
+
rly ask -q "Deploy?::yesno" -q "!Env::single::dev,staging,prod"
|
|
535
|
+
quick inline questions ("!" = required, label::type::options)
|
|
536
|
+
rly ask ... --detach no blocking: prints {boardId,url} now; collect via \`rly wait <id>\`
|
|
537
|
+
rly show --html-file viz.html visualization-only board (submit button = acknowledge)
|
|
538
|
+
rly wait <id> [--timeout 3600] block until board finishes, print result JSON
|
|
539
|
+
rly result <id> result/status now (includes live autosaved draft while open)
|
|
540
|
+
rly list [--json] running boards
|
|
541
|
+
rly open [id] re-open the browser tab of a running board
|
|
542
|
+
rly reopen <id> serve a saved board again, prefilled with its saved answers
|
|
543
|
+
rly reuse <id> [--dump] re-run a past board as a new board (--dump prints its spec)
|
|
544
|
+
rly stop <id> | --all stop running board(s) (status: cancelled, draft preserved)
|
|
545
|
+
rly history [--limit n] [--json] saved boards
|
|
546
|
+
rly spec <id> print a saved board's spec JSON (edit, then ask --file again)
|
|
547
|
+
rly rm <id> | --all delete saved board(s)
|
|
548
|
+
rly schema JSON Schema of the board spec
|
|
549
|
+
rly agent FULL GUIDE for AI agents (spec format, blocks, sizing, patterns)
|
|
550
|
+
rly skill [install|path] bundled universal agent skill (Claude Code, Codex, …)
|
|
551
|
+
|
|
552
|
+
COMMON FLAGS
|
|
553
|
+
--title <s> --intro <s> --html-file <f> --height <px> --submit-label <s>
|
|
554
|
+
--timeout <sec> (default 1800; 0 = none) --port <n> --no-open --detach
|
|
555
|
+
|
|
556
|
+
EXIT CODES 0 submitted/acknowledged · 2 timeout · 3 cancelled · 4 usage · 5 not found
|
|
557
|
+
|
|
558
|
+
NOTES answers & annotations autosave in real time (drafts survive timeout/cancel);
|
|
559
|
+
submitting auto-closes the tab and unblocks the CLI.
|
|
560
|
+
|
|
561
|
+
AI AGENTS run \`rly agent\` for the complete machine-oriented guide.
|
|
562
|
+
a universal skill is bundled — install with \`rly skill install\`
|
|
563
|
+
(or from the repo: npx skills add khanglvm/relay)`);
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export async function main(argv) {
|
|
568
|
+
const [cmd, ...rest] = argv;
|
|
569
|
+
if (cmd !== '__serve') firstRunHint();
|
|
570
|
+
if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h' || cmd === 'agent' || cmd === 'skill') {
|
|
571
|
+
skillFreshnessWarning();
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
switch (cmd) {
|
|
575
|
+
case undefined:
|
|
576
|
+
case 'help':
|
|
577
|
+
case '--help':
|
|
578
|
+
case '-h':
|
|
579
|
+
return printHelp();
|
|
580
|
+
case 'version':
|
|
581
|
+
case '--version':
|
|
582
|
+
case '-v':
|
|
583
|
+
console.log(VERSION);
|
|
584
|
+
return 0;
|
|
585
|
+
case 'ask':
|
|
586
|
+
return await cmdAsk(parseArgs(rest), 'ask');
|
|
587
|
+
case 'show':
|
|
588
|
+
return await cmdAsk(parseArgs(rest), 'show');
|
|
589
|
+
case 'reopen':
|
|
590
|
+
return await cmdReopen(parseArgs(rest));
|
|
591
|
+
case 'reuse':
|
|
592
|
+
return await cmdReuse(parseArgs(rest));
|
|
593
|
+
case 'wait':
|
|
594
|
+
return await cmdWait(parseArgs(rest));
|
|
595
|
+
case 'result':
|
|
596
|
+
return cmdResult(parseArgs(rest));
|
|
597
|
+
case 'list':
|
|
598
|
+
return cmdList(parseArgs(rest));
|
|
599
|
+
case 'open':
|
|
600
|
+
return cmdOpen(parseArgs(rest));
|
|
601
|
+
case 'stop':
|
|
602
|
+
return await cmdStop(parseArgs(rest));
|
|
603
|
+
case 'history':
|
|
604
|
+
return cmdHistory(parseArgs(rest));
|
|
605
|
+
case 'spec':
|
|
606
|
+
return cmdSpec(parseArgs(rest));
|
|
607
|
+
case 'rm':
|
|
608
|
+
return cmdRm(parseArgs(rest));
|
|
609
|
+
case 'skill':
|
|
610
|
+
return cmdSkill(rest);
|
|
611
|
+
case 'agent':
|
|
612
|
+
return cmdAgent();
|
|
613
|
+
case 'schema':
|
|
614
|
+
console.log(JSON.stringify(SPEC_SCHEMA, null, 2));
|
|
615
|
+
return 0;
|
|
616
|
+
case '__serve':
|
|
617
|
+
return await cmdServeInternal(parseArgs(rest));
|
|
618
|
+
default:
|
|
619
|
+
throw new CliError(`unknown command "${cmd}". Run \`rly help\`.`);
|
|
620
|
+
}
|
|
621
|
+
} catch (err) {
|
|
622
|
+
if (err instanceof CliError) {
|
|
623
|
+
process.stderr.write(`rly: ${err.message}\n`);
|
|
624
|
+
if (err.code === 4) process.stderr.write('run `rly agent` for the agent-oriented guide\n');
|
|
625
|
+
return err.code;
|
|
626
|
+
}
|
|
627
|
+
throw err;
|
|
628
|
+
}
|
|
629
|
+
}
|
package/src/open.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function openUrl(url) {
|
|
4
|
+
try {
|
|
5
|
+
const p = process.platform;
|
|
6
|
+
const [cmd, args] =
|
|
7
|
+
p === 'darwin' ? ['open', [url]]
|
|
8
|
+
: p === 'win32' ? ['cmd', ['/c', 'start', '""', url.replace(/&/g, '^&')]]
|
|
9
|
+
: ['xdg-open', [url]];
|
|
10
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|