@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/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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { connect } from '../api.js';
|
|
5
|
+
import { printAuthoringIndex } from '../authoring-skills.js';
|
|
6
|
+
import { UsageError, UserError } from '../errors.js';
|
|
7
|
+
import { exists, safeRelative } from './init.js';
|
|
8
|
+
|
|
9
|
+
export const FLOW_INIT_USAGE = 'juno flow init <id> [--here]';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `juno flow init <id>` — a working copy of the flow's active version, or a
|
|
13
|
+
* stub for an id with none, written into ./<id> (or the current directory
|
|
14
|
+
* with --here).
|
|
15
|
+
*
|
|
16
|
+
* Every file comes from POST /api/flows/scaffold, the single generator of a
|
|
17
|
+
* flow working copy: the CLI writes what it is handed and holds no copy of
|
|
18
|
+
* the layout, the script lines or the types. After the next steps it prints
|
|
19
|
+
* the flow author's skill index, which is where an external agent starts.
|
|
20
|
+
*/
|
|
21
|
+
export async function flowInit(argv, io) {
|
|
22
|
+
const [id, ...extra] = argv._;
|
|
23
|
+
if (!id || extra.length > 0) {
|
|
24
|
+
throw new UsageError(`usage: ${FLOW_INIT_USAGE}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const dir = resolve(io.cwd, argv.here ? '.' : id);
|
|
28
|
+
if (!argv.here && (await exists(dir))) {
|
|
29
|
+
throw new UserError(`${dir} already exists`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { target, client } = await connect(argv, io);
|
|
33
|
+
// The id is validated server-side: one answer to "is this a valid flow id".
|
|
34
|
+
const { files, warnings } = await client.scaffoldFlow(id, { target });
|
|
35
|
+
|
|
36
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
37
|
+
const path = join(dir, safeRelative(rel));
|
|
38
|
+
await mkdir(dirname(path), { recursive: true });
|
|
39
|
+
await writeFile(path, content, 'utf8');
|
|
40
|
+
}
|
|
41
|
+
printScaffoldWarnings(warnings, io);
|
|
42
|
+
|
|
43
|
+
io.log(`created ${dir}`);
|
|
44
|
+
io.log('');
|
|
45
|
+
io.log(argv.here ? 'npm install' : `cd ${id} && npm install`);
|
|
46
|
+
io.log('npm test');
|
|
47
|
+
io.log('npm run deploy');
|
|
48
|
+
await printAuthoringIndex('flow', client, target, io);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A stored manifest the live catalog now refuses (a state it binds was
|
|
54
|
+
* deleted, say) still scaffolds, because the working copy is how it gets
|
|
55
|
+
* fixed; the refusal comes along as warnings, and they go to stderr.
|
|
56
|
+
*/
|
|
57
|
+
export function printScaffoldWarnings(warnings, io) {
|
|
58
|
+
for (const w of warnings ?? []) io.warn(`warning: ${w}`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { connect } from '../api.js';
|
|
2
|
+
import { UserError } from '../errors.js';
|
|
3
|
+
import {
|
|
4
|
+
FLOW_TS,
|
|
5
|
+
MANIFEST,
|
|
6
|
+
PACKAGE_JSON,
|
|
7
|
+
readFlowPackage,
|
|
8
|
+
updatePackageJSON,
|
|
9
|
+
writeFlowFiles,
|
|
10
|
+
} from '../flow-project.js';
|
|
11
|
+
import { printScaffoldWarnings } from './flow-init.js';
|
|
12
|
+
import { dirtyPaths } from './pull.js';
|
|
13
|
+
|
|
14
|
+
export const FLOW_PULL_USAGE = 'juno flow pull [--force]';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `juno flow pull` — overwrite the working copy with the flow's active
|
|
18
|
+
* version: manifest.json, flow.ts and both .d.ts files, plus package.json's
|
|
19
|
+
* `scripts`, `dependencies` and `juno.base_version` — every other key in
|
|
20
|
+
* package.json is the author's and stays. Everything comes from
|
|
21
|
+
* POST /api/flows/scaffold, the same render `init` writes, so a pulled copy
|
|
22
|
+
* and a fresh one cannot differ.
|
|
23
|
+
*
|
|
24
|
+
* It refuses a dirty git tree without --force, as integration pull does: an
|
|
25
|
+
* uncommitted edit is the one thing nothing can get back.
|
|
26
|
+
*/
|
|
27
|
+
export async function flowPull(argv, io) {
|
|
28
|
+
const { juno } = await readFlowPackage(io.cwd);
|
|
29
|
+
const flowId = juno.flow_id;
|
|
30
|
+
|
|
31
|
+
if (!argv.force) {
|
|
32
|
+
const dirty = await dirtyPaths(io.cwd);
|
|
33
|
+
if (dirty === null) {
|
|
34
|
+
io.warn(
|
|
35
|
+
`not a git repository — pull will overwrite ${MANIFEST} and ${FLOW_TS} with no undo`
|
|
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
|
+
}
|
|
44
|
+
|
|
45
|
+
const { target, client } = await connect(argv, io);
|
|
46
|
+
const { files, warnings } = await client.scaffoldFlow(flowId, { target });
|
|
47
|
+
let scaffoldPkg;
|
|
48
|
+
try {
|
|
49
|
+
scaffoldPkg = JSON.parse(files[PACKAGE_JSON]);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new UserError(
|
|
52
|
+
`${target} answered a working copy with no readable ${PACKAGE_JSON}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const version = scaffoldPkg.juno?.base_version;
|
|
56
|
+
// No base_version is the scaffold's stub for a flow with no active
|
|
57
|
+
// version; writing it over the author's work would lose it for nothing.
|
|
58
|
+
if (!Number.isInteger(version)) {
|
|
59
|
+
throw new UserError(
|
|
60
|
+
`${flowId} has no active version on ${target} — nothing to pull`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const written = await writeFlowFiles(
|
|
65
|
+
io.cwd,
|
|
66
|
+
Object.fromEntries(
|
|
67
|
+
Object.entries(files).filter(
|
|
68
|
+
([name]) =>
|
|
69
|
+
name === MANIFEST || name === FLOW_TS || name.endsWith('.d.ts')
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
await updatePackageJSON(io.cwd, (pkg) => {
|
|
74
|
+
pkg.scripts = scaffoldPkg.scripts;
|
|
75
|
+
if (scaffoldPkg.dependencies) pkg.dependencies = scaffoldPkg.dependencies;
|
|
76
|
+
else delete pkg.dependencies;
|
|
77
|
+
pkg.juno.base_version = version;
|
|
78
|
+
});
|
|
79
|
+
written.push(PACKAGE_JSON);
|
|
80
|
+
printScaffoldWarnings(warnings, io);
|
|
81
|
+
|
|
82
|
+
io.log(
|
|
83
|
+
`pulled ${flowId} v${version} from ${target} (${written.sort().join(', ')})`
|
|
84
|
+
);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
@@ -1,21 +1,28 @@
|
|
|
1
|
-
import { JunoClient } from '../api.js';
|
|
1
|
+
import { clientName, JunoClient } from '../api.js';
|
|
2
2
|
import { readManifest, readManifestRaw, writeTypes } from '../project.js';
|
|
3
3
|
import { stampTypeFiles } from '../stamp.js';
|
|
4
4
|
import { resolveTarget } from '../target.js';
|
|
5
5
|
|
|
6
|
+
export const GEN_TYPES_USAGE = 'juno integration gen-types';
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
|
-
* `juno types` — POST manifest.json, write back types/.
|
|
9
|
+
* `juno integration gen-types` — POST manifest.json, write back types/.
|
|
8
10
|
*
|
|
9
11
|
* Both halves come from one call so they cannot skew, and they are committed:
|
|
10
12
|
* a fresh clone compiles offline, and a contributor fixing a bug in someone
|
|
11
13
|
* else's integration never needs a Juno instance. Only the person editing the
|
|
12
14
|
* manifest does, which is the right person to require it of.
|
|
13
15
|
*/
|
|
14
|
-
export async function
|
|
15
|
-
const target = await resolveTarget({
|
|
16
|
+
export async function genTypes(argv, io) {
|
|
17
|
+
const target = await resolveTarget({
|
|
18
|
+
url: argv.url,
|
|
19
|
+
cwd: io.cwd,
|
|
20
|
+
env: io.env,
|
|
21
|
+
});
|
|
16
22
|
const raw = await readManifestRaw(io.cwd);
|
|
17
23
|
const manifest = await readManifest(io.cwd);
|
|
18
|
-
const client =
|
|
24
|
+
const client =
|
|
25
|
+
argv.client ?? new JunoClient(target, { client: clientName(io.env) });
|
|
19
26
|
|
|
20
27
|
const generated = await client.typegen(manifest);
|
|
21
28
|
const written = await writeTypes(
|
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,30 +1,41 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
|
|
6
|
-
import { JunoClient } from '../api.js';
|
|
6
|
+
import { clientName, JunoClient } from '../api.js';
|
|
7
7
|
import { UserError } from '../errors.js';
|
|
8
8
|
import { readManifest } from '../project.js';
|
|
9
9
|
import { resolveTarget } from '../target.js';
|
|
10
10
|
|
|
11
11
|
const execFileAsync = promisify(execFile);
|
|
12
12
|
|
|
13
|
+
export const PULL_USAGE = 'juno integration pull [--force] [--id <id>]';
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
|
-
* `juno pull` — write back the manifest and sources Juno holds
|
|
16
|
+
* `juno integration pull` — write back the manifest and sources Juno holds,
|
|
17
|
+
* and package.json's `scripts`.
|
|
15
18
|
*
|
|
16
19
|
* This exists because the zip is a good first touch and a bad second one: once
|
|
17
20
|
* the authoring agent has edited an integration you deployed, downloading the
|
|
18
21
|
* project again would clobber your working tree, while pull touches only the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
22
|
+
* things Juno owns. The script lines are among them — the server generates
|
|
23
|
+
* them (POST /api/integrations/scaffold renders this install's project), so a
|
|
24
|
+
* project made by an older CLI picks up the current commands; every other key
|
|
25
|
+
* in package.json is the author's and stays. It still refuses a dirty tree
|
|
26
|
+
* without --force, because an uncommitted edit is the one thing git cannot get
|
|
27
|
+
* back for you.
|
|
21
28
|
*/
|
|
22
29
|
export async function pull(argv, io) {
|
|
23
|
-
const target = await resolveTarget({
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
const target = await resolveTarget({
|
|
31
|
+
url: argv.url,
|
|
32
|
+
cwd: io.cwd,
|
|
33
|
+
env: io.env,
|
|
34
|
+
});
|
|
35
|
+
const id = argv.id ?? (await readManifest(io.cwd)).id;
|
|
36
|
+
if (!id) {
|
|
26
37
|
throw new UserError(
|
|
27
|
-
'manifest.json declares no
|
|
38
|
+
'manifest.json declares no id — pass --id <integration>'
|
|
28
39
|
);
|
|
29
40
|
}
|
|
30
41
|
|
|
@@ -32,7 +43,7 @@ export async function pull(argv, io) {
|
|
|
32
43
|
const dirty = await dirtyPaths(io.cwd);
|
|
33
44
|
if (dirty === null) {
|
|
34
45
|
io.warn(
|
|
35
|
-
'not a git repository — pull will overwrite manifest.json
|
|
46
|
+
'not a git repository — pull will overwrite manifest.json, src/ and package.json scripts with no undo'
|
|
36
47
|
);
|
|
37
48
|
} else if (dirty.length > 0) {
|
|
38
49
|
throw new UserError(
|
|
@@ -42,13 +53,20 @@ export async function pull(argv, io) {
|
|
|
42
53
|
}
|
|
43
54
|
}
|
|
44
55
|
|
|
45
|
-
const client =
|
|
46
|
-
|
|
56
|
+
const client =
|
|
57
|
+
argv.client ?? new JunoClient(target, { client: clientName(io.env) });
|
|
58
|
+
const detail = await client.getIntegration(id);
|
|
47
59
|
if (!detail?.manifest) {
|
|
48
60
|
throw new UserError(
|
|
49
|
-
`${
|
|
61
|
+
`${id} has no active version on ${target} — nothing to pull`
|
|
50
62
|
);
|
|
51
63
|
}
|
|
64
|
+
// Fetched before anything is written, so a failure here leaves the working
|
|
65
|
+
// copy as it was.
|
|
66
|
+
const scripts = scaffoldScripts(
|
|
67
|
+
await client.scaffold(id, { target }),
|
|
68
|
+
target
|
|
69
|
+
);
|
|
52
70
|
|
|
53
71
|
const written = ['manifest.json'];
|
|
54
72
|
await writeFile(
|
|
@@ -62,19 +80,55 @@ export async function pull(argv, io) {
|
|
|
62
80
|
await writeFile(path, content, 'utf8');
|
|
63
81
|
written.push(rel);
|
|
64
82
|
}
|
|
83
|
+
if (await replaceScripts(io.cwd, scripts, io)) written.push('package.json');
|
|
65
84
|
io.log(
|
|
66
|
-
`pulled ${
|
|
85
|
+
`pulled ${id} v${detail.version} from ${target} (${written.sort().join(', ')})`
|
|
86
|
+
);
|
|
87
|
+
io.log(
|
|
88
|
+
'run `npm run types` to regenerate types/ against the pulled manifest'
|
|
67
89
|
);
|
|
68
|
-
io.log('run `juno types` to regenerate types/ against the pulled manifest');
|
|
69
90
|
return 0;
|
|
70
91
|
}
|
|
71
92
|
|
|
93
|
+
/** The `scripts` of the package.json a scaffold answered. */
|
|
94
|
+
function scaffoldScripts(files, target) {
|
|
95
|
+
try {
|
|
96
|
+
const scripts = JSON.parse(files['package.json']).scripts;
|
|
97
|
+
if (scripts && typeof scripts === 'object') return scripts;
|
|
98
|
+
} catch {
|
|
99
|
+
// Reported below.
|
|
100
|
+
}
|
|
101
|
+
throw new UserError(
|
|
102
|
+
`${target} answered a project with no readable package.json scripts`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* replaceScripts sets package.json's `scripts`, keeping every other key and
|
|
108
|
+
* its order, written the way npm writes it. A missing or unreadable
|
|
109
|
+
* package.json is left alone with a warning: pull is not the place to invent
|
|
110
|
+
* one. Returns whether it wrote.
|
|
111
|
+
*/
|
|
112
|
+
async function replaceScripts(cwd, scripts, io) {
|
|
113
|
+
const path = join(cwd, 'package.json');
|
|
114
|
+
let pkg;
|
|
115
|
+
try {
|
|
116
|
+
pkg = JSON.parse(await readFile(path, 'utf8'));
|
|
117
|
+
} catch {
|
|
118
|
+
io.warn('no readable package.json — its scripts were not updated');
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
pkg.scripts = scripts;
|
|
122
|
+
await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
72
126
|
/**
|
|
73
127
|
* Returns the paths git reports as changed, [] for a clean tree, or null when
|
|
74
128
|
* this is not a git repository at all — three states the caller treats
|
|
75
129
|
* differently, so they must not collapse into a boolean.
|
|
76
130
|
*/
|
|
77
|
-
async function dirtyPaths(cwd) {
|
|
131
|
+
export async function dirtyPaths(cwd) {
|
|
78
132
|
try {
|
|
79
133
|
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
|
|
80
134
|
cwd,
|
|
@@ -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
|
+
}
|