@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/README.md +273 -95
- package/package.json +2 -2
- package/src/api.js +154 -15
- package/src/authoring-skills.js +2 -6
- package/src/commands/app-init.js +1 -1
- package/src/commands/approval-wait.js +215 -0
- package/src/commands/flow-init.js +1 -1
- package/src/commands/init.js +1 -1
- package/src/commands/permissions.js +47 -0
- package/src/commands/run-follow.js +68 -0
- package/src/commands/session-approve.js +22 -3
- package/src/commands/session.js +103 -0
- package/src/commands/skill.js +28 -19
- package/src/commands/tool.js +23 -5
- package/src/commands/whoami.js +38 -0
- package/src/exit.js +5 -1
- package/src/help.js +143 -22
- package/src/index.js +115 -48
- package/src/prompt.js +9 -0
- package/src/verbs.js +6 -1
- package/verbs.json +391 -6
- package/src/commands/agent-skill-install.js +0 -64
- package/src/installed-skill.js +0 -55
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
|
|
310
|
-
*
|
|
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-
|
|
322
|
-
*
|
|
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
|
|
326
|
-
return this.#json('GET', '/api/agent-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/authoring-skills.js
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
}
|
package/src/commands/app-init.js
CHANGED
|
@@ -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,
|
|
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,
|
|
48
|
+
await printAuthoringIndex('flow', client, io);
|
|
49
49
|
return 0;
|
|
50
50
|
}
|
|
51
51
|
|
package/src/commands/init.js
CHANGED
|
@@ -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,
|
|
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
|
+
}
|