@junoflow/cli 0.3.0 → 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/README.md +579 -54
- package/bin/juno.js +7 -0
- package/eslint.flow.config.js +33 -0
- package/package.json +7 -4
- package/src/api.js +395 -27
- package/src/authoring-skills.js +42 -0
- package/src/commands/agent-skill-install.js +64 -0
- package/src/commands/ask.js +60 -0
- package/src/commands/check.js +44 -16
- package/src/commands/config.js +26 -0
- package/src/commands/credential-set.js +68 -0
- package/src/commands/deploy.js +41 -50
- package/src/commands/flow-check.js +93 -0
- package/src/commands/flow-deploy.js +107 -0
- package/src/commands/flow-gen-types.js +30 -0
- package/src/commands/flow-init.js +59 -0
- package/src/commands/flow-pull.js +86 -0
- package/src/commands/{types.js → gen-types.js} +12 -5
- package/src/commands/init.js +28 -16
- package/src/commands/pull.js +70 -16
- package/src/commands/session-approve.js +73 -0
- package/src/commands/skill.js +73 -0
- package/src/commands/tool.js +249 -0
- package/src/commands/verify-types.js +4 -2
- package/src/config.js +34 -0
- package/src/diagnostics.js +14 -0
- package/src/errors.js +40 -0
- package/src/exit.js +22 -0
- package/src/flow-project.js +128 -0
- package/src/help.js +199 -0
- package/src/index.js +506 -75
- package/src/installed-skill.js +55 -0
- package/src/prompt.js +196 -0
- package/src/stamp.js +10 -9
- package/src/suggest.js +67 -0
- package/src/target.js +22 -7
- package/src/verbs.js +34 -0
- package/src/watch.js +50 -0
- package/verbs.json +227 -0
package/src/index.js
CHANGED
|
@@ -1,123 +1,554 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AGENT_SKILL_INSTALL_USAGE,
|
|
5
|
+
agentSkillInstall,
|
|
6
|
+
} from './commands/agent-skill-install.js';
|
|
7
|
+
import { ASK_USAGE, ask } from './commands/ask.js';
|
|
8
|
+
import { CHECK_USAGE, check } from './commands/check.js';
|
|
9
|
+
import { CONFIG_SET_USAGE, configSet } from './commands/config.js';
|
|
10
|
+
import {
|
|
11
|
+
CREDENTIAL_SET_USAGE,
|
|
12
|
+
credentialSet,
|
|
13
|
+
} from './commands/credential-set.js';
|
|
14
|
+
import { DEPLOY_USAGE, deploy } from './commands/deploy.js';
|
|
15
|
+
import { FLOW_CHECK_USAGE, flowCheck } from './commands/flow-check.js';
|
|
16
|
+
import { FLOW_DEPLOY_USAGE, flowDeploy } from './commands/flow-deploy.js';
|
|
17
|
+
import {
|
|
18
|
+
FLOW_GEN_TYPES_USAGE,
|
|
19
|
+
flowGenTypes,
|
|
20
|
+
} from './commands/flow-gen-types.js';
|
|
21
|
+
import { FLOW_INIT_USAGE, flowInit } from './commands/flow-init.js';
|
|
22
|
+
import { FLOW_PULL_USAGE, flowPull } from './commands/flow-pull.js';
|
|
23
|
+
import { GEN_TYPES_USAGE, genTypes } from './commands/gen-types.js';
|
|
24
|
+
import { INIT_USAGE, init } from './commands/init.js';
|
|
25
|
+
import { PULL_USAGE, pull } from './commands/pull.js';
|
|
26
|
+
import { SKILL_USAGE, skill } from './commands/skill.js';
|
|
27
|
+
import {
|
|
28
|
+
SESSION_APPROVE_USAGE,
|
|
29
|
+
sessionApprove,
|
|
30
|
+
} from './commands/session-approve.js';
|
|
31
|
+
import { runTool } from './commands/tool.js';
|
|
32
|
+
import { VERIFY_TYPES_USAGE, verifyTypes } from './commands/verify-types.js';
|
|
33
|
+
import {
|
|
34
|
+
ConflictError,
|
|
35
|
+
DiagnosticsError,
|
|
36
|
+
UnreachableError,
|
|
37
|
+
UsageError,
|
|
38
|
+
UserError,
|
|
39
|
+
} from './errors.js';
|
|
10
40
|
import { formatDiagnostics } from './diagnostics.js';
|
|
41
|
+
import {
|
|
42
|
+
EXIT_CONFLICT,
|
|
43
|
+
EXIT_FAILURE,
|
|
44
|
+
EXIT_OK,
|
|
45
|
+
EXIT_UNREACHABLE,
|
|
46
|
+
EXIT_USAGE,
|
|
47
|
+
} from './exit.js';
|
|
48
|
+
import {
|
|
49
|
+
groupHelp,
|
|
50
|
+
nonToolHelp,
|
|
51
|
+
verbHelp,
|
|
52
|
+
WORKING_COPY_HEADING,
|
|
53
|
+
workingCopyListing,
|
|
54
|
+
} from './help.js';
|
|
55
|
+
import { closest, closestByVerb, levenshtein } from './suggest.js';
|
|
56
|
+
import { groupOf, normalizeVerb, rowFor, rows } from './verbs.js';
|
|
11
57
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
58
|
+
const URL_OPTION = { url: { type: 'string' } };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The verbs.json rows that call no tool, by command. A row is dispatchable
|
|
62
|
+
* only once it has a handler here; until then the CLI treats it as unknown
|
|
63
|
+
* rather than advertising a command it cannot run.
|
|
64
|
+
*/
|
|
65
|
+
const NON_TOOL = {
|
|
66
|
+
skill: {
|
|
67
|
+
run: skill,
|
|
68
|
+
options: { ...URL_OPTION, json: { type: 'boolean', default: false } },
|
|
69
|
+
usage: SKILL_USAGE,
|
|
70
|
+
},
|
|
71
|
+
ask: {
|
|
72
|
+
run: ask,
|
|
73
|
+
options: {
|
|
74
|
+
...URL_OPTION,
|
|
75
|
+
session: { type: 'string' },
|
|
76
|
+
wait: { type: 'string' },
|
|
77
|
+
},
|
|
78
|
+
usage: ASK_USAGE,
|
|
79
|
+
},
|
|
80
|
+
'session approve': {
|
|
81
|
+
run: sessionApprove,
|
|
82
|
+
options: { ...URL_OPTION, wait: { type: 'string' } },
|
|
83
|
+
usage: SESSION_APPROVE_USAGE,
|
|
84
|
+
},
|
|
85
|
+
'credential set': {
|
|
86
|
+
run: credentialSet,
|
|
87
|
+
options: {
|
|
88
|
+
...URL_OPTION,
|
|
89
|
+
name: { type: 'string' },
|
|
90
|
+
description: { type: 'string' },
|
|
91
|
+
},
|
|
92
|
+
usage: CREDENTIAL_SET_USAGE,
|
|
93
|
+
},
|
|
94
|
+
'config set': { run: configSet, options: {}, usage: CONFIG_SET_USAGE },
|
|
95
|
+
'agent-skill install': {
|
|
96
|
+
run: agentSkillInstall,
|
|
97
|
+
options: {
|
|
98
|
+
...URL_OPTION,
|
|
99
|
+
dir: { type: 'string' },
|
|
100
|
+
'allow-change': { type: 'boolean', default: false },
|
|
101
|
+
'working-copy': { type: 'boolean', default: false },
|
|
102
|
+
'agents-md': { type: 'boolean', default: false },
|
|
103
|
+
json: { type: 'boolean', default: false },
|
|
104
|
+
},
|
|
105
|
+
usage: AGENT_SKILL_INSTALL_USAGE,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The working-copy commands: verbs.json rows with `working_copy: true`, which
|
|
111
|
+
* act on the project in the current directory rather than on the instance
|
|
112
|
+
* (plan-external-agents-flows-as-files.md, Decision 11). Like NON_TOOL, a row
|
|
113
|
+
* is dispatchable only once it has a handler here. `positional` is true only
|
|
114
|
+
* for `init`, which names what to make; every other one refuses a positional
|
|
115
|
+
* (see runRow), because the working copy, not an argument, says what it acts
|
|
116
|
+
* on.
|
|
117
|
+
*/
|
|
118
|
+
const WORKING_COPY = {
|
|
119
|
+
'flow init': {
|
|
120
|
+
run: flowInit,
|
|
121
|
+
options: { ...URL_OPTION, here: { type: 'boolean', default: false } },
|
|
122
|
+
usage: FLOW_INIT_USAGE,
|
|
123
|
+
positional: true,
|
|
124
|
+
},
|
|
125
|
+
'flow gen-types': {
|
|
126
|
+
run: flowGenTypes,
|
|
127
|
+
options: URL_OPTION,
|
|
128
|
+
usage: FLOW_GEN_TYPES_USAGE,
|
|
129
|
+
},
|
|
130
|
+
'flow check': {
|
|
131
|
+
run: flowCheck,
|
|
132
|
+
options: {
|
|
133
|
+
...URL_OPTION,
|
|
134
|
+
'skip-local': { type: 'boolean', default: false },
|
|
135
|
+
json: { type: 'boolean', default: false },
|
|
136
|
+
},
|
|
137
|
+
usage: FLOW_CHECK_USAGE,
|
|
138
|
+
},
|
|
139
|
+
'flow deploy': {
|
|
140
|
+
run: flowDeploy,
|
|
141
|
+
options: {
|
|
142
|
+
...URL_OPTION,
|
|
143
|
+
watch: { type: 'boolean', default: false },
|
|
144
|
+
force: { type: 'boolean', default: false },
|
|
145
|
+
json: { type: 'boolean', default: false },
|
|
146
|
+
},
|
|
147
|
+
usage: FLOW_DEPLOY_USAGE,
|
|
148
|
+
},
|
|
149
|
+
'flow pull': {
|
|
150
|
+
run: flowPull,
|
|
151
|
+
options: { ...URL_OPTION, force: { type: 'boolean', default: false } },
|
|
152
|
+
usage: FLOW_PULL_USAGE,
|
|
153
|
+
},
|
|
154
|
+
'integration init': {
|
|
155
|
+
run: init,
|
|
156
|
+
options: {
|
|
157
|
+
...URL_OPTION,
|
|
158
|
+
name: { type: 'string' },
|
|
159
|
+
here: { type: 'boolean', default: false },
|
|
160
|
+
},
|
|
161
|
+
usage: INIT_USAGE,
|
|
162
|
+
positional: true,
|
|
163
|
+
},
|
|
164
|
+
'integration gen-types': {
|
|
165
|
+
run: genTypes,
|
|
166
|
+
options: URL_OPTION,
|
|
167
|
+
usage: GEN_TYPES_USAGE,
|
|
168
|
+
},
|
|
169
|
+
'integration verify-types': {
|
|
170
|
+
run: verifyTypes,
|
|
171
|
+
options: {},
|
|
172
|
+
usage: VERIFY_TYPES_USAGE,
|
|
173
|
+
},
|
|
174
|
+
// check and deploy keep --id: deploy under a different install id than the
|
|
175
|
+
// manifest's. pull keeps it too, to pull such an install back.
|
|
176
|
+
'integration check': {
|
|
177
|
+
run: check,
|
|
178
|
+
options: {
|
|
179
|
+
...URL_OPTION,
|
|
180
|
+
id: { type: 'string' },
|
|
181
|
+
'skip-local': { type: 'boolean', default: false },
|
|
182
|
+
json: { type: 'boolean', default: false },
|
|
183
|
+
},
|
|
184
|
+
usage: CHECK_USAGE,
|
|
185
|
+
},
|
|
186
|
+
'integration deploy': {
|
|
187
|
+
run: deploy,
|
|
188
|
+
options: {
|
|
189
|
+
...URL_OPTION,
|
|
190
|
+
id: { type: 'string' },
|
|
191
|
+
watch: { type: 'boolean', default: false },
|
|
192
|
+
json: { type: 'boolean', default: false },
|
|
193
|
+
},
|
|
194
|
+
usage: DEPLOY_USAGE,
|
|
195
|
+
},
|
|
196
|
+
'integration pull': {
|
|
197
|
+
run: pull,
|
|
198
|
+
options: {
|
|
199
|
+
...URL_OPTION,
|
|
200
|
+
id: { type: 'string' },
|
|
201
|
+
force: { type: 'boolean', default: false },
|
|
202
|
+
},
|
|
203
|
+
usage: PULL_USAGE,
|
|
204
|
+
},
|
|
19
205
|
};
|
|
20
206
|
|
|
21
|
-
|
|
207
|
+
/** The handler of a non-tool row, working-copy or not, or undefined. */
|
|
208
|
+
function handlerFor(row) {
|
|
209
|
+
if (row.tool) return undefined;
|
|
210
|
+
const table = row.working_copy ? WORKING_COPY : NON_TOOL;
|
|
211
|
+
return Object.hasOwn(table, row.command) ? table[row.command] : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The rows the CLI can run: every tool row, and the non-tool and
|
|
216
|
+
* working-copy rows above.
|
|
217
|
+
*/
|
|
218
|
+
const DISPATCHABLE = rows.filter((row) => row.tool || handlerFor(row));
|
|
219
|
+
const GROUPS = new Set(DISPATCHABLE.map(groupOf).filter(Boolean));
|
|
220
|
+
|
|
221
|
+
function dispatchable(command) {
|
|
222
|
+
const row = rowFor(command);
|
|
223
|
+
return row && DISPATCHABLE.includes(row) ? row : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const USAGE = `juno — operate a Juno instance, and develop flows and integrations for it
|
|
22
227
|
|
|
23
228
|
Usage: juno <command> [options]
|
|
229
|
+
juno <group> <verb> [id] [--input <json>|-|@file] [--jq <expr>]
|
|
230
|
+
|
|
231
|
+
Groups: ${[...GROUPS].join(', ')}
|
|
232
|
+
Commands: ${DISPATCHABLE.filter((r) => !groupOf(r))
|
|
233
|
+
.map((r) => r.command)
|
|
234
|
+
.join(', ')}
|
|
235
|
+
Run \`juno <group> --help\` for a group's commands.
|
|
24
236
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
deploy [--watch] upload, verify, and activate a new version
|
|
31
|
-
pull write back the manifest and sources Juno holds
|
|
32
|
-
|
|
33
|
-
Options:
|
|
34
|
-
--url <url> the Juno instance; else $JUNO_URL, else package.json juno.target
|
|
35
|
-
--name <name> address an install under a different name than the manifest
|
|
36
|
-
--watch deploy on every save (deploy only)
|
|
37
|
-
--force overwrite a dirty working tree (pull only)
|
|
38
|
-
--here scaffold into the current directory (init only)
|
|
39
|
-
--skip-local skip the local tsc/eslint pass (check only)
|
|
40
|
-
-h, --help this
|
|
237
|
+
${WORKING_COPY_HEADING}:
|
|
238
|
+
${workingCopyListing(DISPATCHABLE, handlerFor)}
|
|
239
|
+
|
|
240
|
+
--url <url> picks the instance; else $JUNO_URL, package.json juno.target,
|
|
241
|
+
then the user config (juno config set url <url>).
|
|
41
242
|
`;
|
|
42
243
|
|
|
43
244
|
/**
|
|
44
245
|
* run is the whole CLI, injectable so the tests drive it without spawning a
|
|
45
|
-
* process: io carries cwd
|
|
46
|
-
* handed a client instead of building one from
|
|
246
|
+
* process: io carries cwd, env, the TTY bit, stdin and the three output
|
|
247
|
+
* streams, and a command may be handed a client instead of building one from
|
|
248
|
+
* a URL. It returns the exit code (exit.js).
|
|
47
249
|
*/
|
|
48
250
|
export async function run(argv, io = defaultIO()) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
251
|
+
try {
|
|
252
|
+
return await dispatch(argv, io);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
// Diagnostics print as file:line:col so an editor can click through; a
|
|
255
|
+
// UserError is a sentence about their input, and neither deserves a stack.
|
|
256
|
+
if (err instanceof DiagnosticsError) {
|
|
257
|
+
io.error(formatDiagnostics(err.diagnostics));
|
|
258
|
+
io.error(`\n${err.diagnostics.length} problem(s): ${err.message}`);
|
|
259
|
+
return EXIT_FAILURE;
|
|
260
|
+
}
|
|
261
|
+
if (err instanceof ConflictError) {
|
|
262
|
+
io.error(err.message);
|
|
263
|
+
return EXIT_CONFLICT;
|
|
264
|
+
}
|
|
265
|
+
if (err instanceof UsageError) {
|
|
266
|
+
io.error(err.message);
|
|
267
|
+
return EXIT_USAGE;
|
|
268
|
+
}
|
|
269
|
+
if (err instanceof UnreachableError) {
|
|
270
|
+
io.error(err.message);
|
|
271
|
+
return EXIT_UNREACHABLE;
|
|
272
|
+
}
|
|
273
|
+
if (err instanceof UserError) {
|
|
274
|
+
io.error(err.message);
|
|
275
|
+
return EXIT_FAILURE;
|
|
276
|
+
}
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* dispatch is two-level: the first word is a top-level row (`discover`) or a
|
|
283
|
+
* group, and a group's second word is the verb. A
|
|
284
|
+
* leading `--url` is accepted before the first word, so
|
|
285
|
+
* `juno --url <u> state get x` reads the way an alias would write it.
|
|
286
|
+
*/
|
|
287
|
+
async function dispatch(argv, io) {
|
|
288
|
+
const { url, rest } = leadingURL(argv);
|
|
289
|
+
const [first, ...tail] = rest;
|
|
290
|
+
|
|
291
|
+
if (!first || first === '--help' || first === '-h' || first === 'help') {
|
|
57
292
|
io.log(USAGE);
|
|
58
|
-
return
|
|
293
|
+
return first ? EXIT_OK : EXIT_USAGE;
|
|
59
294
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
295
|
+
|
|
296
|
+
const top = dispatchable(first);
|
|
297
|
+
if (top) return runRow(top, tail, url, io);
|
|
298
|
+
|
|
299
|
+
if (GROUPS.has(first)) {
|
|
300
|
+
const [verb, ...args] = tail;
|
|
301
|
+
// `juno state` alone is its help, as a usage error: the person has not
|
|
302
|
+
// said what to do yet.
|
|
303
|
+
if (!verb || verb.startsWith('-')) {
|
|
304
|
+
return runCommand(
|
|
305
|
+
{ options: URL_OPTION },
|
|
306
|
+
tail,
|
|
307
|
+
url,
|
|
308
|
+
io,
|
|
309
|
+
(parsed) => showGroupHelp(first, parsed, io, EXIT_OK),
|
|
310
|
+
(parsed) => showGroupHelp(first, parsed, io, EXIT_USAGE)
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
const row = dispatchable(`${first} ${normalizeVerb(verb)}`);
|
|
314
|
+
if (!row) {
|
|
315
|
+
throw unknownCommand(`${first} ${verb}`, [
|
|
316
|
+
`${first} ${normalizeVerb(verb)}`,
|
|
317
|
+
]);
|
|
318
|
+
}
|
|
319
|
+
return runRow(row, args, url, io);
|
|
65
320
|
}
|
|
66
321
|
|
|
322
|
+
const second = tail[0] && !tail[0].startsWith('-') ? tail[0] : null;
|
|
323
|
+
if (second) {
|
|
324
|
+
throw unknownTopLevel(`${first} ${second}`, first, [
|
|
325
|
+
`${first} ${normalizeVerb(second)}`,
|
|
326
|
+
first,
|
|
327
|
+
]);
|
|
328
|
+
}
|
|
329
|
+
throw unknownTopLevel(first, first, [first]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function showGroupHelp(group, args, io, code) {
|
|
333
|
+
io.log(await groupHelp(group, DISPATCHABLE, handlerFor, args, io));
|
|
334
|
+
return code;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** runRow runs one verbs.json row, a tool's or a non-tool command's. */
|
|
338
|
+
function runRow(row, argv, url, io) {
|
|
339
|
+
if (!row.tool) {
|
|
340
|
+
const handler = handlerFor(row);
|
|
341
|
+
const run =
|
|
342
|
+
row.working_copy && !handler.positional
|
|
343
|
+
? (args, io) => {
|
|
344
|
+
refusePositional(row, args);
|
|
345
|
+
return handler.run(args, io);
|
|
346
|
+
}
|
|
347
|
+
: handler.run;
|
|
348
|
+
return runCommand(
|
|
349
|
+
handler,
|
|
350
|
+
argv,
|
|
351
|
+
url,
|
|
352
|
+
io,
|
|
353
|
+
() => {
|
|
354
|
+
io.log(nonToolHelp(row, handler));
|
|
355
|
+
return EXIT_OK;
|
|
356
|
+
},
|
|
357
|
+
run
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const options = {
|
|
362
|
+
...URL_OPTION,
|
|
363
|
+
input: { type: 'string' },
|
|
364
|
+
jq: { type: 'string' },
|
|
365
|
+
};
|
|
366
|
+
for (const [flag, spec] of Object.entries(row.flags ?? {})) {
|
|
367
|
+
options[flag] = { type: 'string', multiple: spec.multiple === true };
|
|
368
|
+
}
|
|
369
|
+
return runCommand(
|
|
370
|
+
{ run: (args) => runTool(row, args, io), options },
|
|
371
|
+
argv,
|
|
372
|
+
url,
|
|
373
|
+
io,
|
|
374
|
+
async (args) => {
|
|
375
|
+
io.log(await verbHelp(row, args, io));
|
|
376
|
+
return EXIT_OK;
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* runCommand parses a command's own flags (plus --help) and runs it, or its
|
|
383
|
+
* help. A `--url` given before the command word fills in when the command's
|
|
384
|
+
* own is absent. `run` defaults to the command's own; a group without a verb
|
|
385
|
+
* passes its help instead.
|
|
386
|
+
*/
|
|
387
|
+
async function runCommand(command, argv, url, io, help, run = command.run) {
|
|
67
388
|
let parsed;
|
|
68
389
|
try {
|
|
69
390
|
parsed = parseArgs({
|
|
70
|
-
args:
|
|
391
|
+
args: argv,
|
|
71
392
|
allowPositionals: true,
|
|
72
393
|
options: {
|
|
73
|
-
|
|
74
|
-
name: { type: 'string' },
|
|
75
|
-
watch: { type: 'boolean', default: false },
|
|
76
|
-
force: { type: 'boolean', default: false },
|
|
77
|
-
here: { type: 'boolean', default: false },
|
|
78
|
-
'skip-local': { type: 'boolean', default: false },
|
|
394
|
+
...command.options,
|
|
79
395
|
help: { type: 'boolean', short: 'h', default: false },
|
|
80
396
|
},
|
|
81
397
|
});
|
|
82
398
|
} catch (err) {
|
|
83
|
-
|
|
84
|
-
return 1;
|
|
85
|
-
}
|
|
86
|
-
if (parsed.values.help) {
|
|
87
|
-
io.log(USAGE);
|
|
88
|
-
return 0;
|
|
399
|
+
throw new UsageError(err.message);
|
|
89
400
|
}
|
|
90
401
|
|
|
91
402
|
const args = { ...parsed.values, _: parsed.positionals, client: io.client };
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
403
|
+
if (url !== undefined && args.url === undefined) args.url = url;
|
|
404
|
+
if (parsed.values.help) return help(args);
|
|
405
|
+
return (await run(args, io)) ?? EXIT_OK;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* refusePositional: a working-copy command acts on the project in the current
|
|
410
|
+
* directory, so `juno flow deploy porch-light` is a usage error that says so
|
|
411
|
+
* and points at the command that makes a working copy of porch-light —
|
|
412
|
+
* rather than deploying whatever directory it happens to run in.
|
|
413
|
+
*/
|
|
414
|
+
function refusePositional(row, args) {
|
|
415
|
+
if (args._.length === 0) return;
|
|
416
|
+
const group = groupOf(row);
|
|
417
|
+
throw new UsageError(
|
|
418
|
+
`juno ${row.command} takes no argument: it acts on the working copy in ` +
|
|
419
|
+
'the current directory. ' +
|
|
420
|
+
`To get a working copy of "${args._[0]}", run \`juno ${group} init ${args._[0]}\` ` +
|
|
421
|
+
'and run this from inside it.'
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** leadingURL takes `--url <u>` / `--url=<u>` off the front of argv. */
|
|
426
|
+
function leadingURL(argv) {
|
|
427
|
+
let url;
|
|
428
|
+
let i = 0;
|
|
429
|
+
while (i < argv.length) {
|
|
430
|
+
if (argv[i] === '--url' && i + 1 < argv.length) {
|
|
431
|
+
url = argv[i + 1];
|
|
432
|
+
i += 2;
|
|
433
|
+
} else if (argv[i].startsWith('--url=')) {
|
|
434
|
+
url = argv[i].slice('--url='.length);
|
|
435
|
+
i += 1;
|
|
436
|
+
} else {
|
|
437
|
+
break;
|
|
105
438
|
}
|
|
106
|
-
throw err;
|
|
107
439
|
}
|
|
440
|
+
return { url, rest: argv.slice(i) };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* unknownCommand names what was typed and, when one is close, the command
|
|
445
|
+
* that was probably meant — rather than the whole help, which buries the fix.
|
|
446
|
+
*/
|
|
447
|
+
function unknownCommand(words, attempts) {
|
|
448
|
+
const candidates = [...DISPATCHABLE.map((row) => row.command), ...GROUPS];
|
|
449
|
+
const guess = closest(attempts, candidates);
|
|
450
|
+
const hint = guess ? `did you mean "juno ${guess}"?` : 'see juno --help';
|
|
451
|
+
return new UsageError(`unknown command "juno ${words}"; ${hint}`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* unknownTopLevel is unknownCommand for a first word that is neither a
|
|
456
|
+
* command nor a group. It may be a verb typed without its group: a bare
|
|
457
|
+
* `deploy` is nearer `juno integration deploy` (and `juno flow deploy`) than
|
|
458
|
+
* any top-level command, and the integration commands lived at the top level until
|
|
459
|
+
* 0.5.0. A verb match wins only when it is strictly nearer than every whole
|
|
460
|
+
* command, so a mistyped group (`juno stat get`) still finds its command.
|
|
461
|
+
*/
|
|
462
|
+
function unknownTopLevel(words, first, attempts) {
|
|
463
|
+
const commands = DISPATCHABLE.map((row) => row.command);
|
|
464
|
+
const whole = closest(attempts, [...commands, ...GROUPS]);
|
|
465
|
+
const wholeDistance = whole
|
|
466
|
+
? Math.min(...attempts.map((a) => levenshtein(a, whole)))
|
|
467
|
+
: Infinity;
|
|
468
|
+
const byVerb = closestByVerb(normalizeVerb(first), commands);
|
|
469
|
+
if (byVerb.commands.length === 0 || byVerb.distance >= wholeDistance) {
|
|
470
|
+
return unknownCommand(words, attempts);
|
|
471
|
+
}
|
|
472
|
+
const quoted = byVerb.commands.map((c) => `"juno ${c}"`);
|
|
473
|
+
const list =
|
|
474
|
+
quoted.length === 1
|
|
475
|
+
? quoted[0]
|
|
476
|
+
: `${quoted.slice(0, -1).join(', ')} or ${quoted.at(-1)}`;
|
|
477
|
+
return new UsageError(
|
|
478
|
+
`unknown command "juno ${words}"; did you mean ${list}?`
|
|
479
|
+
);
|
|
108
480
|
}
|
|
109
481
|
|
|
110
482
|
export function defaultIO() {
|
|
111
483
|
return {
|
|
112
484
|
cwd: process.cwd(),
|
|
485
|
+
env: process.env,
|
|
113
486
|
client: null,
|
|
487
|
+
isTTY: process.stdout.isTTY === true,
|
|
488
|
+
stdinIsTTY: process.stdin.isTTY === true,
|
|
489
|
+
readStdin,
|
|
490
|
+
readSecret,
|
|
114
491
|
log: (msg) => process.stdout.write(`${msg}\n`),
|
|
492
|
+
write: (text) => process.stdout.write(text),
|
|
115
493
|
warn: (msg) => process.stderr.write(`${msg}\n`),
|
|
116
494
|
error: (msg) => process.stderr.write(`${msg}\n`),
|
|
117
495
|
};
|
|
118
496
|
}
|
|
119
497
|
|
|
120
498
|
export { JunoClient } from './api.js';
|
|
121
|
-
export {
|
|
499
|
+
export {
|
|
500
|
+
DiagnosticsError,
|
|
501
|
+
UnreachableError,
|
|
502
|
+
UsageError,
|
|
503
|
+
UserError,
|
|
504
|
+
} from './errors.js';
|
|
122
505
|
export { resolveTarget } from './target.js';
|
|
123
506
|
export { readProject } from './project.js';
|
|
507
|
+
|
|
508
|
+
/** readStdin reads the whole of stdin; only `--input -` ever calls it. */
|
|
509
|
+
async function readStdin() {
|
|
510
|
+
const chunks = [];
|
|
511
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
512
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* readSecret prompts on stderr and reads one line from the terminal with echo
|
|
517
|
+
* off: raw mode, so no keystroke is echoed, restored before it returns.
|
|
518
|
+
* Backspace edits; Enter or Ctrl-D ends the line; Ctrl-C restores the mode
|
|
519
|
+
* and exits 130, as an interrupted command does. Only `credential set` calls
|
|
520
|
+
* it, and only once stdin is known to be a TTY.
|
|
521
|
+
*/
|
|
522
|
+
function readSecret(prompt) {
|
|
523
|
+
const stdin = process.stdin;
|
|
524
|
+
return new Promise((resolve) => {
|
|
525
|
+
let value = [];
|
|
526
|
+
const wasRaw = stdin.isRaw;
|
|
527
|
+
const done = () => {
|
|
528
|
+
stdin.off('data', onData);
|
|
529
|
+
stdin.setRawMode(wasRaw);
|
|
530
|
+
stdin.pause();
|
|
531
|
+
process.stderr.write('\n');
|
|
532
|
+
};
|
|
533
|
+
const onData = (chunk) => {
|
|
534
|
+
for (const ch of chunk) {
|
|
535
|
+
if (ch === '\u0003') {
|
|
536
|
+
done();
|
|
537
|
+
process.exit(130);
|
|
538
|
+
}
|
|
539
|
+
if (ch === '\r' || ch === '\n' || ch === '\u0004') {
|
|
540
|
+
done();
|
|
541
|
+
resolve(value.join(''));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
|
|
545
|
+
else value.push(ch);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
process.stderr.write(prompt);
|
|
549
|
+
stdin.setEncoding('utf8');
|
|
550
|
+
stdin.setRawMode(true);
|
|
551
|
+
stdin.on('data', onData);
|
|
552
|
+
stdin.resume();
|
|
553
|
+
});
|
|
554
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** Where `juno agent-skill install` writes the skill, relative to a root. */
|
|
6
|
+
export const SKILL_DIR = join('.claude', 'skills', 'juno');
|
|
7
|
+
|
|
8
|
+
const VERSION_LINE = /^\s*(?:<!--\s*)?juno-skill-version:\s*"?([^"\s]+)"?/m;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* installedSkill finds the Juno skill a harness would load: SKILL.md under
|
|
12
|
+
* `.claude/skills/juno` in the current directory or the nearest parent that
|
|
13
|
+
* has one, then in the home directory. It answers the first file found and the
|
|
14
|
+
* version it records (null when it records none), or null when there is no
|
|
15
|
+
* file. A file that cannot be read counts as absent: the check it serves is a
|
|
16
|
+
* courtesy, never a failure.
|
|
17
|
+
*/
|
|
18
|
+
export async function installedSkill(cwd, env = process.env) {
|
|
19
|
+
const candidates = [];
|
|
20
|
+
let dir = resolve(cwd);
|
|
21
|
+
for (;;) {
|
|
22
|
+
candidates.push(join(dir, SKILL_DIR, 'SKILL.md'));
|
|
23
|
+
const parent = dirname(dir);
|
|
24
|
+
if (parent === dir) break;
|
|
25
|
+
dir = parent;
|
|
26
|
+
}
|
|
27
|
+
candidates.push(join(env.HOME || homedir(), SKILL_DIR, 'SKILL.md'));
|
|
28
|
+
|
|
29
|
+
for (const path of candidates) {
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
text = await readFile(path, 'utf8');
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
return { path, version: VERSION_LINE.exec(text)?.[1] ?? null };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* warnIfStale says on stderr when the installed skill records a version other
|
|
43
|
+
* than the one the instance serves (`agent_skill_version` on GET /api/skills).
|
|
44
|
+
* Silent when either side has none.
|
|
45
|
+
*/
|
|
46
|
+
export async function warnIfStale(index, target, io) {
|
|
47
|
+
const served = index?.agent_skill_version;
|
|
48
|
+
if (!served) return;
|
|
49
|
+
const installed = await installedSkill(io.cwd, io.env);
|
|
50
|
+
if (!installed?.version || installed.version === served) return;
|
|
51
|
+
io.warn(
|
|
52
|
+
`the installed Juno skill (${installed.path}) is version ${installed.version}; ` +
|
|
53
|
+
`${target} serves ${served} — run \`juno agent-skill install\` to update it`
|
|
54
|
+
);
|
|
55
|
+
}
|