@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.
@@ -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
+ }
@@ -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
+ }
@@ -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>] [--json]';
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 a name, that skill's body exactly as served. `--json`
11
- * prints the route's JSON instead. The server renders each tool reference as
12
- * the `juno` command in front of it; the CLI does not rewrite them. The index
13
- * also says when an installed agent skill is older than what the instance
14
- * serves (installed-skill.js).
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 [name, ...extra] = args._;
18
- if (extra.length) throw new UsageError(`usage: ${SKILL_USAGE}`);
18
+ const names = args._;
19
+ const { client } = await connect(args, io);
19
20
 
20
- const { target, client } = await connect(args, io);
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 detail = await client.getSkill(name);
33
- if (!detail) {
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
- name,
35
+ missing,
37
36
  skills.map((s) => s.name)
38
37
  );
39
38
  }
40
- if (args.json) return printJSON(detail, io);
41
- // Verbatim: the body's own trailing newline is the last byte written.
42
- io.write(detail.body);
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 JOB_RUN_USAGE =
12
- 'juno job run [<url>] [--url-file <path>] [--verbose] -- <command> [args…]';
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 job run` runs a command and reports it to an external job's ping URL
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 `job run`, `--` included. The exit code is returned, not
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 jobRun(argv, io, deps = {}) {
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 job run: ${err.message}\nusage: ${JOB_RUN_USAGE}`
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 job run: missing -- <command>\nusage: ${JOB_RUN_USAGE}`
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 job run: one URL before --, got ${parsed.positionals.length} arguments\nusage: ${JOB_RUN_USAGE}`
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('job run', JOB_RUN_USAGE, {
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
- const line = `juno job run: ${command[0]}: ${outcome.startError.message}`;
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 job run: could not report to Juno: ${failure}`);
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 $JUNO_JOB_URL. The two
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.JUNO_JOB_URL?.trim()) return env.JUNO_JOB_URL.trim();
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 JUNO_JOB_URL\nusage: ${usage}`
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. A query the URL already carries is
177
- * kept; without a rid there is no `rid` parameter.
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 path = (q === -1 ? url : url.slice(0, q)).replace(/\/+$/, '');
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 JOB_PING_USAGE =
295
- 'juno job ping [<url>] [--url-file <path>] [--start | --fail | --exit <0-255> | --log] [--rid <id>]';
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 job ping` sends one report to an external job's ping URL, for a task
301
- * `job run` cannot wrap — one whose scheduler runs no command, or whose start
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 `job run` does and, like it, reads no Juno config and no
307
- * API key. Unlike `job run` it has nothing else to do, so a report that still
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 jobPing(args, io, deps = {}) {
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 job ping: ${msg}\nusage: ${JOB_PING_USAGE}`);
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
- let suffix = 'success';
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('job ping', JOB_PING_USAGE, {
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 job ping: could not report to Juno: ${failure}`);
364
+ io.error(`juno task ping: could not report to Juno: ${failure}`);
360
365
  return EXIT_FAILURE;
361
366
  }
362
367
  return EXIT_OK;
@@ -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
- return typeof result?.error === 'string' ? EXIT_FAILURE : EXIT_OK;
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 job run` is the exception once its own arguments are accepted: it
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;