@junoflow/cli 0.14.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 +310 -117
- package/app-client.js +95 -9
- 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/{job-run.js → task-run.js} +36 -31
- package/src/commands/tool.js +23 -5
- package/src/commands/whoami.js +38 -0
- package/src/exit.js +6 -2
- package/src/help.js +143 -22
- package/src/index.js +127 -60
- package/src/prompt.js +9 -0
- package/src/verbs.js +6 -1
- package/verbs.json +435 -13
- package/src/commands/agent-skill-install.js +0 -64
- package/src/installed-skill.js +0 -55
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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { connect } from '../api.js';
|
|
2
|
+
import { UsageError } from '../errors.js';
|
|
3
|
+
import { EXIT_OK } from '../exit.js';
|
|
4
|
+
import { json } from './approval-wait.js';
|
|
5
|
+
|
|
6
|
+
// The assistant's sessions, over /api/agent… as the web UI's Assistant pages
|
|
7
|
+
// read and drive them. They call no tool: a session is the in-app assistant's
|
|
8
|
+
// conversation, not a resource the gateway serves. `session approve` (its own
|
|
9
|
+
// file) is the fourth driving command; these never answer what a session
|
|
10
|
+
// waits on.
|
|
11
|
+
|
|
12
|
+
export const SESSION_LIST_USAGE =
|
|
13
|
+
'juno session list [--rating up|down|unaddressed|addressed|unrated] [--profile assistant|author|integration_author|app_author] [--limit <n>]';
|
|
14
|
+
export const SESSION_GET_USAGE = 'juno session get <session-id> [--transcript]';
|
|
15
|
+
export const SESSION_EXPORT_USAGE = 'juno session export <session-id>';
|
|
16
|
+
export const SESSION_RATE_USAGE =
|
|
17
|
+
'juno session rate <session-id> --rating up|down|addressed|none';
|
|
18
|
+
export const SESSION_CANCEL_USAGE = 'juno session cancel <session-id>';
|
|
19
|
+
|
|
20
|
+
/** The words `--rating` takes on `session rate`, as the route's numbers. */
|
|
21
|
+
const RATINGS = { up: 2, none: 0, addressed: -1, down: -2 };
|
|
22
|
+
|
|
23
|
+
/** oneID is the single session id a command takes. */
|
|
24
|
+
function oneID(args, usage) {
|
|
25
|
+
const [id, ...extra] = args._;
|
|
26
|
+
if (id === undefined || extra.length) throw new UsageError(`usage: ${usage}`);
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** sessionPath is /api/agent/{id} plus suffix, and the 404 it answers. */
|
|
31
|
+
function sessionPath(id, suffix = '') {
|
|
32
|
+
return [
|
|
33
|
+
`/api/agent/${encodeURIComponent(id)}${suffix}`,
|
|
34
|
+
{ notFound: `no assistant session ${id}` },
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function print(io, value) {
|
|
39
|
+
await json(io)(value);
|
|
40
|
+
return EXIT_OK;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `juno session list`: GET /api/agent, newest first. */
|
|
44
|
+
export async function sessionList(args, io) {
|
|
45
|
+
if (args._.length) throw new UsageError(`usage: ${SESSION_LIST_USAGE}`);
|
|
46
|
+
const query = new URLSearchParams();
|
|
47
|
+
if (args.rating !== undefined) query.set('rating', args.rating);
|
|
48
|
+
if (args.profile !== undefined) query.set('profile', args.profile);
|
|
49
|
+
if (args.limit !== undefined) {
|
|
50
|
+
if (!/^\d+$/.test(args.limit)) {
|
|
51
|
+
throw new UsageError(`--limit takes a number, got "${args.limit}"`);
|
|
52
|
+
}
|
|
53
|
+
query.set('limit', args.limit);
|
|
54
|
+
}
|
|
55
|
+
const { client } = await connect(args, io);
|
|
56
|
+
const qs = query.size ? `?${query}` : '';
|
|
57
|
+
return print(io, await client.rest('GET', `/api/agent${qs}`));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `juno session get <id>`: GET /api/agent/{id}, with its transcript on request. */
|
|
61
|
+
export async function sessionGet(args, io) {
|
|
62
|
+
const id = oneID(args, SESSION_GET_USAGE);
|
|
63
|
+
const { client } = await connect(args, io);
|
|
64
|
+
const [path, opts] = sessionPath(
|
|
65
|
+
id,
|
|
66
|
+
args.transcript ? '?transcript=true' : ''
|
|
67
|
+
);
|
|
68
|
+
return print(io, await client.rest('GET', path, undefined, opts));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `juno session export <id>`: GET /api/agent/{id}/export. */
|
|
72
|
+
export async function sessionExport(args, io) {
|
|
73
|
+
const id = oneID(args, SESSION_EXPORT_USAGE);
|
|
74
|
+
const { client } = await connect(args, io);
|
|
75
|
+
const [path, opts] = sessionPath(id, '/export');
|
|
76
|
+
return print(io, await client.rest('GET', path, undefined, opts));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `juno session rate <id> --rating …`: PUT /api/agent/{id}/rating. */
|
|
80
|
+
export async function sessionRate(args, io) {
|
|
81
|
+
const id = oneID(args, SESSION_RATE_USAGE);
|
|
82
|
+
if (!Object.hasOwn(RATINGS, args.rating ?? '')) {
|
|
83
|
+
throw new UsageError(
|
|
84
|
+
`--rating takes up, down, addressed or none; usage: ${SESSION_RATE_USAGE}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const { client } = await connect(args, io);
|
|
88
|
+
const [path, opts] = sessionPath(id, '/rating');
|
|
89
|
+
return print(
|
|
90
|
+
io,
|
|
91
|
+
await client.rest('PUT', path, { rating: RATINGS[args.rating] }, opts)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `juno session cancel <id>`: POST /api/agent/{id}/cancel. */
|
|
96
|
+
export async function sessionCancel(args, io) {
|
|
97
|
+
const id = oneID(args, SESSION_CANCEL_USAGE);
|
|
98
|
+
const { client } = await connect(args, io);
|
|
99
|
+
// The orchestrator's own words on a 404: an unknown session and one that
|
|
100
|
+
// has already ended are both refused there.
|
|
101
|
+
const [path] = sessionPath(id, '/cancel');
|
|
102
|
+
return print(io, await client.rest('POST', path));
|
|
103
|
+
}
|
package/src/commands/skill.js
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
1
|
import { connect } from '../api.js';
|
|
2
2
|
import { UsageError } from '../errors.js';
|
|
3
|
-
import { warnIfStale } from '../installed-skill.js';
|
|
4
3
|
import { levenshtein } from '../suggest.js';
|
|
5
4
|
|
|
6
|
-
export const SKILL_USAGE = 'juno skill [<name
|
|
5
|
+
export const SKILL_USAGE = 'juno skill [<name>...] [--json]';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* `juno skill` prints the skills the instance serves: with no name, one line
|
|
10
|
-
* per skill; with
|
|
11
|
-
* prints the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* per skill; with names, those skills' bodies exactly as served — one name
|
|
10
|
+
* prints the body alone, several put a `===== <name> =====` line before
|
|
11
|
+
* each, so one call loads everything a task needs. `--json` prints the
|
|
12
|
+
* route's JSON instead: the index, one skill's detail, or an array of them.
|
|
13
|
+
* Every name is resolved before anything is printed, so an unknown one fails
|
|
14
|
+
* the call with nothing written. The server renders each tool reference as
|
|
15
|
+
* the `juno` command in front of it; the CLI does not rewrite them.
|
|
15
16
|
*/
|
|
16
17
|
export async function skill(args, io) {
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
const names = args._;
|
|
19
|
+
const { client } = await connect(args, io);
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (name === undefined) {
|
|
21
|
+
if (names.length === 0) {
|
|
23
22
|
const index = await client.listSkills();
|
|
24
|
-
await warnIfStale(index, target, io);
|
|
25
23
|
if (args.json) return printJSON(index, io);
|
|
26
24
|
for (const s of index?.skills ?? []) {
|
|
27
25
|
io.log(`${s.name} — ${s.description}`);
|
|
@@ -29,17 +27,28 @@ export async function skill(args, io) {
|
|
|
29
27
|
return 0;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
|
-
const
|
|
33
|
-
|
|
30
|
+
const details = await Promise.all(names.map((n) => client.getSkill(n)));
|
|
31
|
+
const missing = names.find((_, i) => !details[i]);
|
|
32
|
+
if (missing !== undefined) {
|
|
34
33
|
const { skills = [] } = (await client.listSkills()) ?? {};
|
|
35
34
|
throw unknownSkill(
|
|
36
|
-
|
|
35
|
+
missing,
|
|
37
36
|
skills.map((s) => s.name)
|
|
38
37
|
);
|
|
39
38
|
}
|
|
40
|
-
if (args.json)
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
if (args.json) {
|
|
40
|
+
return printJSON(details.length === 1 ? details[0] : details, io);
|
|
41
|
+
}
|
|
42
|
+
if (details.length === 1) {
|
|
43
|
+
// Verbatim: the body's own trailing newline is the last byte written.
|
|
44
|
+
io.write(details[0].body);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
details.forEach((d, i) => {
|
|
48
|
+
if (i > 0) io.write('\n');
|
|
49
|
+
io.write(`===== ${d.name} =====\n\n`);
|
|
50
|
+
io.write(d.body);
|
|
51
|
+
});
|
|
43
52
|
return 0;
|
|
44
53
|
}
|
|
45
54
|
|
|
@@ -8,8 +8,8 @@ import { parseArgs } from 'node:util';
|
|
|
8
8
|
import { UsageError } from '../errors.js';
|
|
9
9
|
import { EXIT_FAILURE, EXIT_OK } from '../exit.js';
|
|
10
10
|
|
|
11
|
-
export const
|
|
12
|
-
'juno
|
|
11
|
+
export const TASK_RUN_USAGE =
|
|
12
|
+
'juno task run [<url>] [--url-file <path>] [--verbose] -- <command> [args…]';
|
|
13
13
|
|
|
14
14
|
/** The tail of each stream a run reports; Juno keeps the same. */
|
|
15
15
|
export const TAIL_BYTES = 64 << 10;
|
|
@@ -24,7 +24,7 @@ const DEFAULT_TIMEOUTS = {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* `juno
|
|
27
|
+
* `juno task run` runs a command and reports it to a task's ping URL
|
|
28
28
|
* (plan-external-jobs-2-wrapper-and-tools.md, Decisions 3–5). Its first rule
|
|
29
29
|
* is transparency: the command gets the same stdin, and the wrapper's stdout,
|
|
30
30
|
* stderr and exit status are the command's, byte for byte — a scheduler such
|
|
@@ -34,11 +34,11 @@ const DEFAULT_TIMEOUTS = {
|
|
|
34
34
|
* on its own, so the wrapper never queues and never fails the command.
|
|
35
35
|
*
|
|
36
36
|
* It reads no Juno config and no API key; the URL is all it needs. argv is
|
|
37
|
-
* everything after `
|
|
37
|
+
* everything after `task run`, `--` included. The exit code is returned, not
|
|
38
38
|
* passed to process.exit, so stdout is flushed before the process ends.
|
|
39
39
|
* deps is injectable for the tests; the default binds the real ones.
|
|
40
40
|
*/
|
|
41
|
-
export async function
|
|
41
|
+
export async function taskRun(argv, io, deps = {}) {
|
|
42
42
|
const {
|
|
43
43
|
spawn = nodeSpawn,
|
|
44
44
|
fetch = globalThis.fetch,
|
|
@@ -65,20 +65,20 @@ export async function jobRun(argv, io, deps = {}) {
|
|
|
65
65
|
});
|
|
66
66
|
} catch (err) {
|
|
67
67
|
throw new UsageError(
|
|
68
|
-
`juno
|
|
68
|
+
`juno task run: ${err.message}\nusage: ${TASK_RUN_USAGE}`
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
if (command.length === 0) {
|
|
72
72
|
throw new UsageError(
|
|
73
|
-
`juno
|
|
73
|
+
`juno task run: missing -- <command>\nusage: ${TASK_RUN_USAGE}`
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
if (parsed.positionals.length > 1) {
|
|
77
77
|
throw new UsageError(
|
|
78
|
-
`juno
|
|
78
|
+
`juno task run: one URL before --, got ${parsed.positionals.length} arguments\nusage: ${TASK_RUN_USAGE}`
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
|
-
const url = await pingURL('
|
|
81
|
+
const url = await pingURL('task run', TASK_RUN_USAGE, {
|
|
82
82
|
positional: parsed.positionals[0],
|
|
83
83
|
file: parsed.values['url-file'],
|
|
84
84
|
env,
|
|
@@ -107,7 +107,11 @@ export async function jobRun(argv, io, deps = {}) {
|
|
|
107
107
|
let suffix;
|
|
108
108
|
let exitCode;
|
|
109
109
|
if (outcome.startError) {
|
|
110
|
-
|
|
110
|
+
// With no shell, `FOO=bar cmd` names an executable called "FOO=bar".
|
|
111
|
+
const line =
|
|
112
|
+
outcome.startError.code === 'ENOENT' && command[0].includes('=')
|
|
113
|
+
? `juno task run: "${command[0]}" looks like an env assignment; write -- env ${command.join(' ')}`
|
|
114
|
+
: `juno task run: ${command[0]}: ${outcome.startError.message}`;
|
|
111
115
|
io.error(line);
|
|
112
116
|
err.push(Buffer.from(`${line}\n`));
|
|
113
117
|
suffix = 'fail';
|
|
@@ -136,14 +140,14 @@ export async function jobRun(argv, io, deps = {}) {
|
|
|
136
140
|
failure = await finish();
|
|
137
141
|
}
|
|
138
142
|
if (failure && verbose) {
|
|
139
|
-
io.error(`juno
|
|
143
|
+
io.error(`juno task run: could not report to Juno: ${failure}`);
|
|
140
144
|
}
|
|
141
145
|
return exitCode;
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
/**
|
|
145
149
|
* pingURL is the URL from the first source that yields one: the positional,
|
|
146
|
-
* then --url-file (its contents, trimmed), then $
|
|
150
|
+
* then --url-file (its contents, trimmed), then $JUNO_TASK_URL. The two
|
|
147
151
|
* indirect sources keep the token off the command line, where `ps` and the
|
|
148
152
|
* scheduler's job definition would show it. command and usage name the verb
|
|
149
153
|
* in its refusals.
|
|
@@ -165,26 +169,26 @@ async function pingURL(
|
|
|
165
169
|
}
|
|
166
170
|
if (text.trim()) return text.trim();
|
|
167
171
|
}
|
|
168
|
-
if (env.
|
|
172
|
+
if (env.JUNO_TASK_URL?.trim()) return env.JUNO_TASK_URL.trim();
|
|
169
173
|
throw new UsageError(
|
|
170
|
-
`juno ${command}: no ping URL — pass it, --url-file or
|
|
174
|
+
`juno ${command}: no ping URL — pass it, --url-file or JUNO_TASK_URL\nusage: ${usage}`
|
|
171
175
|
);
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
/**
|
|
175
179
|
* endpoint is `<url>/<suffix>?rid=<rid>`, used as given otherwise: no /ping/
|
|
176
|
-
* check, so a Healthchecks URL works too.
|
|
177
|
-
*
|
|
180
|
+
* check, so a Healthchecks URL works too. An empty suffix is a success, which
|
|
181
|
+
* is the bare URL. A query the URL already carries is kept; without a rid
|
|
182
|
+
* there is no `rid` parameter.
|
|
178
183
|
*/
|
|
179
184
|
export function endpoint(url, suffix, rid) {
|
|
180
185
|
const q = url.indexOf('?');
|
|
181
|
-
const
|
|
186
|
+
const base = (q === -1 ? url : url.slice(0, q)).replace(/\/+$/, '');
|
|
187
|
+
const path = suffix ? `${base}/${suffix}` : base;
|
|
182
188
|
const params = [];
|
|
183
189
|
if (q !== -1 && q < url.length - 1) params.push(url.slice(q + 1));
|
|
184
190
|
if (rid) params.push(`rid=${encodeURIComponent(rid)}`);
|
|
185
|
-
return params.length
|
|
186
|
-
? `${path}/${suffix}?${params.join('&')}`
|
|
187
|
-
: `${path}/${suffix}`;
|
|
191
|
+
return params.length ? `${path}?${params.join('&')}` : path;
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
/**
|
|
@@ -291,24 +295,24 @@ export class Tail {
|
|
|
291
295
|
}
|
|
292
296
|
}
|
|
293
297
|
|
|
294
|
-
export const
|
|
295
|
-
'juno
|
|
298
|
+
export const TASK_PING_USAGE =
|
|
299
|
+
'juno task ping [<url>] [--url-file <path>] [--start | --fail | --exit <0-255> | --log] [--rid <id>]';
|
|
296
300
|
|
|
297
301
|
const PING_KINDS = ['start', 'fail', 'exit', 'log'];
|
|
298
302
|
|
|
299
303
|
/**
|
|
300
|
-
* `juno
|
|
301
|
-
* `
|
|
304
|
+
* `juno task ping` sends one report to a task's ping URL, for a task
|
|
305
|
+
* `task run` cannot wrap — one whose scheduler runs no command, or whose start
|
|
302
306
|
* and finish happen in different places. Without a flag it reports success;
|
|
303
307
|
* `--start`, `--fail`, `--exit <n>` and `--log` send the other reports, and
|
|
304
308
|
* `--rid` pairs a start with its finish. It sends no output.
|
|
305
309
|
*
|
|
306
|
-
* It finds the URL as `
|
|
307
|
-
* API key. Unlike `
|
|
310
|
+
* It finds the URL as `task run` does and, like it, reads no Juno config and no
|
|
311
|
+
* API key. Unlike `task run` it has nothing else to do, so a report that still
|
|
308
312
|
* fails after one retry is exit 1 with the reason, not silence: the caller
|
|
309
313
|
* asked for exactly this and can `|| true` it.
|
|
310
314
|
*/
|
|
311
|
-
export async function
|
|
315
|
+
export async function taskPing(args, io, deps = {}) {
|
|
312
316
|
const {
|
|
313
317
|
fetch = globalThis.fetch,
|
|
314
318
|
env = process.env,
|
|
@@ -316,7 +320,7 @@ export async function jobPing(args, io, deps = {}) {
|
|
|
316
320
|
timeouts = DEFAULT_TIMEOUTS,
|
|
317
321
|
} = deps;
|
|
318
322
|
const usage = (msg) =>
|
|
319
|
-
new UsageError(`juno
|
|
323
|
+
new UsageError(`juno task ping: ${msg}\nusage: ${TASK_PING_USAGE}`);
|
|
320
324
|
|
|
321
325
|
if (args._.length > 1) {
|
|
322
326
|
throw usage(`one URL, got ${args._.length} arguments`);
|
|
@@ -327,7 +331,8 @@ export async function jobPing(args, io, deps = {}) {
|
|
|
327
331
|
if (kinds.length > 1) {
|
|
328
332
|
throw usage(`--${kinds.join(' and --')} are different reports; pick one`);
|
|
329
333
|
}
|
|
330
|
-
|
|
334
|
+
// A success is the bare URL: the protocol has no /success suffix.
|
|
335
|
+
let suffix = '';
|
|
331
336
|
if (kinds[0] === 'exit') {
|
|
332
337
|
const n = args.exit.trim();
|
|
333
338
|
if (!/^\d{1,3}$/.test(n) || Number(n) > 255) {
|
|
@@ -337,7 +342,7 @@ export async function jobPing(args, io, deps = {}) {
|
|
|
337
342
|
} else if (kinds[0]) {
|
|
338
343
|
suffix = kinds[0];
|
|
339
344
|
}
|
|
340
|
-
const url = await pingURL('
|
|
345
|
+
const url = await pingURL('task ping', TASK_PING_USAGE, {
|
|
341
346
|
positional: args._[0],
|
|
342
347
|
file: args['url-file'],
|
|
343
348
|
env,
|
|
@@ -356,7 +361,7 @@ export async function jobPing(args, io, deps = {}) {
|
|
|
356
361
|
failure = await send();
|
|
357
362
|
}
|
|
358
363
|
if (failure) {
|
|
359
|
-
io.error(`juno
|
|
364
|
+
io.error(`juno task ping: could not report to Juno: ${failure}`);
|
|
360
365
|
return EXIT_FAILURE;
|
|
361
366
|
}
|
|
362
367
|
return EXIT_OK;
|
package/src/commands/tool.js
CHANGED
|
@@ -3,9 +3,10 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { basename, resolve } from 'node:path';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
5
|
|
|
6
|
-
import { connect } from '../api.js';
|
|
6
|
+
import { connect, skillsPointer } from '../api.js';
|
|
7
7
|
import { UsageError, UserError } from '../errors.js';
|
|
8
8
|
import { EXIT_FAILURE, EXIT_OK } from '../exit.js';
|
|
9
|
+
import { approvalWaitSeconds, followApproval } from './approval-wait.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* runTool runs one tool row of verbs.json: build the tool's input from the
|
|
@@ -16,9 +17,23 @@ import { EXIT_FAILURE, EXIT_OK } from '../exit.js';
|
|
|
16
17
|
*/
|
|
17
18
|
export async function runTool(row, args, io) {
|
|
18
19
|
const input = await buildInput(row, args, io);
|
|
20
|
+
const wait = approvalWaitSeconds(args.wait);
|
|
19
21
|
|
|
20
|
-
const { client } = await connect(args, io);
|
|
21
|
-
const { result, text } = await client.callTool(row.tool, input);
|
|
22
|
+
const { client, target } = await connect(args, io);
|
|
23
|
+
const { result, text, skills } = await client.callTool(row.tool, input);
|
|
24
|
+
|
|
25
|
+
// A destructive tool runs nothing until a person approves it in Juno: it
|
|
26
|
+
// answers approval_required, and the command waits for the decision
|
|
27
|
+
// (plan-cli-parity.md decision 11, the one result the CLI interprets).
|
|
28
|
+
const required = result?.approval_required;
|
|
29
|
+
if (required && typeof required === 'object' && required.id) {
|
|
30
|
+
const print = async (value) => {
|
|
31
|
+
const out = JSON.stringify(value);
|
|
32
|
+
if (args.jq !== undefined) io.log(await runJq(args.jq, out, io));
|
|
33
|
+
else io.log(io.isTTY ? JSON.stringify(value, null, 2) : out);
|
|
34
|
+
};
|
|
35
|
+
return followApproval(required, { client, target, wait, io, print });
|
|
36
|
+
}
|
|
22
37
|
|
|
23
38
|
if (args.jq !== undefined) {
|
|
24
39
|
io.log(await runJq(args.jq, text, io));
|
|
@@ -26,8 +41,11 @@ export async function runTool(row, args, io) {
|
|
|
26
41
|
io.log(io.isTTY ? JSON.stringify(result, null, 2) : text.trimEnd());
|
|
27
42
|
}
|
|
28
43
|
// A tool reports a domain failure as a 200 with a top-level string error:
|
|
29
|
-
// it is printed like any result, and only the exit code says it failed
|
|
30
|
-
|
|
44
|
+
// it is printed like any result, and only the exit code says it failed —
|
|
45
|
+
// plus, on stderr, the skills the gateway says explain the tool.
|
|
46
|
+
if (typeof result?.error !== 'string') return EXIT_OK;
|
|
47
|
+
if (skills.length) io.warn(skillsPointer(skills));
|
|
48
|
+
return EXIT_FAILURE;
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
/**
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { connect } from '../api.js';
|
|
4
|
+
import { UsageError } from '../errors.js';
|
|
5
|
+
import { EXIT_OK } from '../exit.js';
|
|
6
|
+
import { json } from './approval-wait.js';
|
|
7
|
+
|
|
8
|
+
export const WHOAMI_USAGE = 'juno whoami';
|
|
9
|
+
export const VERSION_USAGE = 'juno version';
|
|
10
|
+
|
|
11
|
+
/** This CLI's own version, from the package it ships in. */
|
|
12
|
+
const CLI_VERSION = JSON.parse(
|
|
13
|
+
readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
|
|
14
|
+
).version;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `juno whoami`: GET /api/me — the principal the instance takes this CLI for
|
|
18
|
+
* (a key, a user behind the proxy, or local), its scope and the linked
|
|
19
|
+
* person. It is how an agent learns what its key may do before it tries.
|
|
20
|
+
*/
|
|
21
|
+
export async function whoami(args, io) {
|
|
22
|
+
if (args._.length) throw new UsageError(`usage: ${WHOAMI_USAGE}`);
|
|
23
|
+
const { client } = await connect(args, io);
|
|
24
|
+
await json(io)(await client.rest('GET', '/api/me'));
|
|
25
|
+
return EXIT_OK;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `juno version`: GET /api/version, the instance's build and schema version,
|
|
30
|
+
* with this CLI's version beside it as `cli_version`.
|
|
31
|
+
*/
|
|
32
|
+
export async function version(args, io) {
|
|
33
|
+
if (args._.length) throw new UsageError(`usage: ${VERSION_USAGE}`);
|
|
34
|
+
const { client } = await connect(args, io);
|
|
35
|
+
const body = await client.rest('GET', '/api/version');
|
|
36
|
+
await json(io)({ ...body, cli_version: CLI_VERSION });
|
|
37
|
+
return EXIT_OK;
|
|
38
|
+
}
|
package/src/exit.js
CHANGED
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
* 3 the instance is unreachable (the fetch itself failed)
|
|
11
11
|
* 4 a deploy conflict: a working-copy deploy or check (any kind) got a
|
|
12
12
|
* 409 because the active version is not the copy's base_version
|
|
13
|
-
* 5 `juno ask` / `session approve`: the turn is still running (pending)
|
|
13
|
+
* 5 `juno ask` / `session approve`: the turn is still running (pending);
|
|
14
|
+
* a destructive command or `approval wait`: nobody has decided the
|
|
15
|
+
* approval when --wait ran out
|
|
14
16
|
* 6 `juno ask` / `session approve`: the assistant is awaiting input
|
|
17
|
+
* 130 Ctrl-C while waiting for an approval; the request stays open
|
|
15
18
|
*
|
|
16
|
-
* `juno
|
|
19
|
+
* `juno task run` is the exception once its own arguments are accepted: it
|
|
17
20
|
* exits with the wrapped command's status, as a shell would report it.
|
|
18
21
|
*/
|
|
19
22
|
export const EXIT_OK = 0;
|
|
@@ -23,3 +26,4 @@ export const EXIT_UNREACHABLE = 3;
|
|
|
23
26
|
export const EXIT_CONFLICT = 4;
|
|
24
27
|
export const EXIT_PENDING = 5;
|
|
25
28
|
export const EXIT_AWAITING_INPUT = 6;
|
|
29
|
+
export const EXIT_INTERRUPTED = 130;
|