@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.
@@ -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
 
@@ -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,8 +10,11 @@
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
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.
@@ -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;
package/src/help.js CHANGED
@@ -50,12 +50,18 @@ export async function groupHelp(group, rows, handlerFor, args, io) {
50
50
  const authoring = Object.hasOwn(AUTHORING_HEADINGS, group) ? group : null;
51
51
  let tools = null;
52
52
  let skills = null;
53
+ let readFirst = [];
53
54
  if (onInstance.some((row) => row.tool) || authoring) {
54
55
  try {
55
56
  const { client } = await connect(args, io);
56
57
  if (onInstance.some((row) => row.tool)) {
57
58
  tools = new Map((await client.listTools()).map((t) => [t.name, t]));
58
59
  }
60
+ readFirst = await readFirstFor(
61
+ client,
62
+ [group, ...members.map((r) => r.tool)],
63
+ io
64
+ );
59
65
  if (authoring) {
60
66
  skills = (await client.listSkills({ authoring }))?.skills ?? [];
61
67
  }
@@ -65,19 +71,17 @@ export async function groupHelp(group, rows, handlerFor, args, io) {
65
71
  }
66
72
 
67
73
  const lines = members.map((row) => {
68
- const synopsis = row.tool
69
- ? `juno ${row.command}${positionalName(row) ? ` ${positionalName(row)}` : ''}`
70
- : handlerFor(row).usage;
71
- if (!row.tool) return [synopsis, row.effect, row.summary];
74
+ const line = synopsis(row, handlerFor);
75
+ if (!row.tool) return [line, row.effect, row.summary];
72
76
  const tool = tools?.get(row.tool);
77
+ // A row that fixes part of its tool's input (one verb of command_task)
78
+ // says what it does itself; the tool's first sentence covers every verb.
79
+ if (row.summary)
80
+ return [line, row.effect ?? tool?.effect ?? '', row.summary];
73
81
  // Offline, no tool row shows an effect: printing only the rows that carry
74
82
  // their own would read as if the others had none.
75
- if (!tool) return [synopsis, '', ''];
76
- return [
77
- synopsis,
78
- row.effect ?? tool.effect,
79
- firstSentence(tool.description),
80
- ];
83
+ if (!tool) return [line, '', ''];
84
+ return [line, row.effect ?? tool.effect, firstSentence(tool.description)];
81
85
  });
82
86
  // One column width across both headings, so the two lists line up.
83
87
  const render = tabulate(lines);
@@ -89,6 +93,7 @@ export async function groupHelp(group, rows, handlerFor, args, io) {
89
93
  : `usage: juno ${group} <verb>`,
90
94
  '',
91
95
  ];
96
+ if (readFirst.length) out.push(readFirstLine(readFirst), '');
92
97
  if (inWorkingCopy.length === 0) {
93
98
  out.push(...body(members));
94
99
  } else {
@@ -109,21 +114,99 @@ export async function groupHelp(group, rows, handlerFor, args, io) {
109
114
  return out.join('\n');
110
115
  }
111
116
 
112
- export const WORKING_COPY_HEADING =
113
- 'In a working copy (run from the project directory)';
114
-
115
117
  /**
116
- * workingCopyListing is every working-copy command with its effect and
117
- * summary, for `juno --help`; offline, since all of it is in verbs.json.
118
+ * rootHelp is `juno --help` and bare `juno`: the place an agent with nothing
119
+ * installed starts. Usage first, then the primer from GET /api/agent-primer
120
+ * (what Juno is made of, how to work with it, what only the user does), then
121
+ * every group and top-level command by one line from verbs.json, the
122
+ * working-copy commands, and the skill index from GET /api/skills. The
123
+ * instance owns the primer and the skills; the CLI owns the listing.
124
+ *
125
+ * Offline — no instance configured, unreachable, refused — the primer and the
126
+ * skills are dropped, the reason goes to stderr as for a group's help, and one
127
+ * line says how to connect. A help must not fail offline.
118
128
  */
119
- export function workingCopyListing(rows, handlerFor) {
120
- return tabulate(
121
- rows
122
- .filter((row) => row.working_copy)
123
- .map((row) => [handlerFor(row).usage, row.effect, row.summary])
124
- ).join('\n');
129
+ export async function rootHelp(rows, handlerFor, groupSummaries, args, io) {
130
+ let primer = null;
131
+ let skills = null;
132
+ try {
133
+ const { client } = await connect(args, io);
134
+ const [p, index] = await Promise.all([
135
+ client.getAgentPrimer(),
136
+ client.listSkills(),
137
+ ]);
138
+ primer = p?.body ?? null;
139
+ skills = index?.skills ?? [];
140
+ } catch (err) {
141
+ io.warn(`(guide unavailable: ${err.message})`);
142
+ }
143
+
144
+ const out = [
145
+ 'juno — operate a Juno instance, and develop flows, integrations and apps for it',
146
+ '',
147
+ 'Usage: juno <command> [options]',
148
+ ` juno <group> <verb> [id] ${INPUT_USAGE}`,
149
+ '',
150
+ ];
151
+ if (primer) {
152
+ out.push(primer.trim(), '');
153
+ } else {
154
+ out.push(
155
+ 'The guide to working with Juno and the skill index come from the instance,',
156
+ 'which did not answer (the reason is on stderr). `juno config set url <url>`',
157
+ 'and `juno config set token <key>` connect this CLI to one.',
158
+ ''
159
+ );
160
+ }
161
+
162
+ const groups = [];
163
+ for (const row of rows) {
164
+ const group = groupOf(row);
165
+ if (group && !groups.includes(group)) groups.push(group);
166
+ }
167
+ out.push("Groups (run `juno <group> --help` for a group's commands):");
168
+ out.push(...tabulate(groups.map((g) => [g, '', groupSummaries[g] ?? ''])));
169
+ out.push('', 'Commands:');
170
+ out.push(
171
+ ...tabulate(
172
+ rows
173
+ .filter((row) => !groupOf(row))
174
+ .map((row) => [synopsis(row, handlerFor), '', row.summary ?? ''])
175
+ )
176
+ );
177
+
178
+ if (skills?.length) {
179
+ out.push(
180
+ '',
181
+ 'Skills (`juno skill <name>...` prints them; `juno skill` gives each its full description):'
182
+ );
183
+ const width = Math.max(...skills.map((s) => s.name.length));
184
+ for (const s of skills) {
185
+ out.push(` ${s.name.padEnd(width)} ${firstSentence(s.description)}`);
186
+ }
187
+ }
188
+
189
+ out.push(
190
+ '',
191
+ '--url <url> picks the instance; else $JUNO_URL, package.json juno.target,',
192
+ 'then the user config (juno config set url <url>).',
193
+ '--token <key> is the API key to send; else $JUNO_TOKEN, then the user config',
194
+ '(juno config set token <key>). Create one under Settings → API keys.',
195
+ ''
196
+ );
197
+ return out.join('\n');
198
+ }
199
+
200
+ /** One row's synopsis as a listing shows it: `juno state get <id>`. */
201
+ function synopsis(row, handlerFor) {
202
+ if (!row.tool) return handlerFor(row).usage;
203
+ const positional = positionalName(row);
204
+ return `juno ${row.command}${positional ? ` ${positional}` : ''}`;
125
205
  }
126
206
 
207
+ export const WORKING_COPY_HEADING =
208
+ 'In a working copy (run from the project directory)';
209
+
127
210
  /** Rows of [synopsis, effect, text] as aligned, indented lines. */
128
211
  function tabulate(lines) {
129
212
  const width = Math.max(...lines.map(([s]) => s.length));
@@ -149,7 +232,15 @@ export async function verbHelp(row, args, io) {
149
232
  );
150
233
  }
151
234
 
152
- const out = [`usage: ${rowUsage(row)}`, '', tool.description.trim(), ''];
235
+ const out = [`usage: ${rowUsage(row)}`, ''];
236
+ // Which skills to read first is the server's mapping (each skill's
237
+ // applies_to); a failure to fetch it drops the line, never the help.
238
+ const readFirst = await readFirstFor(client, [groupOf(row), row.tool], io);
239
+ if (readFirst.length) out.push(readFirstLine(readFirst), '');
240
+ // A top-level command's summary is its line in `juno --help`; the tool's
241
+ // description says the same at length.
242
+ if (row.summary && row.fixed_input) out.push(row.summary, '');
243
+ out.push(tool.description.trim(), '');
153
244
  out.push(`effect: ${row.effect ?? tool.effect}`);
154
245
  out.push(`tool: ${row.tool}`);
155
246
  if (row.id_field) {
@@ -177,6 +268,36 @@ export async function verbHelp(row, args, io) {
177
268
  return out.join('\n');
178
269
  }
179
270
 
271
+ /**
272
+ * skillsFor is the names of the skills whose applies_to names any of `names`
273
+ * (a group, a tool), in the index's order. The mapping is the server's: the CLI
274
+ * only matches the strings.
275
+ */
276
+ export function skillsFor(skills, names) {
277
+ const wanted = new Set(names.filter(Boolean));
278
+ return skills
279
+ .filter((s) => (s.applies_to ?? []).some((n) => wanted.has(n)))
280
+ .map((s) => s.name);
281
+ }
282
+
283
+ /**
284
+ * readFirstFor fetches the skill index and answers skillsFor over it. Its
285
+ * own failure costs only the line: the reason goes to stderr and the help
286
+ * goes on.
287
+ */
288
+ async function readFirstFor(client, names, io) {
289
+ try {
290
+ return skillsFor((await client.listSkills())?.skills ?? [], names);
291
+ } catch (err) {
292
+ io.warn(`(skills unavailable: ${err.message})`);
293
+ return [];
294
+ }
295
+ }
296
+
297
+ function readFirstLine(names) {
298
+ return `Read first: juno skill ${names.join(' ')}`;
299
+ }
300
+
180
301
  /** The help of a non-tool command, offline: its summary and its usage. */
181
302
  export function nonToolHelp(row, handler) {
182
303
  return `${row.summary}\n\nusage: ${handler.usage}`;