@junoflow/cli 0.1.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/help.js ADDED
@@ -0,0 +1,199 @@
1
+ import { connect } from './api.js';
2
+ import { AUTHORING_HEADINGS, authoringHeading } from './authoring-skills.js';
3
+ import { UsageError } from './errors.js';
4
+ import { groupOf } from './verbs.js';
5
+
6
+ const INPUT_USAGE = '[--input <json>|-|@file] [--jq <expr>]';
7
+
8
+ /**
9
+ * rowUsage is one command's synopsis: the positional and flags come from its
10
+ * verbs.json row, a non-tool command's from its handler.
11
+ */
12
+ export function rowUsage(row, handler) {
13
+ if (!row.tool) return handler.usage;
14
+ const parts = [`juno ${row.command}`];
15
+ const positional = positionalName(row);
16
+ if (positional) parts.push(`[${positional}]`);
17
+ for (const [flag, spec] of Object.entries(row.flags ?? {})) {
18
+ parts.push(`[--${flag} <${spec.type}>${spec.multiple ? '...' : ''}]`);
19
+ }
20
+ parts.push(INPUT_USAGE);
21
+ return parts.join(' ');
22
+ }
23
+
24
+ function positionalName(row) {
25
+ if (row.id_field) return `<${row.id_field}>`;
26
+ if (row.positional === 'kind:id') return '<kind>:<id> | <kind> <id>';
27
+ if (row.positional === 'file') return '<file>';
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * groupHelp lists a group's commands. The effect and first sentence of each
33
+ * tool's description come from GET /api/tools, because the server owns that
34
+ * text; when the instance cannot answer, the commands are listed alone and
35
+ * the reason goes to stderr. A help must not fail offline.
36
+ *
37
+ * A group with working-copy commands (`juno flow init`, `deploy`, …) lists
38
+ * them under a second heading, from their own `summary`, offline: they act on
39
+ * a directory, not on the instance, and an agent reading the help must be
40
+ * able to tell the two apart. `handlerFor(row)` is a non-tool row's handler.
41
+ */
42
+ export async function groupHelp(group, rows, handlerFor, args, io) {
43
+ const members = rows.filter((row) => groupOf(row) === group);
44
+ const onInstance = members.filter((row) => !row.working_copy);
45
+ const inWorkingCopy = members.filter((row) => row.working_copy);
46
+
47
+ // A kind with working copies also lists its authoring skills, by their
48
+ // first sentence, under the working-copy heading — the same index `init`
49
+ // prints, fetched the same way and dropped the same way offline.
50
+ const authoring = Object.hasOwn(AUTHORING_HEADINGS, group) ? group : null;
51
+ let tools = null;
52
+ let skills = null;
53
+ if (onInstance.some((row) => row.tool) || authoring) {
54
+ try {
55
+ const { client } = await connect(args, io);
56
+ if (onInstance.some((row) => row.tool)) {
57
+ tools = new Map((await client.listTools()).map((t) => [t.name, t]));
58
+ }
59
+ if (authoring) {
60
+ skills = (await client.listSkills({ authoring }))?.skills ?? [];
61
+ }
62
+ } catch (err) {
63
+ io.warn(`(descriptions unavailable: ${err.message})`);
64
+ }
65
+ }
66
+
67
+ 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];
72
+ const tool = tools?.get(row.tool);
73
+ // Offline, no tool row shows an effect: printing only the rows that carry
74
+ // 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
+ ];
81
+ });
82
+ // One column width across both headings, so the two lists line up.
83
+ const render = tabulate(lines);
84
+ const body = (subset) => subset.map((row) => render[members.indexOf(row)]);
85
+
86
+ const out = [
87
+ onInstance.some((row) => row.tool)
88
+ ? `usage: juno ${group} <verb> [id] ${INPUT_USAGE}`
89
+ : `usage: juno ${group} <verb>`,
90
+ '',
91
+ ];
92
+ if (inWorkingCopy.length === 0) {
93
+ out.push(...body(members));
94
+ } else {
95
+ out.push('On the instance:', ...body(onInstance), '');
96
+ out.push(`${WORKING_COPY_HEADING}:`, ...body(inWorkingCopy));
97
+ }
98
+ if (skills?.length) {
99
+ const width = Math.max(...skills.map((s) => s.name.length));
100
+ out.push('', authoringHeading(authoring));
101
+ for (const s of skills) {
102
+ out.push(` ${s.name.padEnd(width)} ${firstSentence(s.description)}`);
103
+ }
104
+ }
105
+ out.push(
106
+ '',
107
+ `Run \`juno ${group} <verb> --help\` for a command's full description and input schema.`
108
+ );
109
+ return out.join('\n');
110
+ }
111
+
112
+ export const WORKING_COPY_HEADING =
113
+ 'In a working copy (run from the project directory)';
114
+
115
+ /**
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
+ */
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');
125
+ }
126
+
127
+ /** Rows of [synopsis, effect, text] as aligned, indented lines. */
128
+ function tabulate(lines) {
129
+ const width = Math.max(...lines.map(([s]) => s.length));
130
+ const effectWidth = Math.max(...lines.map(([, e]) => (e ?? '').length));
131
+ return lines.map(([synopsis, effect = '', text]) =>
132
+ ` ${synopsis.padEnd(width)} ${(effect ?? '').padEnd(effectWidth)} ${text}`.trimEnd()
133
+ );
134
+ }
135
+
136
+ /**
137
+ * verbHelp is one tool command's help: the tool's full description, effect
138
+ * and input schema from GET /api/tools, plus what the row adds — positional,
139
+ * flags, fixed input. Unlike a group's, it needs the instance: the schema is
140
+ * the help.
141
+ */
142
+ export async function verbHelp(row, args, io) {
143
+ const { target, client } = await connect(args, io);
144
+ const tool = (await client.listTools()).find((t) => t.name === row.tool);
145
+ if (!tool) {
146
+ throw new UsageError(
147
+ `${target} does not serve the tool "${row.tool}"; ` +
148
+ 'the instance is probably older than this CLI'
149
+ );
150
+ }
151
+
152
+ const out = [`usage: ${rowUsage(row)}`, '', tool.description.trim(), ''];
153
+ out.push(`effect: ${row.effect ?? tool.effect}`);
154
+ out.push(`tool: ${row.tool}`);
155
+ if (row.id_field) {
156
+ out.push(`positional: <${row.id_field}> → input field "${row.id_field}"`);
157
+ } else if (row.positional === 'kind:id') {
158
+ out.push(
159
+ 'positional: <kind>:<id> or <kind> <id> → input fields "kind" and "id", split at the first ":"; ' +
160
+ 'ha:<entity_id> and mqtt:<topic> are endpoints'
161
+ );
162
+ } else if (row.positional === 'file') {
163
+ out.push(
164
+ 'positional: <file> → its content under "files" by basename, and "entry"'
165
+ );
166
+ }
167
+ for (const [flag, spec] of Object.entries(row.flags ?? {})) {
168
+ const repeat = spec.multiple ? ', repeatable' : '';
169
+ out.push(
170
+ `flag: --${flag} <${spec.type}${repeat}> → input field "${spec.field}"`
171
+ );
172
+ }
173
+ if (row.fixed_input) {
174
+ out.push(`always sends: ${JSON.stringify(row.fixed_input)}`);
175
+ }
176
+ out.push('', 'input schema:', JSON.stringify(tool.input_schema, null, 2));
177
+ return out.join('\n');
178
+ }
179
+
180
+ /** The help of a non-tool command, offline: its summary and its usage. */
181
+ export function nonToolHelp(row, handler) {
182
+ return `${row.summary}\n\nusage: ${handler.usage}`;
183
+ }
184
+
185
+ /**
186
+ * firstSentence is a description's first sentence, for a one-line listing:
187
+ * up to the first `.`, `!` or `?` followed by whitespace, skipping the ones
188
+ * that close an abbreviation such as "e.g.".
189
+ */
190
+ export function firstSentence(text = '') {
191
+ const flat = text.trim().replace(/\s+/g, ' ');
192
+ const end = /[.!?](?= |$)/g;
193
+ let m;
194
+ while ((m = end.exec(flat))) {
195
+ const upTo = flat.slice(0, m.index + 1);
196
+ if (!/\b(e\.g|i\.e|etc|vs)\.$/i.test(upTo)) return upTo;
197
+ }
198
+ return flat;
199
+ }