@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,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
+ }
@@ -5,8 +5,10 @@ import { UserError } from '../errors.js';
5
5
  import { readManifestRaw } from '../project.js';
6
6
  import { ENV_DTS, manifestStamp, readStamp } from '../stamp.js';
7
7
 
8
+ export const VERIFY_TYPES_USAGE = 'juno integration verify-types';
9
+
8
10
  /**
9
- * `juno verify-types` — does types/ still describe manifest.json?
11
+ * `juno integration verify-types` — does types/ still describe manifest.json?
10
12
  *
11
13
  * The one check in this CLI that talks to nothing. It is the counterweight to
12
14
  * committing types/: that decision is what lets a drive-by contributor work
@@ -16,7 +18,7 @@ import { ENV_DTS, manifestStamp, readStamp } from '../stamp.js';
16
18
  * rather than noticing they are stale, and h.conformance() compares the
17
19
  * manifest with the module, never with types/.
18
20
  *
19
- * So this runs first in `npm test` and inside `juno check`. A failure here is
21
+ * So this runs first in `npm test` and inside `juno integration check`. A failure here is
20
22
  * always the same one sentence of advice, which is why the message says it
21
23
  * rather than describing the mismatch.
22
24
  */
package/src/config.js ADDED
@@ -0,0 +1,34 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ /**
6
+ * The user config file: `$XDG_CONFIG_HOME/juno/config.json`, or
7
+ * `~/.config/juno/config.json` when XDG_CONFIG_HOME is unset. It holds
8
+ * `{"url": "…"}`, the last place resolveTarget looks, and is what makes `juno`
9
+ * usable outside a project after a global install.
10
+ */
11
+ export function configPath(env = process.env) {
12
+ const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config');
13
+ return join(base, 'juno', 'config.json');
14
+ }
15
+
16
+ /** The parsed config; a missing or unreadable file is `{}`. */
17
+ export async function readUserConfig(env = process.env) {
18
+ try {
19
+ const parsed = JSON.parse(await readFile(configPath(env), 'utf8'));
20
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
21
+ ? parsed
22
+ : {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ /** Writes the whole config, creating its directory. */
29
+ export async function writeUserConfig(env = process.env, cfg) {
30
+ const path = configPath(env);
31
+ await mkdir(dirname(path), { recursive: true });
32
+ await writeFile(path, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
33
+ return path;
34
+ }
@@ -14,3 +14,17 @@ export function formatDiagnostic(d) {
14
14
  export function formatDiagnostics(diagnostics) {
15
15
  return diagnostics.map(formatDiagnostic).join('\n');
16
16
  }
17
+
18
+ /**
19
+ * --json on check and deploy (both kinds): the route's body on stdout,
20
+ * indented only on a TTY, for CI.
21
+ */
22
+ export function printJSON(body, io) {
23
+ io.log(io.isTTY ? JSON.stringify(body, null, 2) : JSON.stringify(body));
24
+ }
25
+
26
+ /** --json on a failed verify: the 422's detail and diagnostics, exit 1. */
27
+ export function printDiagnosticsJSON(err, io) {
28
+ printJSON({ detail: err.message, diagnostics: err.diagnostics }, io);
29
+ return 1;
30
+ }
package/src/errors.js CHANGED
@@ -24,3 +24,43 @@ export class DiagnosticsError extends Error {
24
24
  this.diagnostics = diagnostics ?? [];
25
25
  }
26
26
  }
27
+
28
+ /**
29
+ * UsageError is a command line the CLI cannot act on: an unknown command or
30
+ * flag, a missing argument, a malformed --input. The top-level handler prints
31
+ * its message and exits 2. The fix is to change the command line — the message
32
+ * should say how, rather than restating the whole help.
33
+ */
34
+ export class UsageError extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = 'UsageError';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * UnreachableError means the request never got an answer: the fetch itself
43
+ * failed (connection refused, DNS, TLS). The top-level handler exits 3. The
44
+ * fix is on the network side — is the instance running, and is --url (or
45
+ * $JUNO_URL, package.json, the user config) pointing at it?
46
+ */
47
+ export class UnreachableError extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = 'UnreachableError';
51
+ }
52
+ }
53
+
54
+ /**
55
+ * ConflictError is a deploy the server refused because the working copy is
56
+ * stale: the flow's active version is not the one `juno.base_version` says it
57
+ * was pulled from (a 409). The top-level handler prints the server's message,
58
+ * which names both versions, and exits 4. The fix is `npm run pull`, or
59
+ * deploying with --force to replace whatever is active.
60
+ */
61
+ export class ConflictError extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = 'ConflictError';
65
+ }
66
+ }
package/src/exit.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Exit codes, one table for the whole CLI (plan-external-agents-cli.md,
3
+ * Decision 11):
4
+ *
5
+ * 0 success
6
+ * 1 a tool result with a top-level string `error`; any other failure
7
+ * (a UserError, diagnostics, a 5xx)
8
+ * 2 usage: unknown command or flag, bad --input, a gateway 422 or 404,
9
+ * a bad --jq
10
+ * 3 the instance is unreachable (the fetch itself failed)
11
+ * 4 a deploy conflict: `juno flow deploy` / `check` against a flow whose
12
+ * active version is not the working copy's base_version (a 409)
13
+ * 5 `juno ask` / `session approve`: the turn is still running (pending)
14
+ * 6 `juno ask` / `session approve`: the assistant is awaiting input
15
+ */
16
+ export const EXIT_OK = 0;
17
+ export const EXIT_FAILURE = 1;
18
+ export const EXIT_USAGE = 2;
19
+ export const EXIT_UNREACHABLE = 3;
20
+ export const EXIT_CONFLICT = 4;
21
+ export const EXIT_PENDING = 5;
22
+ export const EXIT_AWAITING_INPUT = 6;
@@ -0,0 +1,128 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { UserError } from './errors.js';
5
+
6
+ /**
7
+ * A flow working copy is flat (plan-external-agents-flows-as-files.md,
8
+ * Decision 9): manifest.json and flow.ts are the author's, the two .d.ts
9
+ * files are generated, and package.json's `juno` block binds the directory to
10
+ * one flow. The layout is the server's (POST /api/flows/scaffold); this
11
+ * module only knows the handful of names a command reads or writes back.
12
+ */
13
+ export const MANIFEST = 'manifest.json';
14
+ export const FLOW_TS = 'flow.ts';
15
+ export const PACKAGE_JSON = 'package.json';
16
+
17
+ /**
18
+ * readFlowProject reads what ingest takes: the manifest (parsed, so a broken
19
+ * one fails here with its parse error rather than as a server 422), flow.ts,
20
+ * and the binding in package.json. `flowId` is `juno.flow_id` — the id is
21
+ * identity, so the CLI sends it and the server refuses a manifest naming
22
+ * another — and `baseVersion` is `juno.base_version`, absent for a flow that
23
+ * has never been deployed from this copy.
24
+ */
25
+ export async function readFlowProject(cwd) {
26
+ const pkg = await readFlowPackage(cwd);
27
+ return {
28
+ ...(await readFlowManifest(cwd)),
29
+ flowTS: await readText(cwd, FLOW_TS),
30
+ packageJSON: pkg,
31
+ flowId: pkg.juno.flow_id,
32
+ baseVersion: Number.isInteger(pkg.juno.base_version)
33
+ ? pkg.juno.base_version
34
+ : undefined,
35
+ };
36
+ }
37
+
38
+ /** readFlowManifest is manifest.json, as bytes and parsed. */
39
+ export async function readFlowManifest(cwd) {
40
+ const manifestRaw = await readText(cwd, MANIFEST);
41
+ try {
42
+ return { manifest: JSON.parse(manifestRaw), manifestRaw };
43
+ } catch (err) {
44
+ throw new UserError(`${MANIFEST} is not valid JSON: ${err.message}`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * readFlowPackage is package.json, refusing a directory that is not a flow
50
+ * working copy — every command but `init` acts on the current directory, and
51
+ * running one from the wrong place should say so rather than fail on a
52
+ * missing flow.ts.
53
+ */
54
+ export async function readFlowPackage(cwd) {
55
+ let pkg = null;
56
+ try {
57
+ pkg = JSON.parse(await readFile(join(cwd, PACKAGE_JSON), 'utf8'));
58
+ } catch {
59
+ // Missing or unreadable: reported below as not a working copy.
60
+ }
61
+ if (typeof pkg?.juno?.flow_id !== 'string' || pkg.juno.flow_id === '') {
62
+ throw new UserError(
63
+ `${cwd} is not a flow working copy (no juno.flow_id in package.json) — ` +
64
+ 'run `juno flow init <id>` to make one'
65
+ );
66
+ }
67
+ return pkg;
68
+ }
69
+
70
+ /**
71
+ * updatePackageJSON rewrites package.json through `mutate`, keeping every key
72
+ * it does not touch (and their order), with a two-space indent and a trailing
73
+ * newline — the way npm writes it, so the diff is only what changed.
74
+ */
75
+ export async function updatePackageJSON(cwd, mutate) {
76
+ const pkg = await readFlowPackage(cwd);
77
+ mutate(pkg);
78
+ await writeFile(
79
+ join(cwd, PACKAGE_JSON),
80
+ `${JSON.stringify(pkg, null, 2)}\n`,
81
+ 'utf8'
82
+ );
83
+ }
84
+
85
+ /**
86
+ * writeFlowFiles writes files by working-copy name, skipping one whose
87
+ * content is already what is on disk so a watcher does not see a write that
88
+ * changed nothing. Returns the names it wrote.
89
+ */
90
+ export async function writeFlowFiles(cwd, files) {
91
+ const written = [];
92
+ for (const [name, content] of Object.entries(files)) {
93
+ const path = join(cwd, name);
94
+ let current = null;
95
+ try {
96
+ current = await readFile(path, 'utf8');
97
+ } catch {
98
+ // Not there yet.
99
+ }
100
+ if (current === content) continue;
101
+ await writeFile(path, content, 'utf8');
102
+ written.push(name);
103
+ }
104
+ return written.sort();
105
+ }
106
+
107
+ /** The canonical manifest a route answered, as manifest.json holds it. */
108
+ export function manifestText(manifest) {
109
+ return `${JSON.stringify(manifest, null, 2)}\n`;
110
+ }
111
+
112
+ /**
113
+ * The .d.ts files of a route's `types`. Filtering by extension rather than
114
+ * naming the two keeps a future third file working without a CLI release.
115
+ */
116
+ export function typeFiles(types) {
117
+ return Object.fromEntries(
118
+ Object.entries(types ?? {}).filter(([name]) => name.endsWith('.d.ts'))
119
+ );
120
+ }
121
+
122
+ async function readText(cwd, name) {
123
+ try {
124
+ return await readFile(join(cwd, name), 'utf8');
125
+ } catch {
126
+ throw new UserError(`no ${name} in ${cwd}`);
127
+ }
128
+ }