@junoflow/cli 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/src/index.js CHANGED
@@ -1,123 +1,554 @@
1
1
  import { parseArgs } from 'node:util';
2
2
 
3
- import { check } from './commands/check.js';
4
- import { deploy } from './commands/deploy.js';
5
- import { init } from './commands/init.js';
6
- import { pull } from './commands/pull.js';
7
- import { types } from './commands/types.js';
8
- import { verifyTypes } from './commands/verify-types.js';
9
- import { DiagnosticsError, UserError } from './errors.js';
3
+ import {
4
+ AGENT_SKILL_INSTALL_USAGE,
5
+ agentSkillInstall,
6
+ } from './commands/agent-skill-install.js';
7
+ import { ASK_USAGE, ask } from './commands/ask.js';
8
+ import { CHECK_USAGE, check } from './commands/check.js';
9
+ import { CONFIG_SET_USAGE, configSet } from './commands/config.js';
10
+ import {
11
+ CREDENTIAL_SET_USAGE,
12
+ credentialSet,
13
+ } from './commands/credential-set.js';
14
+ import { DEPLOY_USAGE, deploy } from './commands/deploy.js';
15
+ import { FLOW_CHECK_USAGE, flowCheck } from './commands/flow-check.js';
16
+ import { FLOW_DEPLOY_USAGE, flowDeploy } from './commands/flow-deploy.js';
17
+ import {
18
+ FLOW_GEN_TYPES_USAGE,
19
+ flowGenTypes,
20
+ } from './commands/flow-gen-types.js';
21
+ import { FLOW_INIT_USAGE, flowInit } from './commands/flow-init.js';
22
+ import { FLOW_PULL_USAGE, flowPull } from './commands/flow-pull.js';
23
+ import { GEN_TYPES_USAGE, genTypes } from './commands/gen-types.js';
24
+ import { INIT_USAGE, init } from './commands/init.js';
25
+ import { PULL_USAGE, pull } from './commands/pull.js';
26
+ import { SKILL_USAGE, skill } from './commands/skill.js';
27
+ import {
28
+ SESSION_APPROVE_USAGE,
29
+ sessionApprove,
30
+ } from './commands/session-approve.js';
31
+ import { runTool } from './commands/tool.js';
32
+ import { VERIFY_TYPES_USAGE, verifyTypes } from './commands/verify-types.js';
33
+ import {
34
+ ConflictError,
35
+ DiagnosticsError,
36
+ UnreachableError,
37
+ UsageError,
38
+ UserError,
39
+ } from './errors.js';
10
40
  import { formatDiagnostics } from './diagnostics.js';
41
+ import {
42
+ EXIT_CONFLICT,
43
+ EXIT_FAILURE,
44
+ EXIT_OK,
45
+ EXIT_UNREACHABLE,
46
+ EXIT_USAGE,
47
+ } from './exit.js';
48
+ import {
49
+ groupHelp,
50
+ nonToolHelp,
51
+ verbHelp,
52
+ WORKING_COPY_HEADING,
53
+ workingCopyListing,
54
+ } from './help.js';
55
+ import { closest, closestByVerb, levenshtein } from './suggest.js';
56
+ import { groupOf, normalizeVerb, rowFor, rows } from './verbs.js';
11
57
 
