@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.
@@ -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 types(argv, io) {
15
- const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
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 = argv.client ?? new JunoClient(target);
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(
@@ -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 { UserError } from '../errors.js';
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 [name]` — ask a Juno for a starting project and write it down.
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 starter arrives with `types/` already generated, which only
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 name = argv._[0] ?? argv.name;
19
- if (!name) throw new UserError('usage: juno init <name>');
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 ? '.' : name);
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({ url: argv.url, cwd: io.cwd });
27
- const client = argv.client ?? new JunoClient(target);
28
- // The name is validated server-side, against the same rules an install is:
29
- // one answer to "is this a valid integration name" rather than two.
30
- const files = await client.scaffold(name, { target });
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 ${name} && npm install`);
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;
@@ -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
- * two things Juno owns. It still refuses a dirty tree without --force, because
20
- * an uncommitted edit is the one thing git cannot get back for you.
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({ url: argv.url, cwd: io.cwd });
24
- const name = argv.name ?? (await readManifest(io.cwd)).name;
25
- if (!name) {
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 name — pass --name <integration>'
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 and src/ with no undo'
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 = argv.client ?? new JunoClient(target);
46
- const detail = await client.getIntegration(name);
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
- `${name} has no active version on ${target} — nothing to pull`
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 ${name} v${detail.version} from ${target} (${written.sort().join(', ')})`
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
+ }