@junoflow/cli 0.3.0 → 0.9.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 +693 -52
- package/app-client.js +306 -0
- package/bin/juno.js +7 -0
- package/eslint.app.config.js +34 -0
- package/eslint.flow.config.js +35 -0
- package/package.json +12 -4
- package/src/api.js +541 -30
- package/src/app-dev/build.js +134 -0
- package/src/app-dev/harness.js +108 -0
- package/src/app-dev/panel.html +336 -0
- package/src/app-dev/panel.js +290 -0
- package/src/app-dev/server.js +347 -0
- package/src/app-dev/stream.js +224 -0
- package/src/app-dev/watch.js +47 -0
- package/src/app-project.js +133 -0
- package/src/authoring-skills.js +43 -0
- package/src/commands/agent-skill-install.js +64 -0
- package/src/commands/app-check.js +75 -0
- package/src/commands/app-deploy.js +102 -0
- package/src/commands/app-dev.js +141 -0
- package/src/commands/app-gen-types.js +37 -0
- package/src/commands/app-init.js +46 -0
- package/src/commands/app-pull.js +106 -0
- package/src/commands/ask.js +60 -0
- package/src/commands/check.js +71 -27
- package/src/commands/config.js +26 -0
- package/src/commands/credential-set.js +68 -0
- package/src/commands/deploy.js +95 -51
- package/src/commands/flow-check.js +75 -0
- package/src/commands/flow-deploy.js +110 -0
- package/src/commands/flow-fixture.js +67 -0
- package/src/commands/flow-gen-types.js +37 -0
- package/src/commands/flow-init.js +59 -0
- package/src/commands/flow-pull.js +115 -0
- package/src/commands/{types.js → gen-types.js} +12 -5
- package/src/commands/init.js +28 -16
- package/src/commands/pull.js +111 -63
- 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 +78 -0
- package/src/help.js +199 -0
- package/src/index.js +567 -75
- package/src/installed-skill.js +55 -0
- package/src/project.js +76 -8
- 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 +87 -0
- package/src/working-copy.js +262 -0
- package/tsconfig.page.json +15 -0
- package/verbs.json +276 -0
package/src/commands/init.js
CHANGED
|
@@ -1,33 +1,44 @@
|
|
|
1
1
|
import { mkdir, writeFile, access } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { JunoClient } from '../api.js';
|
|
5
|
-
import {
|
|
4
|
+
import { clientName, JunoClient } from '../api.js';
|
|
5
|
+
import { printAuthoringIndex } from '../authoring-skills.js';
|
|
6
|
+
import { UsageError, UserError } from '../errors.js';
|
|
6
7
|
import { resolveTarget } from '../target.js';
|
|
7
8
|
|
|
9
|
+
export const INIT_USAGE = 'juno integration init <id> [--name <name>] [--here]';
|
|
10
|
+
|
|
8
11
|
/**
|
|
9
|
-
* `juno init
|
|
12
|
+
* `juno integration init <id>` — a working copy of the install's active
|
|
13
|
+
* version, or a starting project for an id with none, written into ./<id>
|
|
14
|
+
* (or the current directory with --here).
|
|
10
15
|
*
|
|
11
16
|
* Every file comes from `POST /api/integrations/scaffold`, which renders it
|
|
12
17
|
* with the same generator behind **Develop locally → Download project**. The
|
|
13
18
|
* CLI holds no copy of the layout: a scaffold and an ejected project cannot
|
|
14
|
-
* drift, and the
|
|
15
|
-
* the server can produce.
|
|
19
|
+
* drift, and the project arrives with `types/` already generated, which only
|
|
20
|
+
* the server can produce. `--name` shapes only a starter. After the next steps
|
|
21
|
+
* it prints the integration author's skill index.
|
|
16
22
|
*/
|
|
17
23
|
export async function init(argv, io) {
|
|
18
|
-
const
|
|
19
|
-
if (!
|
|
24
|
+
const [id, ...extra] = argv._;
|
|
25
|
+
if (!id || extra.length > 0) throw new UsageError(`usage: ${INIT_USAGE}`);
|
|
20
26
|
|
|
21
|
-
const dir = resolve(io.cwd, argv.here ? '.' :
|
|
27
|
+
const dir = resolve(io.cwd, argv.here ? '.' : id);
|
|
22
28
|
if (!argv.here && (await exists(dir))) {
|
|
23
29
|
throw new UserError(`${dir} already exists`);
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
const target = await resolveTarget({
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
const target = await resolveTarget({
|
|
33
|
+
url: argv.url,
|
|
34
|
+
cwd: io.cwd,
|
|
35
|
+
env: io.env,
|
|
36
|
+
});
|
|
37
|
+
const client =
|
|
38
|
+
argv.client ?? new JunoClient(target, { client: clientName(io.env) });
|
|
39
|
+
// The id is validated server-side, against the same rules an install is:
|
|
40
|
+
// one answer to "is this a valid integration id" rather than two.
|
|
41
|
+
const files = await client.scaffold(id, { name: argv.name, target });
|
|
31
42
|
|
|
32
43
|
for (const [rel, content] of Object.entries(files)) {
|
|
33
44
|
const path = join(dir, safeRelative(rel));
|
|
@@ -37,9 +48,10 @@ export async function init(argv, io) {
|
|
|
37
48
|
|
|
38
49
|
io.log(`created ${dir}`);
|
|
39
50
|
io.log('');
|
|
40
|
-
io.log(argv.here ? 'npm install' : `cd ${
|
|
51
|
+
io.log(argv.here ? 'npm install' : `cd ${id} && npm install`);
|
|
41
52
|
io.log('npm test');
|
|
42
53
|
io.log('npm run deploy');
|
|
54
|
+
await printAuthoringIndex('integration', client, target, io);
|
|
43
55
|
return 0;
|
|
44
56
|
}
|
|
45
57
|
|
|
@@ -48,7 +60,7 @@ export async function init(argv, io) {
|
|
|
48
60
|
* but "write these paths" is the one place a bad answer escapes the project
|
|
49
61
|
* directory, and checking costs a line.
|
|
50
62
|
*/
|
|
51
|
-
function safeRelative(rel) {
|
|
63
|
+
export function safeRelative(rel) {
|
|
52
64
|
const parts = rel.split('/');
|
|
53
65
|
if (rel.startsWith('/') || parts.includes('..') || parts.includes('')) {
|
|
54
66
|
throw new UserError(
|
|
@@ -58,7 +70,7 @@ function safeRelative(rel) {
|
|
|
58
70
|
return parts.join(sep);
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
async function exists(path) {
|
|
73
|
+
export async function exists(path) {
|
|
62
74
|
try {
|
|
63
75
|
await access(path);
|
|
64
76
|
return true;
|
package/src/commands/pull.js
CHANGED
|
@@ -1,89 +1,137 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
|
-
import { promisify } from 'node:util';
|
|
5
|
-
|
|
6
|
-
import { JunoClient } from '../api.js';
|
|
1
|
+
import { clientName, JunoClient } from '../api.js';
|
|
7
2
|
import { UserError } from '../errors.js';
|
|
8
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
readIntegrationBaseVersion,
|
|
5
|
+
readManifest,
|
|
6
|
+
updateIntegrationPackage,
|
|
7
|
+
} from '../project.js';
|
|
8
|
+
import { stampTypeFiles } from '../stamp.js';
|
|
9
9
|
import { resolveTarget } from '../target.js';
|
|
10
|
+
import {
|
|
11
|
+
baseArtifactPaths,
|
|
12
|
+
MANIFEST,
|
|
13
|
+
manifestText,
|
|
14
|
+
pullArtifacts,
|
|
15
|
+
requireCleanTree,
|
|
16
|
+
setDependencies,
|
|
17
|
+
writeChangedFiles,
|
|
18
|
+
} from '../working-copy.js';
|
|
19
|
+
import { pickTypeFiles } from './gen-types.js';
|
|
10
20
|
|
|
11
|
-
const
|
|
21
|
+
export const PULL_USAGE = 'juno integration pull [--force] [--id <id>]';
|
|
12
22
|
|
|
13
23
|
/**
|
|
14
|
-
* `juno pull` —
|
|
24
|
+
* `juno integration pull` — make the working copy's artifact files the
|
|
25
|
+
* install's active version: manifest.json and every file under src/ and
|
|
26
|
+
* test/, exactly as the version stores them, plus types/ regenerated and
|
|
27
|
+
* stamped against the pulled manifest, and package.json's
|
|
28
|
+
* `juno.base_version` (the version pulled, which the next check and deploy
|
|
29
|
+
* send).
|
|
15
30
|
*
|
|
16
31
|
* This exists because the zip is a good first touch and a bad second one: once
|
|
17
32
|
* the authoring agent has edited an integration you deployed, downloading the
|
|
18
33
|
* project again would clobber your working tree, while pull touches only the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
34
|
+
* files Juno stores. The scaffold's script lines are not among them: they are
|
|
35
|
+
* project content, and an author's own edits to them are theirs — a fresh
|
|
36
|
+
* `juno integration init` is how to get the current ones. `dependencies` is
|
|
37
|
+
* replaced only when the pulled manifest declares some (an agent-authored
|
|
38
|
+
* integration); otherwise they live in package.json alone, which Juno does not
|
|
39
|
+
* store, and stay. Every other key in package.json, and of `juno`, stays.
|
|
40
|
+
*
|
|
41
|
+
* A local file is deleted only when the base version had it and the pulled
|
|
42
|
+
* one does not; a file neither has is one not deployed yet, and stays. It
|
|
43
|
+
* still refuses a dirty tree without --force, because an uncommitted edit is
|
|
44
|
+
* the one thing git cannot get back for you.
|
|
21
45
|
*/
|
|
22
46
|
export async function pull(argv, io) {
|
|
23
|
-
const target = await resolveTarget({
|
|
24
|
-
|
|
25
|
-
|
|
47
|
+
const target = await resolveTarget({
|
|
48
|
+
url: argv.url,
|
|
49
|
+
cwd: io.cwd,
|
|
50
|
+
env: io.env,
|
|
51
|
+
});
|
|
52
|
+
let manifestId;
|
|
53
|
+
try {
|
|
54
|
+
manifestId = (await readManifest(io.cwd)).id;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// With --id the manifest is about to be replaced, so a broken one is
|
|
57
|
+
// no reason to refuse.
|
|
58
|
+
if (argv.id === undefined) throw err;
|
|
59
|
+
}
|
|
60
|
+
const id = argv.id ?? manifestId;
|
|
61
|
+
if (!id) {
|
|
26
62
|
throw new UserError(
|
|
27
|
-
'manifest.json declares no
|
|
63
|
+
'manifest.json declares no id — pass --id <integration>'
|
|
28
64
|
);
|
|
29
65
|
}
|
|
30
66
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
);
|
|
37
|
-
} else if (dirty.length > 0) {
|
|
38
|
-
throw new UserError(
|
|
39
|
-
`working tree has uncommitted changes:\n${dirty.map((p) => ` ${p}`).join('\n')}\n` +
|
|
40
|
-
'Commit or stash them, or pass --force to overwrite.'
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
67
|
+
await requireCleanTree(io.cwd, {
|
|
68
|
+
force: argv.force,
|
|
69
|
+
overwrites: `${MANIFEST}, src/, test/ and types/`,
|
|
70
|
+
io,
|
|
71
|
+
});
|
|
44
72
|
|
|
45
|
-
const client =
|
|
46
|
-
|
|
73
|
+
const client =
|
|
74
|
+
argv.client ?? new JunoClient(target, { client: clientName(io.env) });
|
|
75
|
+
const detail = await client.getIntegration(id);
|
|
47
76
|
if (!detail?.manifest) {
|
|
48
77
|
throw new UserError(
|
|
49
|
-
`${
|
|
78
|
+
`${id} has no active version on ${target} — nothing to pull`
|
|
50
79
|
);
|
|
51
80
|
}
|
|
81
|
+
// Everything is fetched before anything is written, so a failure leaves
|
|
82
|
+
// the working copy as it was.
|
|
83
|
+
const manifestRaw = manifestText(detail.manifest);
|
|
84
|
+
const stored = { [MANIFEST]: manifestRaw, ...detail.source_files };
|
|
85
|
+
const types = stampTypeFiles(
|
|
86
|
+
pickTypeFiles(await client.typegen(detail.manifest)),
|
|
87
|
+
manifestRaw
|
|
88
|
+
);
|
|
89
|
+
// The base belongs to the install the manifest names; pulling another
|
|
90
|
+
// (--id) has no base to diff against, so it deletes nothing.
|
|
91
|
+
const base = await baseArtifactPaths({
|
|
92
|
+
base:
|
|
93
|
+
id === manifestId ? await readIntegrationBaseVersion(io.cwd) : undefined,
|
|
94
|
+
version: detail.version,
|
|
95
|
+
stored,
|
|
96
|
+
read: async (v) => {
|
|
97
|
+
const b = await client.getIntegrationVersion(id, v);
|
|
98
|
+
return b ? [MANIFEST, ...Object.keys(b.SourceFiles ?? {})] : null;
|
|
99
|
+
},
|
|
100
|
+
id,
|
|
101
|
+
target,
|
|
102
|
+
io,
|
|
103
|
+
});
|
|
52
104
|
|
|
53
|
-
const written =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
105
|
+
const { written, removed } = await pullArtifacts(io.cwd, {
|
|
106
|
+
stored,
|
|
107
|
+
base,
|
|
108
|
+
kind: 'integration',
|
|
109
|
+
});
|
|
110
|
+
written.push(
|
|
111
|
+
...(await writeChangedFiles(
|
|
112
|
+
io.cwd,
|
|
113
|
+
Object.fromEntries(
|
|
114
|
+
Object.entries(types).map(([name, content]) => [
|
|
115
|
+
`types/${name}`,
|
|
116
|
+
content,
|
|
117
|
+
])
|
|
118
|
+
)
|
|
119
|
+
))
|
|
58
120
|
);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
121
|
+
const wrote = await updateIntegrationPackage(
|
|
122
|
+
io.cwd,
|
|
123
|
+
(pkg) => {
|
|
124
|
+
if (Object.keys(detail.manifest.dependencies ?? {}).length) {
|
|
125
|
+
setDependencies(pkg, detail.manifest);
|
|
126
|
+
}
|
|
127
|
+
pkg.juno.base_version = detail.version;
|
|
128
|
+
},
|
|
129
|
+
io
|
|
130
|
+
);
|
|
131
|
+
if (wrote) written.push('package.json');
|
|
65
132
|
io.log(
|
|
66
|
-
`pulled ${
|
|
133
|
+
`pulled ${id} v${detail.version} from ${target} (${written.sort().join(', ')})`
|
|
67
134
|
);
|
|
68
|
-
io.log(
|
|
135
|
+
if (removed.length) io.log(`removed ${removed.join(', ')}`);
|
|
69
136
|
return 0;
|
|
70
137
|
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Returns the paths git reports as changed, [] for a clean tree, or null when
|
|
74
|
-
* this is not a git repository at all — three states the caller treats
|
|
75
|
-
* differently, so they must not collapse into a boolean.
|
|
76
|
-
*/
|
|
77
|
-
async function dirtyPaths(cwd) {
|
|
78
|
-
try {
|
|
79
|
-
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
|
|
80
|
-
cwd,
|
|
81
|
-
});
|
|
82
|
-
return stdout
|
|
83
|
-
.split('\n')
|
|
84
|
-
.map((line) => line.trim())
|
|
85
|
-
.filter(Boolean);
|
|
86
|
-
} catch {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { connect } from '../api.js';
|
|
4
|
+
import { UsageError, UserError } from '../errors.js';
|
|
5
|
+
import { converse, finish } from '../prompt.js';
|
|
6
|
+
import { waitSeconds } from './ask.js';
|
|
7
|
+
|
|
8
|
+
export const SESSION_APPROVE_USAGE =
|
|
9
|
+
'juno session approve <session-id> [--wait <seconds>]';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `juno session approve <id>` lets through the tool call an assistant session
|
|
13
|
+
* is waiting on. It is its own command, not `juno ask … approve`, so that a
|
|
14
|
+
* permission rule allowing `juno ask` does not also let a coding agent
|
|
15
|
+
* approve the assistant's call_service on the user's behalf.
|
|
16
|
+
*
|
|
17
|
+
* It checks first that the session is awaiting an approval — anything else
|
|
18
|
+
* sent to a question would be taken as the answer — then sends `approve` as
|
|
19
|
+
* `juno ask` would, and prints and exits the same way.
|
|
20
|
+
*/
|
|
21
|
+
export async function sessionApprove(args, io) {
|
|
22
|
+
const [id, ...extra] = args._;
|
|
23
|
+
if (id === undefined || extra.length) {
|
|
24
|
+
throw new UsageError(`usage: ${SESSION_APPROVE_USAGE}`);
|
|
25
|
+
}
|
|
26
|
+
const wait = waitSeconds(args.wait);
|
|
27
|
+
|
|
28
|
+
const { client } = await connect(args, io);
|
|
29
|
+
const session = await client.getAgentSession(id);
|
|
30
|
+
if (!session) throw new UserError(`no assistant session ${id}`);
|
|
31
|
+
const refusal = notAwaitingApproval(session, id);
|
|
32
|
+
if (refusal) throw new UserError(refusal);
|
|
33
|
+
|
|
34
|
+
const body = {
|
|
35
|
+
session_id: id,
|
|
36
|
+
message: 'approve',
|
|
37
|
+
message_id: randomUUID(),
|
|
38
|
+
wait_timeout_seconds: wait,
|
|
39
|
+
};
|
|
40
|
+
// Never resent after a busy 409: whatever is pending by then is not what
|
|
41
|
+
// was checked above.
|
|
42
|
+
const outcome = await converse(client, body, {
|
|
43
|
+
stream: false,
|
|
44
|
+
io,
|
|
45
|
+
resendWhenBusy: false,
|
|
46
|
+
});
|
|
47
|
+
return finish(outcome, id, io);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What the session is doing instead of waiting on an approval, if it is. */
|
|
51
|
+
function notAwaitingApproval(session, id) {
|
|
52
|
+
const pending = session.pending_input;
|
|
53
|
+
if (session.status === 'awaiting_input' && pending?.kind === 'approval') {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (session.status === 'awaiting_input') {
|
|
57
|
+
return (
|
|
58
|
+
`session ${id} is asking a question, not waiting for an approval: ` +
|
|
59
|
+
`${JSON.stringify(pending?.question ?? '')}\n` +
|
|
60
|
+
`answer it with: juno ask --session ${id} "<answer>"`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (session.status === 'active') {
|
|
64
|
+
return (
|
|
65
|
+
`session ${id} is running a turn and has nothing to approve yet; ` +
|
|
66
|
+
`wait for it with: juno ask --session ${id}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (session.status === 'idle') {
|
|
70
|
+
return `session ${id} is idle and has nothing to approve`;
|
|
71
|
+
}
|
|
72
|
+
return `session ${id} has ended (status: ${session.status}); there is nothing to approve`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { connect } from '../api.js';
|
|
2
|
+
import { UsageError } from '../errors.js';
|
|
3
|
+
import { warnIfStale } from '../installed-skill.js';
|
|
4
|
+
import { levenshtein } from '../suggest.js';
|
|
5
|
+
|
|
6
|
+
export const SKILL_USAGE = 'juno skill [<name>] [--json]';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `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).
|
|
15
|
+
*/
|
|
16
|
+
export async function skill(args, io) {
|
|
17
|
+
const [name, ...extra] = args._;
|
|
18
|
+
if (extra.length) throw new UsageError(`usage: ${SKILL_USAGE}`);
|
|
19
|
+
|
|
20
|
+
const { target, client } = await connect(args, io);
|
|
21
|
+
|
|
22
|
+
if (name === undefined) {
|
|
23
|
+
const index = await client.listSkills();
|
|
24
|
+
await warnIfStale(index, target, io);
|
|
25
|
+
if (args.json) return printJSON(index, io);
|
|
26
|
+
for (const s of index?.skills ?? []) {
|
|
27
|
+
io.log(`${s.name} — ${s.description}`);
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const detail = await client.getSkill(name);
|
|
33
|
+
if (!detail) {
|
|
34
|
+
const { skills = [] } = (await client.listSkills()) ?? {};
|
|
35
|
+
throw unknownSkill(
|
|
36
|
+
name,
|
|
37
|
+
skills.map((s) => s.name)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
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);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Compact off a TTY, indented on one, as every JSON the CLI prints. */
|
|
47
|
+
function printJSON(value, io) {
|
|
48
|
+
io.log(io.isTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value));
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* unknownSkill names up to three served skills near what was typed. Skill
|
|
54
|
+
* names are short, so the distance allowed grows with the name, and a name
|
|
55
|
+
* that contains what was typed (`integration` → `integration-states`) counts
|
|
56
|
+
* as near.
|
|
57
|
+
*/
|
|
58
|
+
function unknownSkill(name, names) {
|
|
59
|
+
const max = Math.max(3, Math.floor(name.length / 2));
|
|
60
|
+
const near = names
|
|
61
|
+
.map((n) => ({
|
|
62
|
+
n,
|
|
63
|
+
d: n.includes(name) ? 0 : levenshtein(name, n),
|
|
64
|
+
}))
|
|
65
|
+
.filter(({ d }) => d <= max)
|
|
66
|
+
.sort((a, b) => a.d - b.d)
|
|
67
|
+
.slice(0, 3)
|
|
68
|
+
.map(({ n }) => `"${n}"`);
|
|
69
|
+
const hint = near.length
|
|
70
|
+
? `did you mean ${near.join(', ')}?`
|
|
71
|
+
: 'run `juno skill` for the list';
|
|
72
|
+
return new UsageError(`unknown skill "${name}"; ${hint}`);
|
|
73
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
|
|
6
|
+
import { connect } from '../api.js';
|
|
7
|
+
import { UsageError, UserError } from '../errors.js';
|
|
8
|
+
import { EXIT_FAILURE, EXIT_OK } from '../exit.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* runTool runs one tool row of verbs.json: build the tool's input from the
|
|
12
|
+
* command line, POST it to the gateway, print the answer. It holds no tool
|
|
13
|
+
* semantics — the gateway validates the input against the tool's schema and
|
|
14
|
+
* answers 422 naming the field — so nothing here knows what any field means
|
|
15
|
+
* beyond which one the positional fills.
|
|
16
|
+
*/
|
|
17
|
+
export async function runTool(row, args, io) {
|
|
18
|
+
const input = await buildInput(row, args, io);
|
|
19
|
+
|
|
20
|
+
const { client } = await connect(args, io);
|
|
21
|
+
const { result, text } = await client.callTool(row.tool, input);
|
|
22
|
+
|
|
23
|
+
if (args.jq !== undefined) {
|
|
24
|
+
io.log(await runJq(args.jq, text, io));
|
|
25
|
+
} else {
|
|
26
|
+
io.log(io.isTTY ? JSON.stringify(result, null, 2) : text.trimEnd());
|
|
27
|
+
}
|
|
28
|
+
// 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;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* buildInput merges in Decision 8's order: the --input object, then the
|
|
35
|
+
* positional into its field, then the row's flags, then fixed_input. A later
|
|
36
|
+
* source may fill a field but never silently overwrite one: a disagreement is
|
|
37
|
+
* a usage error naming both, because either could be the one the person meant.
|
|
38
|
+
*/
|
|
39
|
+
async function buildInput(row, args, io) {
|
|
40
|
+
const input = await readInput(args.input, io);
|
|
41
|
+
const positionals = args._;
|
|
42
|
+
|
|
43
|
+
// `<kind> <id>` is what discover's {kind, id} reads as, so a kind:id row
|
|
44
|
+
// takes it as two words as well as one.
|
|
45
|
+
if (row.positional === 'kind:id' && positionals.length === 2) {
|
|
46
|
+
put(input, 'kind', positionals[0], 'the positional argument');
|
|
47
|
+
put(input, 'id', positionals[1], 'the positional argument');
|
|
48
|
+
return finishInput(row, args, input);
|
|
49
|
+
}
|
|
50
|
+
if (positionals.length > 1) {
|
|
51
|
+
throw new UsageError(
|
|
52
|
+
`juno ${row.command} takes at most one positional argument, got ${positionals.length}: ${positionals.join(' ')}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const [positional] = positionals;
|
|
56
|
+
if (positional !== undefined) {
|
|
57
|
+
if (row.id_field) {
|
|
58
|
+
put(input, row.id_field, positional, 'the positional argument');
|
|
59
|
+
} else if (row.positional === 'kind:id') {
|
|
60
|
+
putKindID(input, positional);
|
|
61
|
+
} else if (row.positional === 'file') {
|
|
62
|
+
await putFile(input, positional, io);
|
|
63
|
+
} else {
|
|
64
|
+
throw new UsageError(
|
|
65
|
+
`juno ${row.command} takes no positional argument; pass its input with --input`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return finishInput(row, args, input);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** finishInput adds the row's flags, then its fixed_input. */
|
|
73
|
+
function finishInput(row, args, input) {
|
|
74
|
+
for (const [flag, spec] of Object.entries(row.flags ?? {})) {
|
|
75
|
+
if (args[flag] === undefined) continue;
|
|
76
|
+
const values = spec.multiple ? args[flag] : [args[flag]];
|
|
77
|
+
const typed = values.map((v) => flagValue(flag, spec, v));
|
|
78
|
+
put(input, spec.field, spec.multiple ? typed : typed[0], `--${flag}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const [field, value] of Object.entries(row.fixed_input ?? {})) {
|
|
82
|
+
if (
|
|
83
|
+
Object.hasOwn(input, field) &&
|
|
84
|
+
!isDeepStrictEqual(input[field], value)
|
|
85
|
+
) {
|
|
86
|
+
throw new UsageError(
|
|
87
|
+
`juno ${row.command} always sends ${JSON.stringify(field)}: ${JSON.stringify(value)}, ` +
|
|
88
|
+
`but --input has ${JSON.stringify(input[field])}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
input[field] = value;
|
|
92
|
+
}
|
|
93
|
+
return input;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** put sets input[field] unless --input already holds a different value. */
|
|
97
|
+
function put(input, field, value, source) {
|
|
98
|
+
if (Object.hasOwn(input, field) && !isDeepStrictEqual(input[field], value)) {
|
|
99
|
+
throw new UsageError(
|
|
100
|
+
`--input has ${JSON.stringify(field)}: ${JSON.stringify(input[field])}, ` +
|
|
101
|
+
`but ${source} says ${JSON.stringify(value)}; give it once`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
input[field] = value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* putKindID splits `<kind>:<id>` on the first colon only: an endpoint id such
|
|
109
|
+
* as `ha:light.hallway` or `mqtt:zigbee2mqtt/porch/set` carries its own. An
|
|
110
|
+
* endpoint id on its own is an endpoint — `ha:` and `mqtt:` are what the tool
|
|
111
|
+
* calls the address, so `juno related ha:light.hallway` means what it says.
|
|
112
|
+
*/
|
|
113
|
+
function putKindID(input, arg) {
|
|
114
|
+
const colon = arg.indexOf(':');
|
|
115
|
+
if (colon <= 0 || colon === arg.length - 1) {
|
|
116
|
+
throw new UsageError(
|
|
117
|
+
`expected <kind>:<id> or <kind> <id>, e.g. state:porch-dark or ha:light.hallway; got "${arg}"`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const kind = arg.slice(0, colon);
|
|
121
|
+
if (ENDPOINT_SCHEMES.has(kind)) {
|
|
122
|
+
put(input, 'kind', 'endpoint', `the positional argument`);
|
|
123
|
+
put(input, 'id', arg, `the positional argument`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
put(input, 'kind', kind, `the positional argument`);
|
|
127
|
+
put(input, 'id', arg.slice(colon + 1), `the positional argument`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** ENDPOINT_SCHEMES are the prefixes of an endpoint id, as get_related spells it. */
|
|
131
|
+
const ENDPOINT_SCHEMES = new Set(['ha', 'mqtt']);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* putFile reads a script into `files` under its basename and makes it the
|
|
135
|
+
* `entry`. Other files from --input ride along; one with the same name is a
|
|
136
|
+
* usage error rather than silently replaced.
|
|
137
|
+
*/
|
|
138
|
+
async function putFile(input, arg, io) {
|
|
139
|
+
const path = resolve(io.cwd, arg);
|
|
140
|
+
let content;
|
|
141
|
+
try {
|
|
142
|
+
content = await readFile(path, 'utf8');
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw new UsageError(`cannot read ${path}: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
const name = basename(path);
|
|
147
|
+
const files = input.files ?? {};
|
|
148
|
+
if (files === null || typeof files !== 'object' || Array.isArray(files)) {
|
|
149
|
+
throw new UsageError('--input has "files" that is not an object');
|
|
150
|
+
}
|
|
151
|
+
if (Object.hasOwn(files, name)) {
|
|
152
|
+
throw new UsageError(
|
|
153
|
+
`--input already has a file named "${name}" under "files", and so does the positional ${arg}; give it once`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
input.files = { ...files, [name]: content };
|
|
157
|
+
put(input, 'entry', name, `the positional argument ${arg}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** flagValue types one flag value as verbs.json declares it. */
|
|
161
|
+
function flagValue(flag, spec, value) {
|
|
162
|
+
if (spec.type !== 'integer') return value;
|
|
163
|
+
if (!/^-?\d+$/.test(value)) {
|
|
164
|
+
throw new UsageError(`--${flag} takes an integer, got "${value}"`);
|
|
165
|
+
}
|
|
166
|
+
return Number(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* readInput parses --input: `-` is stdin, `@path` a file relative to the
|
|
171
|
+
* working directory, anything else the JSON itself. Stdin is read only when
|
|
172
|
+
* asked for — an open, empty stdin would hang an agent — and an absent
|
|
173
|
+
* --input is `{}`.
|
|
174
|
+
*/
|
|
175
|
+
async function readInput(spec, io) {
|
|
176
|
+
if (spec === undefined) return {};
|
|
177
|
+
|
|
178
|
+
let source;
|
|
179
|
+
let raw;
|
|
180
|
+
if (spec === '-') {
|
|
181
|
+
source = 'stdin (--input -)';
|
|
182
|
+
raw = await io.readStdin();
|
|
183
|
+
} else if (spec.startsWith('@')) {
|
|
184
|
+
const path = resolve(io.cwd, spec.slice(1));
|
|
185
|
+
source = `--input ${spec}`;
|
|
186
|
+
try {
|
|
187
|
+
raw = await readFile(path, 'utf8');
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new UsageError(`cannot read ${path} (${source}): ${err.message}`);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
source = '--input';
|
|
193
|
+
raw = spec;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let value;
|
|
197
|
+
try {
|
|
198
|
+
value = JSON.parse(raw);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
throw new UsageError(`${source} is not JSON: ${err.message}`);
|
|
201
|
+
}
|
|
202
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
203
|
+
throw new UsageError(`${source} must be a JSON object`);
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* runJq pipes the result through the jq on PATH. It is a subprocess, not a
|
|
210
|
+
* library: `--jq` exists so a filter stays inside the `juno <kind> <verb>`
|
|
211
|
+
* permission rule, and the CLI takes on no jq implementation of its own.
|
|
212
|
+
*/
|
|
213
|
+
function runJq(expr, text, io) {
|
|
214
|
+
const argv = io.isTTY ? [expr] : ['-c', expr];
|
|
215
|
+
return new Promise((resolvePromise, reject) => {
|
|
216
|
+
let child;
|
|
217
|
+
try {
|
|
218
|
+
child = spawn('jq', argv, {
|
|
219
|
+
env: io.env,
|
|
220
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
221
|
+
});
|
|
222
|
+
} catch (err) {
|
|
223
|
+
reject(jqSpawnError(err));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const out = [];
|
|
227
|
+
const err = [];
|
|
228
|
+
child.stdout.on('data', (c) => out.push(c));
|
|
229
|
+
child.stderr.on('data', (c) => err.push(c));
|
|
230
|
+
child.on('error', (e) => reject(jqSpawnError(e)));
|
|
231
|
+
child.on('close', (code) => {
|
|
232
|
+
if (code === 0) {
|
|
233
|
+
resolvePromise(Buffer.concat(out).toString('utf8').replace(/\n$/, ''));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const stderr = Buffer.concat(err).toString('utf8').trim();
|
|
237
|
+
reject(new UsageError(stderr || `jq exited ${code}`));
|
|
238
|
+
});
|
|
239
|
+
// jq may exit before reading everything (a bad expression); the EPIPE
|
|
240
|
+
// that follows is not the failure worth reporting.
|
|
241
|
+
child.stdin.on('error', () => {});
|
|
242
|
+
child.stdin.end(text);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function jqSpawnError(err) {
|
|
247
|
+
if (err.code === 'ENOENT') return new UsageError('jq is not installed');
|
|
248
|
+
return new UserError(`cannot run jq: ${err.message}`);
|
|
249
|
+
}
|