@junoflow/cli 0.15.0 → 0.17.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/api.js CHANGED
@@ -281,9 +281,11 @@ export class JunoClient {
281
281
  async callTool(name, input) {
282
282
  const path = `/api/tools/${encodeURIComponent(name)}`;
283
283
  const res = await this.#request('POST', path, input);
284
+ const skills = skillsHeader(res);
284
285
  if (res.status === 422) {
285
286
  throw new UsageError(
286
- `invalid input for ${name}: ${validationDetail(res)}`
287
+ `invalid input for ${name}: ${validationDetail(res)}` +
288
+ (skills.length ? `\n${skillsPointer(skills)}` : '')
287
289
  );
288
290
  }
289
291
  if (res.status === 404) {
@@ -293,7 +295,7 @@ export class JunoClient {
293
295
  );
294
296
  }
295
297
  if (!res.ok) throw failure('POST', path, res);
296
- return { result: jsonBody('POST', path, res), text: res.text };
298
+ return { result: jsonBody('POST', path, res), text: res.text, skills };
297
299
  }
298
300
 
299
301
  /**
@@ -306,8 +308,8 @@ export class JunoClient {
306
308
  }
307
309
 
308
310
  /**
309
- * GET /api/skills — `{skills: [{name, description}], agent_skill_version}`,
310
- * sorted by name. `authoring` (`flow` or `integration`) narrows it to the
311
+ * GET /api/skills — `{skills: [{name, description, applies_to}]}`, sorted
312
+ * by name. `authoring` (`flow` or `integration`) narrows it to the
311
313
  * skills for writing that kind of working copy.
312
314
  */
313
315
  async listSkills({ authoring } = {}) {
@@ -318,12 +320,20 @@ export class JunoClient {
318
320
  }
319
321
 
320
322
  /**
321
- * GET /api/agent-skill — `{version, skill_md, agents_md, permissions,
322
- * working_copy_permissions}`: the skill a coding agent installs and the
323
- * permission rules that go with it, both rendered by the server.
323
+ * GET /api/agent-primer — `{body}`: the markdown `juno --help` prints
324
+ * between its usage lines and the command listing, rendered by the server.
324
325
  */
325
- async getAgentSkill() {
326
- return this.#json('GET', '/api/agent-skill');
326
+ async getAgentPrimer() {
327
+ return this.#json('GET', '/api/agent-primer');
328
+ }
329
+
330
+ /**
331
+ * GET /api/agent-permissions — `{read, change, working_copy: {read,
332
+ * change}}`: Claude Code permission rules for the juno commands and a
333
+ * working copy's npm scripts, by effect, rendered by the server.
334
+ */
335
+ async getAgentPermissions() {
336
+ return this.#json('GET', '/api/agent-permissions');
327
337
  }
328
338
 
329
339
  /**
@@ -456,6 +466,49 @@ export class JunoClient {
456
466
  return jsonBody('GET', path, res);
457
467
  }
458
468
 
469
+ /**
470
+ * GET /api/approvals/{id} — one approval as it stands, or null when there
471
+ * is none. Readable at read scope, so the key that asked can poll it.
472
+ */
473
+ async getApproval(id) {
474
+ const path = `/api/approvals/${encodeURIComponent(id)}`;
475
+ const res = await this.#request('GET', path);
476
+ if (res.status === 404) return null;
477
+ if (!res.ok) throw failure('GET', path, res);
478
+ return jsonBody('GET', path, res);
479
+ }
480
+
481
+ /**
482
+ * rest is one call to a REST route the CLI fronts without a tool (the
483
+ * session, whoami and version commands): the answer's JSON body, or a
484
+ * UserError carrying the server's words — a 404 as `notFound` when given,
485
+ * else just those words, since it is the id the person typed that is
486
+ * unknown.
487
+ */
488
+ async rest(method, path, body, { notFound } = {}) {
489
+ const res = await this.#request(method, path, body);
490
+ if (res.status === 404) {
491
+ throw new UserError(notFound ?? problemDetail(res));
492
+ }
493
+ if (res.status === 422) throw new UsageError(validationDetail(res));
494
+ return this.#answer(method, path, res);
495
+ }
496
+
497
+ /**
498
+ * events reads a server-sent event stream as it arrives, yielding
499
+ * `{event, data}` with data parsed as JSON; it ends when the server closes
500
+ * the stream or `signal` aborts. A non-2xx answer is the route's failure.
501
+ */
502
+ async *events(path, { signal } = {}) {
503
+ const res = await this.#send('GET', path, undefined, signal);
504
+ if (!res.ok) {
505
+ const answer = await this.#read(res);
506
+ if (answer.status === 404) throw new UserError(problemDetail(answer));
507
+ throw failure('GET', path, answer);
508
+ }
509
+ yield* sse(res.body, this.target, signal);
510
+ }
511
+
459
512
  /**
460
513
  * #request sends a request and reads its whole answer. It throws only when
461
514
  * there is no answer at all; every status comes back to the caller, which
@@ -474,7 +527,7 @@ export class JunoClient {
474
527
  * request to its sign-in page. Following it would hand the caller that
475
528
  * page's HTML as a 200.
476
529
  */
477
- async #send(method, path, body) {
530
+ async #send(method, path, body, signal) {
478
531
  const url = `${this.target}${path}`;
479
532
  // Every request carries the header, the existing commands' included:
480
533
  // one client, one header.
@@ -488,6 +541,7 @@ export class JunoClient {
488
541
  headers,
489
542
  body: body ? JSON.stringify(body) : undefined,
490
543
  redirect: 'manual',
544
+ signal,
491
545
  });
492
546
  } catch (err) {
493
547
  throw new UnreachableError(
@@ -527,6 +581,7 @@ export class JunoClient {
527
581
  ok: res.ok,
528
582
  status: res.status,
529
583
  statusText: res.statusText,
584
+ headers: res.headers,
530
585
  text,
531
586
  parsed,
532
587
  };
@@ -574,6 +629,60 @@ async function* ndjson(body, target) {
574
629
  yield* lines(true);
575
630
  }
576
631
 
632
+ /**
633
+ * sse reads a text/event-stream body: blank-line separated events of
634
+ * `event:` and `data:` lines, each data parsed as JSON. An abort ends it
635
+ * quietly; a connection that drops is an UnreachableError.
636
+ */
637
+ async function* sse(body, target, signal) {
638
+ const decoder = new TextDecoder();
639
+ let buffered = '';
640
+ const events = function* (final) {
641
+ buffered = buffered.replace(/\r\n/g, '\n');
642
+ let end;
643
+ while (
644
+ (end = buffered.indexOf('\n\n')) >= 0 ||
645
+ (final && buffered.trim())
646
+ ) {
647
+ const block = end >= 0 ? buffered.slice(0, end) : buffered;
648
+ buffered = end >= 0 ? buffered.slice(end + 2) : '';
649
+ let event = 'message';
650
+ const data = [];
651
+ for (const line of block.split('\n')) {
652
+ if (line.startsWith('event:')) event = line.slice(6).trim();
653
+ else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
654
+ }
655
+ if (!data.length) continue;
656
+ const text = data.join('\n');
657
+ let parsed;
658
+ try {
659
+ parsed = JSON.parse(text);
660
+ } catch {
661
+ throw new UserError(
662
+ `the stream sent an event that is not JSON: ${text}`
663
+ );
664
+ }
665
+ yield { event, data: parsed };
666
+ }
667
+ };
668
+ try {
669
+ for await (const chunk of body) {
670
+ buffered += decoder.decode(chunk, { stream: true });
671
+ yield* events(false);
672
+ }
673
+ } catch (err) {
674
+ if (err instanceof UserError || signal?.aborted) {
675
+ if (signal?.aborted) return;
676
+ throw err;
677
+ }
678
+ throw new UnreachableError(
679
+ `lost the connection to Juno at ${target}: ${err.message}`
680
+ );
681
+ }
682
+ buffered += decoder.decode();
683
+ yield* events(true);
684
+ }
685
+
577
686
  /**
578
687
  * jsonBody is a 2xx answer's parsed body, which every route this client calls
579
688
  * answers as a JSON object. Anything else came from something other than
@@ -584,17 +693,47 @@ async function* ndjson(body, target) {
584
693
  function jsonBody(method, path, res) {
585
694
  if (res.parsed === null || typeof res.parsed !== 'object') {
586
695
  throw new UserError(
587
- `${method} ${path} answered something that is not JSON: ${res.text.trim()}`
696
+ `${method} ${path} answered something that is not JSON: ${briefText(res)}`
588
697
  );
589
698
  }
590
699
  return res.parsed;
591
700
  }
592
701
 
702
+ /**
703
+ * skillsHeader is X-Juno-Skills, the skills the gateway names beside a call
704
+ * the tool refused (a 422, or a result with a string error), or [] when it
705
+ * sent none. The server decides which skills apply; the CLI only prints them.
706
+ */
707
+ function skillsHeader(res) {
708
+ const value = res.headers?.get?.('x-juno-skills') ?? '';
709
+ return value.split(/\s+/).filter(Boolean);
710
+ }
711
+
712
+ /** The last stderr line after a refused call: what to read before retrying. */
713
+ export function skillsPointer(skills) {
714
+ return `see: juno skill ${skills.join(' ')}`;
715
+ }
716
+
593
717
  /** The server's own words in an error answer: Huma's `detail`, else the body. */
594
718
  function problemDetail(res) {
595
- return (
596
- res.parsed?.detail || res.parsed?.title || res.text.trim() || res.statusText
597
- );
719
+ return res.parsed?.detail || res.parsed?.title || briefText(res);
720
+ }
721
+
722
+ const BRIEF_LIMIT = 200;
723
+
724
+ /**
725
+ * briefText is a body that is not Juno's JSON, cut to one line: an HTML page
726
+ * (a proxy's, or an instance older than the route answering its web app) is
727
+ * named rather than printed, because its markup buries the one line that
728
+ * matters — the status and the route — under a screenful of tags, and other
729
+ * text is collapsed to one line of at most BRIEF_LIMIT characters.
730
+ */
731
+ export function briefText(res) {
732
+ const text = (res.text ?? '').trim();
733
+ if (!text) return res.statusText || '(empty body)';
734
+ if (/^<(!doctype|html|head|body)\b/i.test(text)) return 'an HTML page';
735
+ const flat = text.replace(/\s+/g, ' ');
736
+ return flat.length > BRIEF_LIMIT ? `${flat.slice(0, BRIEF_LIMIT)}…` : flat;
598
737
  }
599
738
 
600
739
  /**
@@ -619,7 +758,7 @@ const UNAUTHORIZED_HINT =
619
758
  * `errors[]` instead, so those are appended.
620
759
  */
621
760
  function validationDetail(res) {
622
- const parts = [res.parsed?.detail || res.text.trim() || res.statusText];
761
+ const parts = [res.parsed?.detail || briefText(res)];
623
762
  for (const e of res.parsed?.errors ?? []) {
624
763
  parts.push(e.location ? `${e.location}: ${e.message}` : e.message);
625
764
  }
@@ -1,5 +1,3 @@
1
- import { warnIfStale } from './installed-skill.js';
2
-
3
1
  /**
4
2
  * The kinds of working copy with an authoring skill index, and the heading
5
3
  * each is printed under. The index itself is the server's
@@ -22,10 +20,9 @@ export function authoringHeading(kind) {
22
20
  * printAuthoringIndex prints a kind's authoring skills after `init` has
23
21
  * written the working copy, as ` <name> — <description>`. Fetching it is a
24
22
  * courtesy: the working copy is already on disk, so a failure is a note on
25
- * stderr, never a failed init. It also says when an installed agent skill is
26
- * stale, since the index carries the version the instance serves.
23
+ * stderr, never a failed init.
27
24
  */
28
- export async function printAuthoringIndex(kind, client, target, io) {
25
+ export async function printAuthoringIndex(kind, client, io) {
29
26
  let index;
30
27
  try {
31
28
  index = await client.listSkills({ authoring: kind });
@@ -39,5 +36,4 @@ export async function printAuthoringIndex(kind, client, target, io) {
39
36
  io.log(authoringHeading(kind));
40
37
  for (const s of skills) io.log(` ${s.name} — ${s.description}`);
41
38
  }
42
- await warnIfStale(index, target, io);
43
39
  }
@@ -41,6 +41,6 @@ export async function appInit(argv, io) {
41
41
  io.log('npm test');
42
42
  io.log('npm run check # stores a draft and prints its preview URL');
43
43
  io.log('npm run deploy');
44
- await printAuthoringIndex('app', client, target, io);
44
+ await printAuthoringIndex('app', client, io);
45
45
  return 0;
46
46
  }
@@ -0,0 +1,215 @@
1
+ import { connect } from '../api.js';
2
+ import { UnreachableError, UsageError, UserError } from '../errors.js';
3
+ import {
4
+ EXIT_FAILURE,
5
+ EXIT_INTERRUPTED,
6
+ EXIT_OK,
7
+ EXIT_PENDING,
8
+ } from '../exit.js';
9
+
10
+ export const APPROVAL_WAIT_USAGE =
11
+ 'juno approval wait <approval-id> [--wait <seconds>]';
12
+
13
+ /** How long a destructive command waits for a person by default. */
14
+ export const DEFAULT_APPROVAL_WAIT_SECONDS = 300;
15
+
16
+ /** How often the approval is read while waiting. */
17
+ const POLL_MS = 2000;
18
+
19
+ /**
20
+ * approvalWaitSeconds reads `--wait`: whole seconds, 0 meaning look once.
21
+ */
22
+ export function approvalWaitSeconds(flag) {
23
+ if (flag === undefined) return DEFAULT_APPROVAL_WAIT_SECONDS;
24
+ if (!/^\d+$/.test(flag)) {
25
+ throw new UsageError(`--wait takes a number of seconds, got "${flag}"`);
26
+ }
27
+ return Number(flag);
28
+ }
29
+
30
+ /**
31
+ * `juno approval wait <id>` picks up a wait a destructive command left: it
32
+ * reads the approval and, while a person has not decided it, waits as the
33
+ * command did, then prints how it ended and exits the same way.
34
+ */
35
+ export async function approvalWait(args, io) {
36
+ const [id, ...extra] = args._;
37
+ if (id === undefined || extra.length) {
38
+ throw new UsageError(`usage: ${APPROVAL_WAIT_USAGE}`);
39
+ }
40
+ const wait = approvalWaitSeconds(args.wait);
41
+ const { client, target } = await connect(args, io);
42
+ const a = await client.getApproval(id);
43
+ if (!a) throw new UserError(`no approval ${id}`);
44
+ const required = {
45
+ id: a.id,
46
+ url: approvalURL(`/approvals/${a.id}`, target),
47
+ summary: a.summary,
48
+ expires_at: a.expires_at,
49
+ };
50
+ return followApproval(required, {
51
+ client,
52
+ target,
53
+ wait,
54
+ io,
55
+ print: json(io),
56
+ });
57
+ }
58
+
59
+ /**
60
+ * followApproval is plan-cli-parity.md decision 11, and the one place the CLI
61
+ * interprets a result: a destructive tool answered `approval_required`, so
62
+ * it says on stderr what needs approving and where, then reads the approval
63
+ * until a person decided it or `wait` seconds ran out.
64
+ *
65
+ * - executed: the stored result, with `approval: {id, approved_by}` beside
66
+ * it, exit 0 — or 1 when the result is a refusal, as the tool's own would be
67
+ * - failed, denied, expired: `{"error": …}`, exit 1
68
+ * - still pending: the `approval_required` answer again, exit 5
69
+ * - Ctrl-C: exit 130, the request left open
70
+ *
71
+ * `print` writes one JSON value to stdout (a `--jq` filter applies to it).
72
+ */
73
+ export async function followApproval(
74
+ required,
75
+ { client, target, wait, io, print }
76
+ ) {
77
+ const id = required.id;
78
+ const url = approvalURL(required.url ?? `/approvals/${id}`, target);
79
+ const resume = `juno approval wait ${id}`;
80
+ io.warn(`Approval needed: ${required.summary}.`);
81
+ io.warn(`Approve or deny in Juno: ${url}`);
82
+
83
+ const interrupt = new AbortController();
84
+ const off = io.onInterrupt?.(() => interrupt.abort()) ?? (() => {});
85
+ try {
86
+ const now = io.now ?? Date.now;
87
+ const deadline = now() + wait * 1000;
88
+ let approval = await read(client, id, io);
89
+ if (approval?.status === 'pending' || approval?.status === 'approved') {
90
+ io.warn(
91
+ wait > 0
92
+ ? `waiting up to ${wait}s… (Ctrl-C leaves the request open: ${resume})`
93
+ : `not waiting (--wait 0); check later with: ${resume}`
94
+ );
95
+ }
96
+ while (!approval || !ended(approval.status)) {
97
+ const left = deadline - now();
98
+ if (left <= 0) break;
99
+ await sleep(Math.min(io.pollMs ?? POLL_MS, left), interrupt.signal);
100
+ if (interrupt.signal.aborted) {
101
+ io.warn(`stopped waiting; the request is still open: ${resume}`);
102
+ return EXIT_INTERRUPTED;
103
+ }
104
+ approval = (await read(client, id, io)) ?? approval;
105
+ }
106
+ if (!approval || !ended(approval.status)) {
107
+ await print({ approval_required: { ...required, url } });
108
+ io.warn(`still waiting for a person; keep waiting with: ${resume}`);
109
+ return EXIT_PENDING;
110
+ }
111
+ return outcome(approval, print);
112
+ } finally {
113
+ off();
114
+ }
115
+ }
116
+
117
+ /** The statuses an approval ends in; `approved` means its call is running. */
118
+ function ended(status) {
119
+ return ['executed', 'failed', 'denied', 'expired'].includes(status);
120
+ }
121
+
122
+ /**
123
+ * read is one GET of the approval. A dropped connection is not the end of the
124
+ * wait — the next read may well get through — so it answers null and warns;
125
+ * an approval that does not exist is.
126
+ */
127
+ async function read(client, id, io) {
128
+ try {
129
+ const a = await client.getApproval(id);
130
+ if (!a) throw new UserError(`no approval ${id}`);
131
+ return a;
132
+ } catch (err) {
133
+ if (!(err instanceof UnreachableError)) throw err;
134
+ io.warn(`${err.message}; still waiting`);
135
+ return null;
136
+ }
137
+ }
138
+
139
+ async function outcome(a, print) {
140
+ switch (a.status) {
141
+ case 'executed': {
142
+ await print(
143
+ withApproval(a.result, { id: a.id, approved_by: a.decided_by })
144
+ );
145
+ return typeof a.result?.error === 'string' ? EXIT_FAILURE : EXIT_OK;
146
+ }
147
+ case 'failed': {
148
+ // The tool refused (it answered an error, stored as the result), or the
149
+ // call could not run at all (the error alone).
150
+ const result =
151
+ a.result && typeof a.result === 'object' && !Array.isArray(a.result)
152
+ ? a.result
153
+ : { error: a.error };
154
+ if (typeof result.error !== 'string') result.error = a.error;
155
+ await print(
156
+ withApproval(result, {
157
+ id: a.id,
158
+ status: 'failed',
159
+ approved_by: a.decided_by,
160
+ })
161
+ );
162
+ return EXIT_FAILURE;
163
+ }
164
+ case 'denied':
165
+ await print({
166
+ error: `denied by ${a.decided_by}`,
167
+ approval: { id: a.id, status: 'denied' },
168
+ });
169
+ return EXIT_FAILURE;
170
+ default:
171
+ await print({
172
+ error: 'expired: nobody approved it within 24 hours',
173
+ approval: { id: a.id, status: 'expired' },
174
+ });
175
+ return EXIT_FAILURE;
176
+ }
177
+ }
178
+
179
+ /** The result with the approval beside it; a non-object result is wrapped. */
180
+ function withApproval(result, approval) {
181
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
182
+ return { ...result, approval };
183
+ }
184
+ return { result: result ?? null, approval };
185
+ }
186
+
187
+ /**
188
+ * The page an approval is decided on: the server's link, which is a bare path
189
+ * while the instance has no public URL — completed against the instance this
190
+ * CLI reached.
191
+ */
192
+ export function approvalURL(url, target) {
193
+ return url.startsWith('/')
194
+ ? `${String(target).replace(/\/+$/, '')}${url}`
195
+ : url;
196
+ }
197
+
198
+ /** json prints a value as the CLI prints any result: indented on a TTY. */
199
+ export function json(io) {
200
+ return async (value) =>
201
+ io.log(io.isTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value));
202
+ }
203
+
204
+ function sleep(ms, signal) {
205
+ return new Promise((resolve) => {
206
+ if (signal.aborted) return resolve();
207
+ const t = setTimeout(done, ms);
208
+ function done() {
209
+ clearTimeout(t);
210
+ signal.removeEventListener('abort', done);
211
+ resolve();
212
+ }
213
+ signal.addEventListener('abort', done);
214
+ });
215
+ }
@@ -45,7 +45,7 @@ export async function flowInit(argv, io) {
45
45
  io.log(argv.here ? 'npm install' : `cd ${id} && npm install`);
46
46
  io.log('npm test');
47
47
  io.log('npm run deploy');
48
- await printAuthoringIndex('flow', client, target, io);
48
+ await printAuthoringIndex('flow', client, io);
49
49
  return 0;
50
50
  }
51
51
 
@@ -44,7 +44,7 @@ export async function init(argv, io) {
44
44
  io.log(argv.here ? 'npm install' : `cd ${id} && npm install`);
45
45
  io.log('npm test');
46
46
  io.log('npm run deploy');
47
- await printAuthoringIndex('integration', client, target, io);
47
+ await printAuthoringIndex('integration', client, io);
48
48
  return 0;
49
49
  }
50
50
 
@@ -0,0 +1,47 @@
1
+ import { connect } from '../api.js';
2
+ import { UsageError } from '../errors.js';
3
+
4
+ export const PERMISSIONS_USAGE =
5
+ 'juno permissions [--allow-change] [--working-copy] [--json]';
6
+
7
+ /**
8
+ * `juno permissions` prints a Claude Code permission block on stdout,
9
+ * `{"permissions":{"allow":[…]}}`: every read command, plus every change
10
+ * command with --allow-change, plus the working copy's npm scripts of the same
11
+ * tiers with --working-copy. No act command is ever in it — the server
12
+ * (GET /api/agent-permissions) leaves them out, and the CLI adds nothing.
13
+ * Where to put the block goes to stderr, so stdout stays the JSON alone.
14
+ * `--json` prints the route's body instead.
15
+ */
16
+ export async function permissions(args, io) {
17
+ if (args._.length > 0) {
18
+ throw new UsageError(
19
+ `juno permissions takes no argument; usage: ${PERMISSIONS_USAGE}`
20
+ );
21
+ }
22
+ const { client } = await connect(args, io);
23
+ const tiers = await client.getAgentPermissions();
24
+
25
+ if (args.json) return printJSON(tiers, io);
26
+
27
+ const pick = (t) => [
28
+ ...(t?.read ?? []),
29
+ ...(args['allow-change'] ? (t?.change ?? []) : []),
30
+ ];
31
+ const allow = pick(tiers);
32
+ if (args['working-copy']) allow.push(...pick(tiers.working_copy));
33
+
34
+ io.warn(
35
+ args['working-copy']
36
+ ? "Add these rules to this working copy's .claude/settings.json — not to user-wide settings, " +
37
+ "where they would also allow any other project's npm scripts:"
38
+ : 'Add these rules to .claude/settings.json (or ~/.claude/settings.json) to run them without a prompt:'
39
+ );
40
+ return printJSON({ permissions: { allow } }, io);
41
+ }
42
+
43
+ /** Compact off a TTY, indented on one, as every JSON the CLI prints. */
44
+ function printJSON(value, io) {
45
+ io.log(io.isTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value));
46
+ return 0;
47
+ }
@@ -0,0 +1,68 @@
1
+ import { connect } from '../api.js';
2
+ import { UsageError, UserError } from '../errors.js';
3
+ import { EXIT_INTERRUPTED } from '../exit.js';
4
+ import { runTool } from './tool.js';
5
+
6
+ /**
7
+ * `juno run get <run-id> --follow` follows a run as it happens, then prints
8
+ * what `juno run get` prints. It is a flag on the tool row rather than a row
9
+ * of its own because the answer is the tool's: the steps stream to stderr as
10
+ * they finish (`GET /api/flows/{id}/runs/{seq}/trace/stream`, the run page's
11
+ * live trace), and once the stream says the run is done, get_run's result is
12
+ * stdout, exactly as without the flag. A run already finished prints at once.
13
+ * A run waiting on something (an `ask`, a timer) has not finished, so the
14
+ * follow waits with it; Ctrl-C stops following and leaves the run alone.
15
+ */
16
+ export async function runFollow(row, args, io) {
17
+ const [id, ...extra] = args._;
18
+ if (id === undefined || extra.length) {
19
+ throw new UsageError(`usage: juno ${row.command} <run-id> --follow`);
20
+ }
21
+ const { client } = await connect(args, io);
22
+ const run = await client.rest(
23
+ 'GET',
24
+ `/api/runs/${encodeURIComponent(id)}`,
25
+ undefined,
26
+ { notFound: `no run ${id}` }
27
+ );
28
+ const stream = `/api/flows/${encodeURIComponent(run.flow_id)}/runs/${run.seq}/trace/stream`;
29
+
30
+ const interrupt = new AbortController();
31
+ const off = io.onInterrupt?.(() => interrupt.abort()) ?? (() => {});
32
+ let done = false;
33
+ try {
34
+ for await (const { event, data } of client.events(stream, {
35
+ signal: interrupt.signal,
36
+ })) {
37
+ if (event === 'snapshot') {
38
+ io.warn(`following ${run.flow_id} run ${run.seq} (${data.status})`);
39
+ for (const step of data.steps ?? []) io.warn(stepLine(step));
40
+ } else if (event === 'step') {
41
+ io.warn(stepLine(data.step ?? {}));
42
+ } else if (event === 'done') {
43
+ io.warn(`run ${data.status}${data.error ? `: ${data.error}` : ''}`);
44
+ done = true;
45
+ break;
46
+ }
47
+ }
48
+ } finally {
49
+ off();
50
+ }
51
+ if (interrupt.signal.aborted) {
52
+ io.warn(`stopped following; the run goes on: juno run get ${id}`);
53
+ return EXIT_INTERRUPTED;
54
+ }
55
+ if (!done) {
56
+ throw new UserError(
57
+ `the trace stream of run ${id} ended before the run did; read it with: juno run get ${id}`
58
+ );
59
+ }
60
+ return runTool(row, { ...args, follow: undefined }, io);
61
+ }
62
+
63
+ /** stepLine is one step as a line of progress. */
64
+ function stepLine(step) {
65
+ const took = step.duration_ms != null ? ` ${step.duration_ms}ms` : '';
66
+ const err = step.error ? `: ${step.error}` : '';
67
+ return ` ${step.step_id} ${step.step_type} ${step.status}${took}${err}`;
68
+ }
@@ -17,6 +17,10 @@ export const SESSION_APPROVE_USAGE =
17
17
  * It checks first that the session is awaiting an approval — anything else
18
18
  * sent to a question would be taken as the answer — then sends `approve` as
19
19
  * `juno ask` would, and prints and exits the same way.
20
+ *
21
+ * A destructive tool's approval (`pending_input.approval_id`) is refused: it
22
+ * is answered only by a person in Juno, on the page it names, and the server
23
+ * refuses an `approve` sent to it anyway.
20
24
  */
21
25
  export async function sessionApprove(args, io) {
22
26
  const [id, ...extra] = args._;
@@ -25,10 +29,10 @@ export async function sessionApprove(args, io) {
25
29
  }
26
30
  const wait = waitSeconds(args.wait);
27
31
 
28
- const { client } = await connect(args, io);
32
+ const { client, target } = await connect(args, io);
29
33
  const session = await client.getAgentSession(id);
30
34
  if (!session) throw new UserError(`no assistant session ${id}`);
31
- const refusal = notAwaitingApproval(session, id);
35
+ const refusal = notAwaitingApproval(session, id, target);
32
36
  if (refusal) throw new UserError(refusal);
33
37
 
34
38
  const body = {
@@ -48,8 +52,11 @@ export async function sessionApprove(args, io) {
48
52
  }
49
53
 
50
54
  /** What the session is doing instead of waiting on an approval, if it is. */
51
- function notAwaitingApproval(session, id) {
55
+ function notAwaitingApproval(session, id, target) {
52
56
  const pending = session.pending_input;
57
+ if (session.status === 'awaiting_input' && pending?.approval_id) {
58
+ return `this approval can only be answered in Juno: ${approvalURL(pending, target)}`;
59
+ }
53
60
  if (session.status === 'awaiting_input' && pending?.kind === 'approval') {
54
61
  return null;
55
62
  }
@@ -71,3 +78,15 @@ function notAwaitingApproval(session, id) {
71
78
  }
72
79
  return `session ${id} has ended (status: ${session.status}); there is nothing to approve`;
73
80
  }
81
+
82
+ /**
83
+ * The page an approval is decided on: the server's link, which is a bare path
84
+ * while the instance has no public URL — completed against the instance this
85
+ * CLI reached.
86
+ */
87
+ function approvalURL(pending, target) {
88
+ const url = pending.approval_url ?? `/approvals/${pending.approval_id}`;
89
+ return url.startsWith('/')
90
+ ? `${String(target).replace(/\/+$/, '')}${url}`
91
+ : url;
92
+ }