12
- const COMMANDS = {
13
- init,
14
- types,
15
- 'verify-types': verifyTypes,
16
- check,
17
- deploy,
18
- pull,
58
+ const URL_OPTION = { url: { type: 'string' } };
59
+
60
+ /**
61
+ * The verbs.json rows that call no tool, by command. A row is dispatchable
62
+ * only once it has a handler here; until then the CLI treats it as unknown
63
+ * rather than advertising a command it cannot run.
64
+ */
65
+ const NON_TOOL = {
66
+ skill: {
67
+ run: skill,
68
+ options: { ...URL_OPTION, json: { type: 'boolean', default: false } },
69
+ usage: SKILL_USAGE,
70
+ },
71
+ ask: {
72
+ run: ask,
73
+ options: {
74
+ ...URL_OPTION,
75
+ session: { type: 'string' },
76
+ wait: { type: 'string' },
77
+ },
78
+ usage: ASK_USAGE,
79
+ },
80
+ 'session approve': {
81
+ run: sessionApprove,
82
+ options: { ...URL_OPTION, wait: { type: 'string' } },
83
+ usage: SESSION_APPROVE_USAGE,
84
+ },
85
+ 'credential set': {
86
+ run: credentialSet,
87
+ options: {
88
+ ...URL_OPTION,
89
+ name: { type: 'string' },
90
+ description: { type: 'string' },
91
+ },
92
+ usage: CREDENTIAL_SET_USAGE,
93
+ },
94
+ 'config set': { run: configSet, options: {}, usage: CONFIG_SET_USAGE },
95
+ 'agent-skill install': {
96
+ run: agentSkillInstall,
97
+ options: {
98
+ ...URL_OPTION,
99
+ dir: { type: 'string' },
100
+ 'allow-change': { type: 'boolean', default: false },
101
+ 'working-copy': { type: 'boolean', default: false },
102
+ 'agents-md': { type: 'boolean', default: false },
103
+ json: { type: 'boolean', default: false },
104
+ },
105
+ usage: AGENT_SKILL_INSTALL_USAGE,
106
+ },
107
+ };
108
+
109
+ /**
110
+ * The working-copy commands: verbs.json rows with `working_copy: true`, which
111
+ * act on the project in the current directory rather than on the instance
112
+ * (plan-external-agents-flows-as-files.md, Decision 11). Like NON_TOOL, a row
113
+ * is dispatchable only once it has a handler here. `positional` is true only
114
+ * for `init`, which names what to make; every other one refuses a positional
115
+ * (see runRow), because the working copy, not an argument, says what it acts
116
+ * on.
117
+ */
118
+ const WORKING_COPY = {
119
+ 'flow init': {
120
+ run: flowInit,
121
+ options: { ...URL_OPTION, here: { type: 'boolean', default: false } },
122
+ usage: FLOW_INIT_USAGE,
123
+ positional: true,
124
+ },
125
+ 'flow gen-types': {
126
+ run: flowGenTypes,
127
+ options: URL_OPTION,
128
+ usage: FLOW_GEN_TYPES_USAGE,
129
+ },
130
+ 'flow check': {
131
+ run: flowCheck,
132
+ options: {
133
+ ...URL_OPTION,
134
+ 'skip-local': { type: 'boolean', default: false },
135
+ json: { type: 'boolean', default: false },
136
+ },
137
+ usage: FLOW_CHECK_USAGE,
138
+ },
139
+ 'flow deploy': {
140
+ run: flowDeploy,
141
+ options: {
142
+ ...URL_OPTION,
143
+ watch: { type: 'boolean', default: false },
144
+ force: { type: 'boolean', default: false },
145
+ json: { type: 'boolean', default: false },
146
+ },
147
+ usage: FLOW_DEPLOY_USAGE,
148
+ },
149
+ 'flow pull': {
150
+ run: flowPull,
151
+ options: { ...URL_OPTION, force: { type: 'boolean', default: false } },
152
+ usage: FLOW_PULL_USAGE,
153
+ },
154
+ 'integration init': {
155
+ run: init,
156
+ options: {
157
+ ...URL_OPTION,
158
+ name: { type: 'string' },
159
+ here: { type: 'boolean', default: false },
160
+ },
161
+ usage: INIT_USAGE,
162
+ positional: true,
163
+ },
164
+ 'integration gen-types': {
165
+ run: genTypes,
166
+ options: URL_OPTION,
167
+ usage: GEN_TYPES_USAGE,
168
+ },
169
+ 'integration verify-types': {
170
+ run: verifyTypes,
171
+ options: {},
172
+ usage: VERIFY_TYPES_USAGE,
173
+ },
174
+ // check and deploy keep --id: deploy under a different install id than the
175
+ // manifest's. pull keeps it too, to pull such an install back.
176
+ 'integration check': {
177
+ run: check,
178
+ options: {
179
+ ...URL_OPTION,
180
+ id: { type: 'string' },
181
+ 'skip-local': { type: 'boolean', default: false },
182
+ json: { type: 'boolean', default: false },
183
+ },
184
+ usage: CHECK_USAGE,
185
+ },
186
+ 'integration deploy': {
187
+ run: deploy,
188
+ options: {
189
+ ...URL_OPTION,
190
+ id: { type: 'string' },
191
+ watch: { type: 'boolean', default: false },
192
+ json: { type: 'boolean', default: false },
193
+ },
194
+ usage: DEPLOY_USAGE,
195
+ },
196
+ 'integration pull': {
197
+ run: pull,
198
+ options: {
199
+ ...URL_OPTION,
200
+ id: { type: 'string' },
201
+ force: { type: 'boolean', default: false },
202
+ },
203
+ usage: PULL_USAGE,
204
+ },
19
205
  };
20
206
 
21
- const USAGE = `juno — develop Juno integrations
207
+ /** The handler of a non-tool row, working-copy or not, or undefined. */
208
+ function handlerFor(row) {
209
+ if (row.tool) return undefined;
210
+ const table = row.working_copy ? WORKING_COPY : NON_TOOL;
211
+ return Object.hasOwn(table, row.command) ? table[row.command] : undefined;
212
+ }
213
+
214
+ /**
215
+ * The rows the CLI can run: every tool row, and the non-tool and
216
+ * working-copy rows above.
217
+ */
218
+ const DISPATCHABLE = rows.filter((row) => row.tool || handlerFor(row));
219
+ const GROUPS = new Set(DISPATCHABLE.map(groupOf).filter(Boolean));
220
+
221
+ function dispatchable(command) {
222
+ const row = rowFor(command);
223
+ return row && DISPATCHABLE.includes(row) ? row : undefined;
224
+ }
225
+
226
+ const USAGE = `juno — operate a Juno instance, and develop flows and integrations for it
22
227
 
23
228
  Usage: juno <command> [options]
229
+ juno <group> <verb> [id] [--input <json>|-|@file] [--jq <expr>]
230
+
231
+ Groups: ${[...GROUPS].join(', ')}
232
+ Commands: ${DISPATCHABLE.filter((r) => !groupOf(r))
233
+ .map((r) => r.command)
234
+ .join(', ')}
235
+ Run \`juno <group> --help\` for a group's commands.
24
236
 
25
- Commands:
26
- init <name> scaffold a new integration project from a Juno
27
- types generate types/ from manifest.json
28
- verify-types check types/ still matches manifest.json (offline)
29
- check typecheck and lint locally, then validate against a Juno
30
- deploy [--watch] upload, verify, and activate a new version
31
- pull write back the manifest and sources Juno holds
32
-
33
- Options:
34
- --url <url> the Juno instance; else $JUNO_URL, else package.json juno.target
35
- --name <name> address an install under a different name than the manifest
36
- --watch deploy on every save (deploy only)
37
- --force overwrite a dirty working tree (pull only)
38
- --here scaffold into the current directory (init only)
39
- --skip-local skip the local tsc/eslint pass (check only)
40
- -h, --help this
237
+ ${WORKING_COPY_HEADING}:
238
+ ${workingCopyListing(DISPATCHABLE, handlerFor)}
239
+
240
+ --url <url> picks the instance; else $JUNO_URL, package.json juno.target,
241
+ then the user config (juno config set url <url>).
41
242
  `;
42
243
 
43
244
  /**
44
245
  * run is the whole CLI, injectable so the tests drive it without spawning a
45
- * process: io carries cwd and the three output streams, and a command may be
46
- * handed a client instead of building one from a URL.
246
+ * process: io carries cwd, env, the TTY bit, stdin and the three output
247
+ * streams, and a command may be handed a client instead of building one from
248
+ * a URL. It returns the exit code (exit.js).
47
249
  */
48
250
  export async function run(argv, io = defaultIO()) {
49
- const [command, ...rest] = argv;
50
-
51
- if (
52
- !command ||
53
- command === '--help' ||
54
- command === '-h' ||
55
- command === 'help'
56
- ) {
251
+ try {
252
+ return await dispatch(argv, io);
253
+ } catch (err) {
254
+ // Diagnostics print as file:line:col so an editor can click through; a
255
+ // UserError is a sentence about their input, and neither deserves a stack.
256
+ if (err instanceof DiagnosticsError) {
257
+ io.error(formatDiagnostics(err.diagnostics));
258
+ io.error(`\n${err.diagnostics.length} problem(s): ${err.message}`);
259
+ return EXIT_FAILURE;
260
+ }
261
+ if (err instanceof ConflictError) {
262
+ io.error(err.message);
263
+ return EXIT_CONFLICT;
264
+ }
265
+ if (err instanceof UsageError) {
266
+ io.error(err.message);
267
+ return EXIT_USAGE;
268
+ }
269
+ if (err instanceof UnreachableError) {
270
+ io.error(err.message);
271
+ return EXIT_UNREACHABLE;
272
+ }
273
+ if (err instanceof UserError) {
274
+ io.error(err.message);
275
+ return EXIT_FAILURE;
276
+ }
277
+ throw err;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * dispatch is two-level: the first word is a top-level row (`discover`) or a
283
+ * group, and a group's second word is the verb. A
284
+ * leading `--url` is accepted before the first word, so
285
+ * `juno --url <u> state get x` reads the way an alias would write it.
286
+ */
287
+ async function dispatch(argv, io) {
288
+ const { url, rest } = leadingURL(argv);
289
+ const [first, ...tail] = rest;
290
+
291
+ if (!first || first === '--help' || first === '-h' || first === 'help') {
57
292
  io.log(USAGE);
58
- return command ? 0 : 1;
293
+ return first ? EXIT_OK : EXIT_USAGE;
59
294
  }
60
- const handler = COMMANDS[command];
61
- if (!handler) {
62
- io.error(`unknown command: ${command}\n`);
63
- io.error(USAGE);
64
- return 1;
295
+
296
+ const top = dispatchable(first);
297
+ if (top) return runRow(top, tail, url, io);
298
+
299
+ if (GROUPS.has(first)) {
300
+ const [verb, ...args] = tail;
301
+ // `juno state` alone is its help, as a usage error: the person has not
302
+ // said what to do yet.
303
+ if (!verb || verb.startsWith('-')) {
304
+ return runCommand(
305
+ { options: URL_OPTION },
306
+ tail,
307
+ url,
308
+ io,
309
+ (parsed) => showGroupHelp(first, parsed, io, EXIT_OK),
310
+ (parsed) => showGroupHelp(first, parsed, io, EXIT_USAGE)
311
+ );
312
+ }
313
+ const row = dispatchable(`${first} ${normalizeVerb(verb)}`);
314
+ if (!row) {
315
+ throw unknownCommand(`${first} ${verb}`, [
316
+ `${first} ${normalizeVerb(verb)}`,
317
+ ]);
318
+ }
319
+ return runRow(row, args, url, io);
65
320
  }
66
321
 
322
+ const second = tail[0] && !tail[0].startsWith('-') ? tail[0] : null;
323
+ if (second) {
324
+ throw unknownTopLevel(`${first} ${second}`, first, [
325
+ `${first} ${normalizeVerb(second)}`,
326
+ first,
327
+ ]);
328
+ }
329
+ throw unknownTopLevel(first, first, [first]);
330
+ }
331
+
332
+ async function showGroupHelp(group, args, io, code) {
333
+ io.log(await groupHelp(group, DISPATCHABLE, handlerFor, args, io));
334
+ return code;
335
+ }
336
+
337
+ /** runRow runs one verbs.json row, a tool's or a non-tool command's. */
338
+ function runRow(row, argv, url, io) {
339
+ if (!row.tool) {
340
+ const handler = handlerFor(row);
341
+ const run =
342
+ row.working_copy && !handler.positional
343
+ ? (args, io) => {
344
+ refusePositional(row, args);
345
+ return handler.run(args, io);
346
+ }
347
+ : handler.run;
348
+ return runCommand(
349
+ handler,
350
+ argv,
351
+ url,
352
+ io,
353
+ () => {
354
+ io.log(nonToolHelp(row, handler));
355
+ return EXIT_OK;
356
+ },
357
+ run
358
+ );
359
+ }
360
+
361
+ const options = {
362
+ ...URL_OPTION,
363
+ input: { type: 'string' },
364
+ jq: { type: 'string' },
365
+ };
366
+ for (const [flag, spec] of Object.entries(row.flags ?? {})) {
367
+ options[flag] = { type: 'string', multiple: spec.multiple === true };
368
+ }
369
+ return runCommand(
370
+ { run: (args) => runTool(row, args, io), options },
371
+ argv,
372
+ url,
373
+ io,
374
+ async (args) => {
375
+ io.log(await verbHelp(row, args, io));
376
+ return EXIT_OK;
377
+ }
378
+ );
379
+ }
380
+
381
+ /**
382
+ * runCommand parses a command's own flags (plus --help) and runs it, or its
383
+ * help. A `--url` given before the command word fills in when the command's
384
+ * own is absent. `run` defaults to the command's own; a group without a verb
385
+ * passes its help instead.
386
+ */
387
+ async function runCommand(command, argv, url, io, help, run = command.run) {
67
388
  let parsed;
68
389
  try {
69
390
  parsed = parseArgs({
70
- args: rest,
391
+ args: argv,
71
392
  allowPositionals: true,
72
393
  options: {
73
- url: { type: 'string' },
74
- name: { type: 'string' },
75
- watch: { type: 'boolean', default: false },
76
- force: { type: 'boolean', default: false },
77
- here: { type: 'boolean', default: false },
78
- 'skip-local': { type: 'boolean', default: false },
394
+ ...command.options,
79
395
  help: { type: 'boolean', short: 'h', default: false },
80
396
  },
81
397
  });
82
398
  } catch (err) {
83
- io.error(err.message);
84
- return 1;
85
- }
86
- if (parsed.values.help) {
87
- io.log(USAGE);
88
- return 0;
399
+ throw new UsageError(err.message);
89
400
  }
90
401
 
91
402
  const args = { ...parsed.values, _: parsed.positionals, client: io.client };
92
- try {
93
- return (await handler(args, io)) ?? 0;
94
- } catch (err) {
95
- // Diagnostics print as file:line:col so an editor can click through; a
96
- // UserError is a sentence about their input, and neither deserves a stack.
97
- if (err instanceof DiagnosticsError) {
98
- io.error(formatDiagnostics(err.diagnostics));
99
- io.error(`\n${err.diagnostics.length} problem(s): ${err.message}`);
100
- return 1;
101
- }
102
- if (err instanceof UserError) {
103
- io.error(err.message);
104
- return 1;
403
+ if (url !== undefined && args.url === undefined) args.url = url;
404
+ if (parsed.values.help) return help(args);
405
+ return (await run(args, io)) ?? EXIT_OK;
406
+ }
407
+
408
+ /**
409
+ * refusePositional: a working-copy command acts on the project in the current
410
+ * directory, so `juno flow deploy porch-light` is a usage error that says so
411
+ * and points at the command that makes a working copy of porch-light —
412
+ * rather than deploying whatever directory it happens to run in.
413
+ */
414
+ function refusePositional(row, args) {
415
+ if (args._.length === 0) return;
416
+ const group = groupOf(row);
417
+ throw new UsageError(
418
+ `juno ${row.command} takes no argument: it acts on the working copy in ` +
419
+ 'the current directory. ' +
420
+ `To get a working copy of "${args._[0]}", run \`juno ${group} init ${args._[0]}\` ` +
421
+ 'and run this from inside it.'
422
+ );
423
+ }
424
+
425
+ /** leadingURL takes `--url <u>` / `--url=<u>` off the front of argv. */
426
+ function leadingURL(argv) {
427
+ let url;
428
+ let i = 0;
429
+ while (i < argv.length) {
430
+ if (argv[i] === '--url' && i + 1 < argv.length) {
431
+ url = argv[i + 1];
432
+ i += 2;
433
+ } else if (argv[i].startsWith('--url=')) {
434
+ url = argv[i].slice('--url='.length);
435
+ i += 1;
436
+ } else {
437
+ break;
105
438
  }
106
- throw err;
107
439
  }
440
+ return { url, rest: argv.slice(i) };
441
+ }
442
+
443
+ /**
444
+ * unknownCommand names what was typed and, when one is close, the command
445
+ * that was probably meant — rather than the whole help, which buries the fix.
446
+ */
447
+ function unknownCommand(words, attempts) {
448
+ const candidates = [...DISPATCHABLE.map((row) => row.command), ...GROUPS];
449
+ const guess = closest(attempts, candidates);
450
+ const hint = guess ? `did you mean "juno ${guess}"?` : 'see juno --help';
451
+ return new UsageError(`unknown command "juno ${words}"; ${hint}`);
452
+ }
453
+
454
+ /**
455
+ * unknownTopLevel is unknownCommand for a first word that is neither a
456
+ * command nor a group. It may be a verb typed without its group: a bare
457
+ * `deploy` is nearer `juno integration deploy` (and `juno flow deploy`) than
458
+ * any top-level command, and the integration commands lived at the top level until
459
+ * 0.5.0. A verb match wins only when it is strictly nearer than every whole
460
+ * command, so a mistyped group (`juno stat get`) still finds its command.
461
+ */
462
+ function unknownTopLevel(words, first, attempts) {
463
+ const commands = DISPATCHABLE.map((row) => row.command);
464
+ const whole = closest(attempts, [...commands, ...GROUPS]);
465
+ const wholeDistance = whole
466
+ ? Math.min(...attempts.map((a) => levenshtein(a, whole)))
467
+ : Infinity;
468
+ const byVerb = closestByVerb(normalizeVerb(first), commands);
469
+ if (byVerb.commands.length === 0 || byVerb.distance >= wholeDistance) {
470
+ return unknownCommand(words, attempts);
471
+ }
472
+ const quoted = byVerb.commands.map((c) => `"juno ${c}"`);
473
+ const list =
474
+ quoted.length === 1
475
+ ? quoted[0]
476
+ : `${quoted.slice(0, -1).join(', ')} or ${quoted.at(-1)}`;
477
+ return new UsageError(
478
+ `unknown command "juno ${words}"; did you mean ${list}?`
479
+ );
108
480
  }
109
481
 
110
482
  export function defaultIO() {
111
483
  return {
112
484
  cwd: process.cwd(),
485
+ env: process.env,
113
486
  client: null,
487
+ isTTY: process.stdout.isTTY === true,
488
+ stdinIsTTY: process.stdin.isTTY === true,
489
+ readStdin,
490
+ readSecret,
114
491
  log: (msg) => process.stdout.write(`${msg}\n`),
492
+ write: (text) => process.stdout.write(text),
115
493
  warn: (msg) => process.stderr.write(`${msg}\n`),
116
494
  error: (msg) => process.stderr.write(`${msg}\n`),
117
495
  };
118
496
  }
119
497
 
120
498
  export { JunoClient } from './api.js';
121
- export { DiagnosticsError, UserError } from './errors.js';
499
+ export {
500
+ DiagnosticsError,
501
+ UnreachableError,
502
+ UsageError,
503
+ UserError,
504
+ } from './errors.js';
122
505
  export { resolveTarget } from './target.js';
123
506
  export { readProject } from './project.js';
507
+
508
+ /** readStdin reads the whole of stdin; only `--input -` ever calls it. */
509
+ async function readStdin() {
510
+ const chunks = [];
511
+ for await (const chunk of process.stdin) chunks.push(chunk);
512
+ return Buffer.concat(chunks).toString('utf8');
513
+ }
514
+
515
+ /**
516
+ * readSecret prompts on stderr and reads one line from the terminal with echo
517
+ * off: raw mode, so no keystroke is echoed, restored before it returns.
518
+ * Backspace edits; Enter or Ctrl-D ends the line; Ctrl-C restores the mode
519
+ * and exits 130, as an interrupted command does. Only `credential set` calls
520
+ * it, and only once stdin is known to be a TTY.
521
+ */
522
+ function readSecret(prompt) {
523
+ const stdin = process.stdin;
524
+ return new Promise((resolve) => {
525
+ let value = [];
526
+ const wasRaw = stdin.isRaw;
527
+ const done = () => {
528
+ stdin.off('data', onData);
529
+ stdin.setRawMode(wasRaw);
530
+ stdin.pause();
531
+ process.stderr.write('\n');
532
+ };
533
+ const onData = (chunk) => {
534
+ for (const ch of chunk) {
535
+ if (ch === '\u0003') {
536
+ done();
537
+ process.exit(130);
538
+ }
539
+ if (ch === '\r' || ch === '\n' || ch === '\u0004') {
540
+ done();
541
+ resolve(value.join(''));
542
+ return;
543
+ }
544
+ if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
545
+ else value.push(ch);
546
+ }
547
+ };
548
+ process.stderr.write(prompt);
549
+ stdin.setEncoding('utf8');
550
+ stdin.setRawMode(true);
551
+ stdin.on('data', onData);
552
+ stdin.resume();
553
+ });
554
+ }
@@ -0,0 +1,55 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+
5
+ /** Where `juno agent-skill install` writes the skill, relative to a root. */
6
+ export const SKILL_DIR = join('.claude', 'skills', 'juno');
7
+
8
+ const VERSION_LINE = /^\s*(?:<!--\s*)?juno-skill-version:\s*"?([^"\s]+)"?/m;
9
+
10
+ /**
11
+ * installedSkill finds the Juno skill a harness would load: SKILL.md under
12
+ * `.claude/skills/juno` in the current directory or the nearest parent that
13
+ * has one, then in the home directory. It answers the first file found and the
14
+ * version it records (null when it records none), or null when there is no
15
+ * file. A file that cannot be read counts as absent: the check it serves is a
16
+ * courtesy, never a failure.
17
+ */
18
+ export async function installedSkill(cwd, env = process.env) {
19
+ const candidates = [];
20
+ let dir = resolve(cwd);
21
+ for (;;) {
22
+ candidates.push(join(dir, SKILL_DIR, 'SKILL.md'));
23
+ const parent = dirname(dir);
24
+ if (parent === dir) break;
25
+ dir = parent;
26
+ }
27
+ candidates.push(join(env.HOME || homedir(), SKILL_DIR, 'SKILL.md'));
28
+
29
+ for (const path of candidates) {
30
+ let text;
31
+ try {
32
+ text = await readFile(path, 'utf8');
33
+ } catch {
34
+ continue;
35
+ }
36
+ return { path, version: VERSION_LINE.exec(text)?.[1] ?? null };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * warnIfStale says on stderr when the installed skill records a version other
43
+ * than the one the instance serves (`agent_skill_version` on GET /api/skills).
44
+ * Silent when either side has none.
45
+ */
46
+ export async function warnIfStale(index, target, io) {
47
+ const served = index?.agent_skill_version;
48
+ if (!served) return;
49
+ const installed = await installedSkill(io.cwd, io.env);
50
+ if (!installed?.version || installed.version === served) return;
51
+ io.warn(
52
+ `the installed Juno skill (${installed.path}) is version ${installed.version}; ` +
53
+ `${target} serves ${served} — run \`juno agent-skill install\` to update it`
54
+ );
55
+ }