@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/src/prompt.js ADDED
@@ -0,0 +1,196 @@
1
+ import { UnreachableError, UserError } from './errors.js';
2
+ import {
3
+ EXIT_AWAITING_INPUT,
4
+ EXIT_FAILURE,
5
+ EXIT_OK,
6
+ EXIT_PENDING,
7
+ } from './exit.js';
8
+
9
+ /**
10
+ * The Agent Prompt API client both `juno ask` and `juno session approve` use:
11
+ * the request with its retries, the 409 contract, and the outcome → exit code
12
+ * mapping. Everything here follows docs/…/reference/agent-api.md; the CLI adds
13
+ * no semantics of its own beyond which of the server's answers ends a command.
14
+ */
15
+
16
+ /** Transport retries after a failed fetch, each reusing both ids. */
17
+ const TRANSPORT_RETRIES = 2;
18
+ const TRANSPORT_BACKOFF_MS = [500, 1000];
19
+
20
+ /** Rounds of observe-then-resend a busy session gets before giving up. */
21
+ const BUSY_ROUNDS = 3;
22
+
23
+ /** The default `wait_timeout_seconds`, which stays under a harness's Bash timeout. */
24
+ export const DEFAULT_WAIT_SECONDS = 60;
25
+
26
+ /**
27
+ * converse sends `body` (which always names its `session_id`: the CLI mints
28
+ * one for a new conversation) and returns the turn's final event as
29
+ * `{event, text}`, `text` being the bytes it arrived as.
30
+ *
31
+ * The 409 contract, told apart by the body as the API reference documents
32
+ * it:
33
+ * - "session is busy" / "is not idle" / "is not awaiting input": observe
34
+ * until the turn ends, then send again with the same message_id, at most
35
+ * BUSY_ROUNDS times. With `resendWhenBusy: false` (session approve, which
36
+ * must not approve something other than what it checked) it is exit 1.
37
+ * An observe that ends awaiting input is returned instead of resent: the
38
+ * message was not meant as an answer to a question asked after it was
39
+ * typed, and to an approval any answer but `approve` is a denial.
40
+ * - "session was just created": retry at once, once.
41
+ * - "session has ended", and anything else: exit 1 with the server's words.
42
+ */
43
+ export async function converse(
44
+ client,
45
+ body,
46
+ { stream, io, resendWhenBusy = true }
47
+ ) {
48
+ let retriedJustCreated = false;
49
+ let rounds = 0;
50
+ for (;;) {
51
+ const answer = await exchange(client, body, { stream, io });
52
+ if (!answer.conflict) return answer;
53
+
54
+ const kind = conflictKind(answer.conflict);
55
+ if (kind === 'just-created' && !retriedJustCreated) {
56
+ retriedJustCreated = true;
57
+ continue;
58
+ }
59
+ if (kind !== 'busy' || !resendWhenBusy || rounds >= BUSY_ROUNDS) {
60
+ throw new UserError(answer.conflict);
61
+ }
62
+
63
+ rounds += 1;
64
+ io.warn(`${answer.conflict}`);
65
+ io.warn(`waiting for session ${body.session_id}'s current turn to end…`);
66
+ const observed = await exchange(client, observeBody(body), { stream, io });
67
+ if (observed.conflict) throw new UserError(observed.conflict);
68
+ if (observed.event.type === 'awaiting_input') {
69
+ io.warn(
70
+ 'your message was not sent: the session is now awaiting input, ' +
71
+ 'and it would have been taken as the answer'
72
+ );
73
+ return observed;
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * exchange is one request, retried at most TRANSPORT_RETRIES times when
80
+ * there was no answer at all. A retry is safe because it carries the same
81
+ * session_id and message_id: the server either creates the session under
82
+ * that id or, finding the message already delivered, observes its turn.
83
+ */
84
+ async function exchange(client, body, { stream, io }) {
85
+ for (let attempt = 0; ; attempt += 1) {
86
+ try {
87
+ return await once(client, body, { stream, io });
88
+ } catch (err) {
89
+ if (!(err instanceof UnreachableError) || attempt >= TRANSPORT_RETRIES) {
90
+ throw err;
91
+ }
92
+ io.warn(`${err.message}; retrying`);
93
+ await sleep(TRANSPORT_BACKOFF_MS[attempt]);
94
+ }
95
+ }
96
+ }
97
+
98
+ async function once(client, body, { stream, io }) {
99
+ const answer = await client.prompt(body, { stream });
100
+ if (!answer.events) return answer;
101
+
102
+ // Streaming: progress to stderr, one line an event, until the final one.
103
+ for await (const line of answer.events) {
104
+ const { event } = line;
105
+ if (FINAL.has(event.type)) return line;
106
+ const note = progress(event);
107
+ if (note) io.warn(note);
108
+ }
109
+ throw new UnreachableError('the prompt stream ended before the turn did');
110
+ }
111
+
112
+ /** The event types that end a turn, and with it the stream. */
113
+ const FINAL = new Set([
114
+ 'message',
115
+ 'awaiting_input',
116
+ 'done',
117
+ 'error',
118
+ 'pending',
119
+ ]);
120
+
121
+ function progress(event) {
122
+ switch (event.type) {
123
+ case 'session':
124
+ return `session ${event.session_id}`;
125
+ case 'turn':
126
+ return '· turn finished';
127
+ case 'child':
128
+ return `· delegated to authoring session ${event.child_session_id}`;
129
+ default:
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** Observe: the same session and wait, with no message and so no message_id. */
135
+ function observeBody(body) {
136
+ const { message: _m, message_id: _id, ...rest } = body;
137
+ return rest;
138
+ }
139
+
140
+ function conflictKind(text) {
141
+ if (/^session is busy\b/.test(text)) return 'busy';
142
+ if (/\bis not (idle|awaiting input)\b/.test(text)) return 'busy';
143
+ if (/^session was just created\b/.test(text)) return 'just-created';
144
+ if (/^session has ended\b/.test(text)) return 'ended';
145
+ return 'other';
146
+ }
147
+
148
+ /**
149
+ * outcomeExit maps a turn's final event to the exit code: 0 a reply or a
150
+ * completed session, 5 still running, 6 awaiting input, 1 anything that
151
+ * failed.
152
+ */
153
+ export function outcomeExit(event) {
154
+ switch (event.type) {
155
+ case 'message':
156
+ return EXIT_OK;
157
+ case 'done':
158
+ return event.status === 'completed' ? EXIT_OK : EXIT_FAILURE;
159
+ case 'pending':
160
+ return EXIT_PENDING;
161
+ case 'awaiting_input':
162
+ return EXIT_AWAITING_INPUT;
163
+ default:
164
+ return EXIT_FAILURE;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * finish prints the final event on stdout — unchanged off a TTY, indented on
170
+ * one — says on stderr how to go on when the turn has not ended the
171
+ * conversation's business, and returns the exit code.
172
+ */
173
+ export function finish({ event, text }, sessionID, io) {
174
+ io.log(io.isTTY ? JSON.stringify(event, null, 2) : text.trimEnd());
175
+ const next = nextStep(event, sessionID);
176
+ if (next) io.warn(next);
177
+ return outcomeExit(event);
178
+ }
179
+
180
+ function nextStep(event, id) {
181
+ if (event.type === 'pending') {
182
+ return `still running; keep waiting with: juno ask --session ${id}`;
183
+ }
184
+ if (event.type !== 'awaiting_input') return null;
185
+ if (event.pending_input?.kind === 'approval') {
186
+ return (
187
+ `awaiting approval; approve with: juno session approve ${id}\n` +
188
+ `or deny, with a reason the assistant sees: juno ask --session ${id} "<reason>"`
189
+ );
190
+ }
191
+ return `awaiting an answer; reply with: juno ask --session ${id} "<answer>"`;
192
+ }
193
+
194
+ function sleep(ms) {
195
+ return new Promise((resolve) => setTimeout(resolve, ms));
196
+ }
package/src/stamp.js CHANGED
@@ -7,16 +7,17 @@ import { createHash } from 'node:crypto';
7
7
  * and TestManifestStampParity pin the same input to the same output from each
8
8
  * side, which is the only thing that keeps them together.
9
9
  *
10
- * Why it exists: "edit manifest.json, forget `juno types`" is the one mistake
11
- * this workflow reliably produces, and nothing else catches it. tsc compiles
12
- * happily against stale types, eslint is happy, and h.conformance() checks the
13
- * manifest against the *module*, never against types/. The drift surfaces at
14
- * deploy, or later still, as a flow author calling an action with a shape that
15
- * no longer exists.
10
+ * Why it exists: "edit manifest.json, forget `juno integration gen-types`" is
11
+ * the one mistake this workflow reliably produces, and nothing else catches
12
+ * it. tsc compiles happily against stale types, eslint is happy, and
13
+ * h.conformance() checks the manifest against the *module*, never against
14
+ * types/. The drift surfaces at deploy, or later still, as a flow author
15
+ * calling an action with a shape that no longer exists.
16
16
  *
17
17
  * Why a hash rather than asking a Juno: the check has to run where the mistake
18
18
  * is caught — a contributor's CI, which deliberately has no instance to ask.
19
- * `juno types` needs a server; verifying what it wrote must not.
19
+ * `juno integration gen-types` needs a server; verifying what it wrote must
20
+ * not.
20
21
  */
21
22
  const STAMP_PREFIX = '// juno:manifest-sha256 ';
22
23
  const STAMP_LENGTH = 16;
@@ -68,8 +69,8 @@ export function stampEnvDTS(envDTS, manifestJSON) {
68
69
  * Applied client-side rather than server-side because it fingerprints the bytes
69
70
  * on *this* disk: both endpoints are handed a parsed manifest and never see the
70
71
  * file, and the same typegen endpoint also answers the authoring agent, which
71
- * has no file at all. The project generator behind `juno init` does see one, so
72
- * it stamps there — internal/typegen/stamp.go.
72
+ * has no file at all. The project generator behind `juno integration init`
73
+ * does see one, so it stamps there — internal/typegen/stamp.go.
73
74
  */
74
75
  export function stampTypeFiles(types, rawManifest) {
75
76
  const name = ENV_DTS.replace('types/', '');
package/src/suggest.js ADDED
@@ -0,0 +1,67 @@
1
+ /** Suggestions further than this are more likely noise than a typo. */
2
+ const MAX_DISTANCE = 3;
3
+
4
+ /**
5
+ * closest is the candidate nearest to any of `attempts` by edit distance, or
6
+ * null when none is within MAX_DISTANCE. Ties go to the earlier candidate, so
7
+ * the order of verbs.json decides.
8
+ */
9
+ export function closest(attempts, candidates) {
10
+ let best = null;
11
+ let bestDistance = MAX_DISTANCE + 1;
12
+ for (const candidate of candidates) {
13
+ for (const attempt of attempts) {
14
+ const d = levenshtein(attempt, candidate);
15
+ if (d < bestDistance) {
16
+ best = candidate;
17
+ bestDistance = d;
18
+ }
19
+ }
20
+ }
21
+ return best;
22
+ }
23
+
24
+ /** levenshtein is the plain edit distance: insert, delete, substitute. */
25
+ export function levenshtein(a, b) {
26
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
27
+ for (let i = 1; i <= a.length; i++) {
28
+ const cur = [i];
29
+ for (let j = 1; j <= b.length; j++) {
30
+ cur[j] = Math.min(
31
+ prev[j] + 1,
32
+ cur[j - 1] + 1,
33
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
34
+ );
35
+ }
36
+ prev = cur;
37
+ }
38
+ return prev[b.length];
39
+ }
40
+
41
+ /**
42
+ * closestByVerb is for a bare word that is not a command: the grouped
43
+ * commands whose verb is nearest to it, all of them when several groups share
44
+ * that verb, with the distance. A bare `deploy` is the verb of both
45
+ * `flow deploy` and `integration deploy`, and only the person knows which
46
+ * working copy they are in, so both are offered rather than whichever
47
+ * verbs.json lists first. A verb one of whose `-` words is the attempt counts
48
+ * as distance 1, so a bare `types` still finds `gen-types`.
49
+ */
50
+ export function closestByVerb(attempt, commands) {
51
+ let best = [];
52
+ let bestDistance = MAX_DISTANCE + 1;
53
+ for (const command of commands) {
54
+ const space = command.indexOf(' ');
55
+ if (space < 0) continue;
56
+ const verb = command.slice(space + 1);
57
+ let d = levenshtein(attempt, verb);
58
+ if (d > 1 && verb.split('-').includes(attempt)) d = 1;
59
+ if (d < bestDistance) {
60
+ best = [command];
61
+ bestDistance = d;
62
+ } else if (d === bestDistance) {
63
+ best.push(command);
64
+ }
65
+ }
66
+ return { commands: best, distance: bestDistance };
67
+ }
package/src/target.js CHANGED
@@ -1,24 +1,34 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
 
4
+ import { readUserConfig } from './config.js';
4
5
  import { UserError } from './errors.js';
5
6
 
6
7
  /**
7
8
  * Resolve which Juno instance to talk to, in precedence order: an explicit
8
9
  * --url, then $JUNO_URL, then `juno.target` in package.json — which is what an
9
- * ejected project arrives with, so a fresh clone deploys with no setup.
10
+ * ejected project arrives with, so a fresh clone deploys with no setup — then
11
+ * the user config file `juno config set url` writes, for use outside a
12
+ * project.
10
13
  *
11
- * A missing target names all three: "no target configured" leaves the reader
12
- * guessing which of the three they were supposed to have used.
14
+ * A missing target names all four: "no target configured" leaves the reader
15
+ * guessing which of them they were supposed to have used.
13
16
  */
14
- export async function resolveTarget({ url, cwd = process.cwd() } = {}) {
17
+ export async function resolveTarget({
18
+ url,
19
+ cwd = process.cwd(),
20
+ env = process.env,
21
+ } = {}) {
15
22
  const candidate =
16
- url ?? process.env.JUNO_URL ?? (await targetFromPackageJSON(cwd));
23
+ url ??
24
+ env.JUNO_URL ??
25
+ (await targetFromPackageJSON(cwd)) ??
26
+ (await targetFromUserConfig(env));
17
27
  if (!candidate) {
18
28
  throw new UserError(
19
- 'no Juno instance to deploy to. Pass --url <url>, set $JUNO_URL, or add\n' +
29
+ 'no Juno instance configured. Pass --url <url>, set $JUNO_URL, add\n' +
20
30
  ' "juno": { "target": "http://juno.local:8080" }\n' +
21
- 'to package.json.'
31
+ 'to package.json, or run `juno config set url <url>`.'
22
32
  );
23
33
  }
24
34
  return normalizeTarget(candidate);
@@ -33,6 +43,11 @@ async function targetFromPackageJSON(cwd) {
33
43
  }
34
44
  }
35
45
 
46
+ async function targetFromUserConfig(env) {
47
+ const { url } = await readUserConfig(env);
48
+ return typeof url === 'string' && url.trim() ? url : null;
49
+ }
50
+
36
51
  /** Accepts a bare host the way a browser address bar does. */
37
52
  export function normalizeTarget(target) {
38
53
  let t = target.trim().replace(/\/+$/, '');
package/src/verbs.js ADDED
@@ -0,0 +1,34 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ // createRequire rather than an import attribute: `with { type: 'json' }` needs
4
+ // Node 20.10+, and engines says >=20.
5
+ const { commands: ROWS } = createRequire(import.meta.url)('../verbs.json');
6
+
7
+ /**
8
+ * verbs.json is the command table: every `juno <group> <verb>` the CLI
9
+ * answers, and the one place a command name is decided. The Go tests in
10
+ * internal/agent/verbs_test.go hold it to the tool gateway; this module only
11
+ * indexes it.
12
+ */
13
+ export const rows = ROWS;
14
+
15
+ const byCommand = new Map(ROWS.map((row) => [row.command, row]));
16
+
17
+ /** The row for a command string ("state get"), or undefined. */
18
+ export function rowFor(command) {
19
+ return byCommand.get(command);
20
+ }
21
+
22
+ /**
23
+ * A verb spelled with `_` is an alias for the `-` spelling, so an agent that
24
+ * types the tool's own word order (`juno state get_history`) still lands.
25
+ */
26
+ export function normalizeVerb(verb) {
27
+ return verb.replaceAll('_', '-');
28
+ }
29
+
30
+ /** The group of a two-word command, or null for a top-level one. */
31
+ export function groupOf(row) {
32
+ const space = row.command.indexOf(' ');
33
+ return space < 0 ? null : row.command.slice(0, space);
34
+ }
package/src/watch.js ADDED
@@ -0,0 +1,50 @@
1
+ import { watch } from 'node:fs';
2
+
3
+ import { DiagnosticsError } from './errors.js';
4
+ import { formatDiagnostics } from './diagnostics.js';
5
+
6
+ /** How long to wait for edits to settle before redeploying. */
7
+ export const WATCH_DEBOUNCE_MS = 200;
8
+
9
+ /**
10
+ * deployOnChange is `deploy --watch` for both kinds of working copy: run
11
+ * `once`, then again whenever one of `paths` changes (`{path, recursive?}`),
12
+ * debounced so an editor's save-and-rename is one deploy. A failed deploy is
13
+ * printed and the loop keeps watching — the next save is the retry. It
14
+ * resolves only on SIGINT: watch mode ends when the author ends it.
15
+ */
16
+ export async function deployOnChange({ io, target, paths, once }) {
17
+ io.log(`watching ${io.cwd} — deploying to ${target}`);
18
+ const iteration = async () => {
19
+ try {
20
+ await once();
21
+ } catch (err) {
22
+ if (err instanceof DiagnosticsError) {
23
+ io.error(formatDiagnostics(err.diagnostics));
24
+ } else {
25
+ io.error(err.message);
26
+ }
27
+ }
28
+ };
29
+ await iteration();
30
+
31
+ let timer = null;
32
+ const schedule = () => {
33
+ if (timer) clearTimeout(timer);
34
+ timer = setTimeout(() => {
35
+ timer = null;
36
+ void iteration();
37
+ }, WATCH_DEBOUNCE_MS);
38
+ };
39
+
40
+ const watchers = paths.map(({ path, recursive = false }) =>
41
+ watch(path, { recursive }, schedule)
42
+ );
43
+ await new Promise((resolve) => {
44
+ process.once('SIGINT', () => {
45
+ for (const w of watchers) w.close();
46
+ resolve();
47
+ });
48
+ });
49
+ return 0;
50
+ }
package/verbs.json ADDED
@@ -0,0 +1,227 @@
1
+ {
2
+ "commands": [
3
+ {
4
+ "command": "discover",
5
+ "tool": "discover",
6
+ "id_field": "query",
7
+ "flags": {
8
+ "kind": { "field": "kinds", "type": "string", "multiple": true }
9
+ },
10
+ "naming_exception": "searches every kind, so it has no group"
11
+ },
12
+ {
13
+ "command": "related",
14
+ "tool": "get_related",
15
+ "positional": "kind:id",
16
+ "flags": { "degree": { "field": "degree", "type": "integer" } },
17
+ "naming_exception": "takes any kind, so it has no group"
18
+ },
19
+ { "command": "state get", "tool": "get_state", "id_field": "id" },
20
+ {
21
+ "command": "state get-history",
22
+ "tool": "get_state_history",
23
+ "id_field": "id"
24
+ },
25
+ {
26
+ "command": "state get-layers",
27
+ "tool": "get_state_layers",
28
+ "id_field": "id"
29
+ },
30
+ { "command": "state evaluate-level", "tool": "evaluate_level" },
31
+ {
32
+ "command": "state preview-layers",
33
+ "tool": "edit_state_layers",
34
+ "id_field": "id",
35
+ "fixed_input": { "dry_run": true },
36
+ "effect": "read",
37
+ "naming_exception": "fixes dry_run: true on edit_state_layers, so a permission rule can allow the preview and not the edit"
38
+ },
39
+ { "command": "state create", "tool": "create_state", "id_field": "id" },
40
+ {
41
+ "command": "state edit-layers",
42
+ "tool": "edit_state_layers",
43
+ "id_field": "id",
44
+ "fixed_input": { "dry_run": false }
45
+ },
46
+ {
47
+ "command": "state set-value",
48
+ "tool": "set_state_value",
49
+ "id_field": "id"
50
+ },
51
+ { "command": "state claim", "tool": "claim_state", "id_field": "id" },
52
+ { "command": "state release", "tool": "release_state", "id_field": "id" },
53
+ { "command": "flow get", "tool": "get_flow", "id_field": "id" },
54
+ {
55
+ "command": "flow set-enabled",
56
+ "tool": "set_flow_enabled",
57
+ "id_field": "flow_id"
58
+ },
59
+ {
60
+ "command": "flow trigger",
61
+ "tool": "trigger_flow",
62
+ "id_field": "flow_id"
63
+ },
64
+ {
65
+ "command": "flow fire-trigger",
66
+ "tool": "fire_trigger",
67
+ "id_field": "flow_id"
68
+ },
69
+ {
70
+ "command": "flow init",
71
+ "effect": "read",
72
+ "working_copy": true,
73
+ "summary": "Write a working copy of a flow (or a stub for a new id) into ./<id>"
74
+ },
75
+ {
76
+ "command": "flow gen-types",
77
+ "effect": "read",
78
+ "working_copy": true,
79
+ "summary": "Regenerate flow-env.d.ts and flow-api.d.ts from manifest.json"
80
+ },
81
+ {
82
+ "command": "flow check",
83
+ "effect": "read",
84
+ "working_copy": true,
85
+ "summary": "Typecheck and lint locally, then verify against Juno without deploying"
86
+ },
87
+ {
88
+ "command": "flow deploy",
89
+ "effect": "change",
90
+ "working_copy": true,
91
+ "summary": "Verify, commit and activate the working copy as the flow's next version"
92
+ },
93
+ {
94
+ "command": "flow pull",
95
+ "effect": "read",
96
+ "working_copy": true,
97
+ "summary": "Overwrite the working copy with the flow's active version"
98
+ },
99
+ { "command": "run list", "tool": "list_runs" },
100
+ { "command": "run get", "tool": "get_run", "id_field": "run_id" },
101
+ {
102
+ "command": "integration get",
103
+ "tool": "get_integration",
104
+ "id_field": "id"
105
+ },
106
+ {
107
+ "command": "integration configure",
108
+ "tool": "configure_integration",
109
+ "id_field": "id"
110
+ },
111
+ { "command": "integration install", "tool": "install_integration" },
112
+ {
113
+ "command": "integration run-schedule",
114
+ "tool": "run_integration_schedule",
115
+ "id_field": "integration"
116
+ },
117
+ {
118
+ "command": "integration init",
119
+ "effect": "read",
120
+ "working_copy": true,
121
+ "summary": "Write a working copy of an install (or a starter for a new id) into ./<id>"
122
+ },
123
+ {
124
+ "command": "integration gen-types",
125
+ "effect": "read",
126
+ "working_copy": true,
127
+ "summary": "Regenerate types/ from manifest.json"
128
+ },
129
+ {
130
+ "command": "integration verify-types",
131
+ "effect": "read",
132
+ "working_copy": true,
133
+ "summary": "Check offline that types/ matches manifest.json"
134
+ },
135
+ {
136
+ "command": "integration check",
137
+ "effect": "read",
138
+ "working_copy": true,
139
+ "summary": "Typecheck and lint locally, then verify against Juno without deploying"
140
+ },
141
+ {
142
+ "command": "integration deploy",
143
+ "effect": "change",
144
+ "working_copy": true,
145
+ "summary": "Verify, commit and activate the working copy as the install's next version"
146
+ },
147
+ {
148
+ "command": "integration pull",
149
+ "effect": "read",
150
+ "working_copy": true,
151
+ "summary": "Overwrite manifest.json, src/ and package.json scripts with the active version"
152
+ },
153
+ { "command": "device list", "tool": "list_devices" },
154
+ { "command": "device get", "tool": "get_device", "id_field": "id" },
155
+ { "command": "device preview", "tool": "preview_device" },
156
+ { "command": "device write", "tool": "write_device" },
157
+ { "command": "device suspend", "tool": "suspend_device", "id_field": "id" },
158
+ { "command": "device release", "tool": "release_device", "id_field": "id" },
159
+ { "command": "control write", "tool": "write_control" },
160
+ { "command": "control use", "tool": "use_control", "id_field": "id" },
161
+ {
162
+ "command": "control release",
163
+ "tool": "release_control",
164
+ "id_field": "id"
165
+ },
166
+ { "command": "finding get", "tool": "get_finding", "id_field": "slug" },
167
+ { "command": "webhook list", "tool": "list_webhooks" },
168
+ {
169
+ "command": "webhook get-deliveries",
170
+ "tool": "get_webhook_deliveries",
171
+ "id_field": "id"
172
+ },
173
+ { "command": "webhook write", "tool": "write_webhook", "id_field": "id" },
174
+ { "command": "webhook fire", "tool": "fire_webhook", "id_field": "id" },
175
+ { "command": "credential list", "tool": "list_credentials" },
176
+ {
177
+ "command": "ha get-entity",
178
+ "tool": "get_entity",
179
+ "id_field": "entity_id"
180
+ },
181
+ {
182
+ "command": "ha get-entity-history",
183
+ "tool": "get_entity_history",
184
+ "id_field": "entity_id"
185
+ },
186
+ { "command": "ha list-services", "tool": "list_services" },
187
+ { "command": "ha call-service", "tool": "call_service" },
188
+ {
189
+ "command": "script run",
190
+ "tool": "run_script",
191
+ "positional": "file",
192
+ "flags": {
193
+ "cred": { "field": "credentials", "type": "string", "multiple": true }
194
+ }
195
+ },
196
+ {
197
+ "command": "skill",
198
+ "effect": "read",
199
+ "summary": "Print the skills Juno's assistant can load, or one skill's body"
200
+ },
201
+ {
202
+ "command": "ask",
203
+ "effect": "act",
204
+ "summary": "Prompt Juno's in-app assistant"
205
+ },
206
+ {
207
+ "command": "session approve",
208
+ "effect": "act",
209
+ "summary": "Approve the tool call an assistant session is waiting on"
210
+ },
211
+ {
212
+ "command": "credential set",
213
+ "effect": "act",
214
+ "summary": "Set a credential's secret, typed at a terminal with echo off"
215
+ },
216
+ {
217
+ "command": "config set",
218
+ "effect": "local",
219
+ "summary": "Remember the instance URL in the user config file"
220
+ },
221
+ {
222
+ "command": "agent-skill install",
223
+ "effect": "local",
224
+ "summary": "Write Juno's skill for a coding agent and print its permission block"
225
+ }
226
+ ]
227
+ }