@junoflow/cli 0.3.0 → 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/bin/juno.js CHANGED
@@ -1,4 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from '../src/index.js';
3
3
 
4
+ // A reader that stops early (`juno discover | head`) closes the pipe under the
5
+ // next write. That is the reader's choice, not a failure worth a stack trace.
6
+ process.stdout.on('error', (err) => {
7
+ if (err.code !== 'EPIPE') throw err;
8
+ process.exit(process.exitCode ?? 0);
9
+ });
10
+
4
11
  process.exitCode = await run(process.argv.slice(2));
@@ -0,0 +1,33 @@
1
+ import tsParser from '@typescript-eslint/parser';
2
+ import juno from '@junoflow/eslint-plugin';
3
+
4
+ /**
5
+ * The lint preset for a flow working copy (`juno flow init`).
6
+ *
7
+ * The rules come from the plugin's `rulesets.json`, `configs.flow` — the set
8
+ * Juno's verify step lints a flow's `flow.ts` with — so the author runs the
9
+ * rules the server runs, by construction rather than by two lists.
10
+ *
11
+ * One rule reports nothing here: `juno/prefer-claim-over-set` needs to know
12
+ * which of the flow's state bindings have claim slots, which is a fact about
13
+ * the instance's registry that a local config cannot know. Verify is told, so
14
+ * `npm run check` and `npm run deploy` report it.
15
+ *
16
+ * The generated `.d.ts` files are not linted: verify lints the author's source
17
+ * and nothing else, and the flow rules are about handlers they do not contain.
18
+ */
19
+ export default [
20
+ {
21
+ ignores: ['node_modules/**', 'dist/**', '**/*.d.ts'],
22
+ },
23
+ {
24
+ files: ['**/*.ts'],
25
+ languageOptions: {
26
+ parser: tsParser,
27
+ ecmaVersion: 2022,
28
+ sourceType: 'module',
29
+ },
30
+ plugins: { juno },
31
+ rules: { ...juno.configs.flow },
32
+ },
33
+ ];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@junoflow/cli",
3
- "version": "0.3.0",
4
- "description": "Command line tools for developing Juno integrations",
3
+ "version": "0.6.0",
4
+ "description": "Develop Juno integrations and operate a Juno instance from the command line",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -16,13 +16,16 @@
16
16
  ".": "./src/index.js",
17
17
  "./package.json": "./package.json",
18
18
  "./tsconfig.base.json": "./tsconfig.base.json",
19
- "./eslint.config.js": "./eslint.config.js"
19
+ "./eslint.config.js": "./eslint.config.js",
20
+ "./eslint.flow.config.js": "./eslint.flow.config.js"
20
21
  },
21
22
  "files": [
22
23
  "bin",
23
24
  "src",
24
25
  "tsconfig.base.json",
25
26
  "eslint.config.js",
27
+ "eslint.flow.config.js",
28
+ "verbs.json",
26
29
  "README.md"
27
30
  ],
28
31
  "scripts": {
@@ -32,7 +35,7 @@
32
35
  "node": ">=20"
33
36
  },
34
37
  "dependencies": {
35
- "@junoflow/eslint-plugin": "0.3.0",
38
+ "@junoflow/eslint-plugin": "0.6.0",
36
39
  "@typescript-eslint/parser": "^8.0.0"
37
40
  },
38
41
  "devDependencies": {
package/src/api.js CHANGED
@@ -1,4 +1,37 @@
1
- import { DiagnosticsError, UserError } from './errors.js';
1
+ import {
2
+ ConflictError,
3
+ DiagnosticsError,
4
+ UnreachableError,
5
+ UsageError,
6
+ UserError,
7
+ } from './errors.js';
8
+ import { resolveTarget } from './target.js';
9
+
10
+ /**
11
+ * clientName is the X-Juno-Client value: `cli/claude-code` inside a shell
12
+ * Claude Code started (it sets CLAUDECODE=1), `cli` everywhere else. The
13
+ * gateway records a write as `operator:<client>`; it is the actor, not a
14
+ * security control.
15
+ */
16
+ export function clientName(env = process.env) {
17
+ return env.CLAUDECODE === '1' ? 'cli/claude-code' : 'cli';
18
+ }
19
+
20
+ /**
21
+ * connect resolves the instance (target.js) and builds the client every
22
+ * command uses, unless one was injected: `args.client` is how the tests hand
23
+ * a command a client instead of a URL.
24
+ */
25
+ export async function connect(args, io) {
26
+ const target = await resolveTarget({
27
+ url: args.url,
28
+ cwd: io.cwd,
29
+ env: io.env,
30
+ });
31
+ const client =
32
+ args.client ?? new JunoClient(target, { client: clientName(io.env) });
33
+ return { target, client };
34
+ }
2
35
 
3
36
  /**
4
37
  * Client for the handful of endpoints this CLI knows about. It knows nothing
@@ -8,9 +41,13 @@ import { DiagnosticsError, UserError } from './errors.js';
8
41
  * disagree.
9
42
  */
10
43
  export class JunoClient {
11
- constructor(target, { fetch: fetchImpl = globalThis.fetch } = {}) {
44
+ constructor(
45
+ target,
46
+ { fetch: fetchImpl = globalThis.fetch, client = clientName() } = {}
47
+ ) {
12
48
  this.target = target;
13
49
  this.fetch = fetchImpl;
50
+ this.client = client;
14
51
  }
15
52
 
16
53
  /**
@@ -18,9 +55,10 @@ export class JunoClient {
18
55
  * content. `target` is what this client reached the instance at; the server
19
56
  * prefers its own configured public URL and falls back to it.
20
57
  */
21
- async scaffold(name, { description, target } = {}) {
58
+ async scaffold(id, { name, description, target } = {}) {
22
59
  const body = await this.#json('POST', '/api/integrations/scaffold', {
23
- name,
60
+ id,
61
+ name: name ?? undefined,
24
62
  description: description ?? undefined,
25
63
  target: target ?? undefined,
26
64
  });
@@ -33,57 +71,387 @@ export class JunoClient {
33
71
  }
34
72
 
35
73
  /** POST /api/integrations/ingest — deploy, or validate only with dryRun. */
36
- async ingest(project, { dryRun = false, name } = {}) {
74
+ async ingest(project, { dryRun = false, id } = {}) {
37
75
  const query = dryRun ? '?dry_run=true' : '';
38
76
  return this.#json('POST', `/api/integrations/ingest${query}`, {
39
77
  manifest: project.manifest,
40
78
  files: project.files,
41
79
  package_json: project.packageJSON ?? undefined,
42
80
  lockfile: project.lockfile ?? undefined,
43
- name: name ?? undefined,
81
+ id: id ?? undefined,
44
82
  });
45
83
  }
46
84
 
47
- /** GET /api/integrations/{name} — the manifest and sources Juno holds. */
48
- async getIntegration(name) {
49
- return this.#json('GET', `/api/integrations/${encodeURIComponent(name)}`);
85
+ /** GET /api/integrations/{id} — the manifest and sources Juno holds. */
86
+ async getIntegration(id) {
87
+ return this.#json('GET', `/api/integrations/${encodeURIComponent(id)}`);
88
+ }
89
+
90
+ /**
91
+ * POST /api/flows/scaffold — a flow working copy, `{files: {path: content},
92
+ * warnings?: [string]}`: the active version's, or a stub for an id with
93
+ * none. The warnings are a stored manifest the live catalog now refuses;
94
+ * the working copy is still rendered, because it is how that gets fixed.
95
+ */
96
+ async scaffoldFlow(id, { target } = {}) {
97
+ const body = await this.#flowJSON('/api/flows/scaffold', {
98
+ id,
99
+ target: target ?? undefined,
100
+ });
101
+ return { files: body?.files ?? {}, warnings: body?.warnings ?? [] };
102
+ }
103
+
104
+ /**
105
+ * POST /api/typegen/flow — `{manifest, types: {"flow-env.d.ts",
106
+ * "flow-api.d.ts"}}` for a manifest, from the live catalog.
107
+ */
108
+ async typegenFlow(manifest) {
109
+ return this.#flowJSON('/api/typegen/flow', { manifest });
110
+ }
111
+
112
+ /**
113
+ * POST /api/flows/ingest — verify a flow working copy and, unless dryRun,
114
+ * commit and activate it. `baseVersion` undefined sends none (a new flow,
115
+ * or --force). A 409 is a ConflictError; a 422 with diagnostics is a
116
+ * DiagnosticsError.
117
+ */
118
+ async ingestFlow({ flowId, manifest, flowTS, baseVersion }, { dryRun } = {}) {
119
+ const query = dryRun ? '?dry_run=true' : '';
120
+ return this.#flowJSON(`/api/flows/ingest${query}`, {
121
+ flow_id: flowId,
122
+ manifest,
123
+ flow_ts: flowTS,
124
+ base_version: baseVersion ?? undefined,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * #flowJSON is #json for the flow working-copy routes, which answer two
130
+ * more failures in their own words: a 409 (the working copy is stale) and a
131
+ * 422 with no diagnostics (a flow id that is not kebab-case).
132
+ */
133
+ async #flowJSON(path, body) {
134
+ const res = await this.#request('POST', path, body);
135
+ if (res.status === 409) throw new ConflictError(problemDetail(res));
136
+ if (res.status === 422 && !res.parsed?.diagnostics?.length) {
137
+ throw new UserError(validationDetail(res));
138
+ }
139
+ if (res.ok) return res.parsed;
140
+ if (res.parsed?.diagnostics?.length) {
141
+ throw new DiagnosticsError(
142
+ res.parsed.detail || 'verification failed',
143
+ res.parsed.diagnostics
144
+ );
145
+ }
146
+ throw failure('POST', path, res);
147
+ }
148
+
149
+ /**
150
+ * POST /api/tools/{name} — one tool call. A 200 is the tool's answer and is
151
+ * returned whatever it says, `{"error": …}` included: a domain failure is a
152
+ * result, and the caller decides the exit code. `text` is the body exactly as
153
+ * it arrived, so it can be printed unchanged.
154
+ */
155
+ async callTool(name, input) {
156
+ const path = `/api/tools/${encodeURIComponent(name)}`;
157
+ const res = await this.#request('POST', path, input);
158
+ if (res.status === 422) {
159
+ throw new UsageError(
160
+ `invalid input for ${name}: ${validationDetail(res)}`
161
+ );
162
+ }
163
+ if (res.status === 404) {
164
+ throw new UsageError(
165
+ `${this.target} does not serve the tool "${name}"; ` +
166
+ 'the instance is probably older than this CLI'
167
+ );
168
+ }
169
+ if (!res.ok) throw failure('POST', path, res);
170
+ if (res.parsed === null) {
171
+ throw new UserError(
172
+ `POST ${path} answered something that is not JSON: ${res.text.trim()}`
173
+ );
174
+ }
175
+ return { result: res.parsed, text: res.text };
176
+ }
177
+
178
+ /**
179
+ * GET /api/tools — every served tool with its description, input schema
180
+ * and effect. Help reads it, so the text a person sees is the server's.
181
+ */
182
+ async listTools() {
183
+ const body = await this.#json('GET', '/api/tools');
184
+ return body?.tools ?? [];
185
+ }
186
+
187
+ /**
188
+ * GET /api/skills — `{skills: [{name, description}], agent_skill_version}`,
189
+ * sorted by name. `authoring` (`flow` or `integration`) narrows it to the
190
+ * skills for writing that kind of working copy.
191
+ */
192
+ async listSkills({ authoring } = {}) {
193
+ const query = authoring
194
+ ? `?authoring=${encodeURIComponent(authoring)}`
195
+ : '';
196
+ return this.#json('GET', `/api/skills${query}`);
197
+ }
198
+
199
+ /**
200
+ * GET /api/agent-skill — `{version, skill_md, agents_md, permissions,
201
+ * working_copy_permissions}`: the skill a coding agent installs and the
202
+ * permission rules that go with it, both rendered by the server.
203
+ */
204
+ async getAgentSkill() {
205
+ return this.#json('GET', '/api/agent-skill');
206
+ }
207
+
208
+ /**
209
+ * GET /api/skills/{name} — `{name, description, body}`, the body exactly as
210
+ * the in-app load_skill returns it, or null when the instance serves no
211
+ * skill of that name.
212
+ */
213
+ async getSkill(name) {
214
+ const path = `/api/skills/${encodeURIComponent(name)}`;
215
+ const res = await this.#request('GET', path);
216
+ if (res.status === 404) return null;
217
+ if (!res.ok || res.parsed === null) throw failure('GET', path, res);
218
+ return res.parsed;
219
+ }
220
+
221
+ /**
222
+ * GET /api/secrets/{id} — the credential's `{name, description}`, or null
223
+ * when there is none (a built-in id answers 404 too). The route also
224
+ * answers the plaintext value; it is dropped here, so it never leaves this
225
+ * method.
226
+ */
227
+ async getCredentialMeta(id) {
228
+ const path = `/api/secrets/${encodeURIComponent(id)}`;
229
+ const res = await this.#request('GET', path);
230
+ if (res.status === 404) return null;
231
+ if (!res.ok || res.parsed === null) throw failure('GET', path, res);
232
+ return {
233
+ name: res.parsed.name ?? '',
234
+ description: res.parsed.description ?? '',
235
+ };
236
+ }
237
+
238
+ /** POST /api/secrets — a new credential. */
239
+ async createCredential({ id, name, description, value }) {
240
+ await this.#credentialWrite('POST', '/api/secrets', id, {
241
+ id,
242
+ name,
243
+ description,
244
+ value,
245
+ });
246
+ }
247
+
248
+ /** PUT /api/secrets/{id} — replace an existing credential's value. */
249
+ async updateCredential(id, { name, description, value }) {
250
+ const path = `/api/secrets/${encodeURIComponent(id)}`;
251
+ await this.#credentialWrite('PUT', path, id, { name, description, value });
252
+ }
253
+
254
+ /**
255
+ * #credentialWrite sends a credential write and returns nothing: PUT
256
+ * answers the stored plaintext, which no caller has any use for. A 422 is
257
+ * the server refusing the request (a reserved built-in id, a missing
258
+ * description), printed in its own words as a usage error.
259
+ */
260
+ async #credentialWrite(method, path, id, body) {
261
+ const res = await this.#request(method, path, body);
262
+ if (res.status === 422) {
263
+ throw new UsageError(
264
+ `cannot set credential "${id}": ${validationDetail(res)}`
265
+ );
266
+ }
267
+ if (!res.ok) throw failure(method, path, res);
50
268
  }
51
269
 
52
270
  async #json(method, path, body) {
271
+ const res = await this.#request(method, path, body);
272
+ if (res.ok) return res.parsed;
273
+
274
+ // A 422 is the interesting failure: it carries the compiler's own
275
+ // findings, and reprinting them beats summarising them.
276
+ if (res.parsed?.diagnostics?.length) {
277
+ throw new DiagnosticsError(
278
+ res.parsed.detail || 'verification failed',
279
+ res.parsed.diagnostics
280
+ );
281
+ }
282
+ throw failure(method, path, res);
283
+ }
284
+
285
+ /**
286
+ * POST /api/agent/prompt — one exchange with the in-app assistant. `stream`
287
+ * is written into the body here, so the request and how its answer is read
288
+ * cannot disagree.
289
+ *
290
+ * - `stream: false`: `{status, event, text}` — 200 is a turn that ended,
291
+ * 202 a `pending` one; `text` is the body as it arrived.
292
+ * - `stream: true`: `{status: 200, events}`, an async iterator over the
293
+ * NDJSON lines as `{event, text}`, read as they arrive.
294
+ * - 409: `{status: 409, conflict}`, the server's words. The caller decides
295
+ * what each one means (prompt.js); this method only reports it.
296
+ *
297
+ * A 400 is a malformed `--session` or `--wait` (UsageError); a 404 is
298
+ * observing a session that does not exist (UserError).
299
+ */
300
+ async prompt(body, { stream }) {
301
+ const path = '/api/agent/prompt';
302
+ const res = await this.#send('POST', path, { ...body, stream });
303
+ if (stream && res.status === 200) {
304
+ return { status: 200, events: ndjson(res.body, this.target) };
305
+ }
306
+
307
+ const answer = await this.#read(res);
308
+ if (answer.status === 409) {
309
+ return { status: 409, conflict: problemDetail(answer) };
310
+ }
311
+ if (answer.status === 400) throw new UsageError(problemDetail(answer));
312
+ if (answer.status === 404) throw new UserError(problemDetail(answer));
313
+ if (!answer.ok) throw failure('POST', path, answer);
314
+ if (answer.parsed === null || typeof answer.parsed !== 'object') {
315
+ throw new UserError(
316
+ `POST ${path} answered something that is not JSON: ${answer.text.trim()}`
317
+ );
318
+ }
319
+ return { status: answer.status, event: answer.parsed, text: answer.text };
320
+ }
321
+
322
+ /**
323
+ * GET /api/agent/{id} — one session's `{status, pending_input, …}`, or null
324
+ * when there is none.
325
+ */
326
+ async getAgentSession(id) {
327
+ const path = `/api/agent/${encodeURIComponent(id)}`;
328
+ const res = await this.#request('GET', path);
329
+ if (res.status === 404) return null;
330
+ if (!res.ok || res.parsed === null) throw failure('GET', path, res);
331
+ return res.parsed;
332
+ }
333
+
334
+ /**
335
+ * #request sends a request and reads its whole answer. It throws only when
336
+ * there is no answer at all; every status comes back to the caller, which
337
+ * knows what a 404 or a 422 means for its route.
338
+ */
339
+ async #request(method, path, body) {
340
+ return this.#read(await this.#send(method, path, body));
341
+ }
342
+
343
+ /**
344
+ * #send is the one place a request leaves this CLI, returning the fetch
345
+ * Response unread so a stream can be read as it arrives.
346
+ */
347
+ async #send(method, path, body) {
53
348
  const url = `${this.target}${path}`;
54
- let res;
349
+ // Every request carries the header, the existing commands' included:
350
+ // one client, one header.
351
+ const headers = { 'X-Juno-Client': this.client };
352
+ if (body) headers['content-type'] = 'application/json';
55
353
  try {
56
- res = await this.fetch(url, {
354
+ return await this.fetch(url, {
57
355
  method,
58
- headers: body ? { 'content-type': 'application/json' } : {},
356
+ headers,
59
357
  body: body ? JSON.stringify(body) : undefined,
60
358
  });
61
359
  } catch (err) {
62
- throw new UserError(
360
+ throw new UnreachableError(
63
361
  `cannot reach Juno at ${this.target}: ${err.message}`
64
362
  );
65
363
  }
364
+ }
66
365
 
67
- const text = await res.text();
366
+ async #read(res) {
367
+ let text;
368
+ try {
369
+ text = await res.text();
370
+ } catch (err) {
371
+ throw new UnreachableError(
372
+ `lost the connection to Juno at ${this.target}: ${err.message}`
373
+ );
374
+ }
68
375
  let parsed = null;
69
376
  try {
70
377
  parsed = text ? JSON.parse(text) : null;
71
378
  } catch {
72
- // A non-JSON body from a proxy or a wrong URL; reported below as-is.
379
+ // A non-JSON body from a proxy or a wrong URL; reported as-is.
73
380
  }
381
+ return {
382
+ ok: res.ok,
383
+ status: res.status,
384
+ statusText: res.statusText,
385
+ text,
386
+ parsed,
387
+ };
388
+ }
389
+ }
74
390
 
75
- if (res.ok) return parsed;
76
-
77
- // A 422 is the interesting failure: it carries the compiler's own
78
- // findings, and reprinting them beats summarising them.
79
- if (parsed?.diagnostics?.length) {
80
- throw new DiagnosticsError(
81
- parsed.detail || 'verification failed',
82
- parsed.diagnostics
83
- );
391
+ /**
392
+ * ndjson reads a response body one line at a time, yielding each parsed line
393
+ * with its exact text. A connection that drops mid-stream is an
394
+ * UnreachableError, like one that never connected: either way there is no
395
+ * answer, and a retry that reuses the message id is safe.
396
+ */
397
+ async function* ndjson(body, target) {
398
+ const decoder = new TextDecoder();
399
+ let buffered = '';
400
+ const lines = function* (final) {
401
+ let nl;
402
+ while ((nl = buffered.indexOf('\n')) >= 0 || (final && buffered)) {
403
+ const text = nl >= 0 ? buffered.slice(0, nl) : buffered;
404
+ buffered = nl >= 0 ? buffered.slice(nl + 1) : '';
405
+ if (!text.trim()) continue;
406
+ let event;
407
+ try {
408
+ event = JSON.parse(text);
409
+ } catch {
410
+ throw new UserError(
411
+ `the prompt stream sent a line that is not JSON: ${text}`
412
+ );
413
+ }
414
+ yield { event, text };
84
415
  }
85
- const detail =
86
- parsed?.detail || parsed?.title || text.trim() || res.statusText;
87
- throw new UserError(`${method} ${path} failed (${res.status}): ${detail}`);
416
+ };
417
+ try {
418
+ for await (const chunk of body) {
419
+ buffered += decoder.decode(chunk, { stream: true });
420
+ yield* lines(false);
421
+ }
422
+ } catch (err) {
423
+ if (err instanceof UserError) throw err;
424
+ throw new UnreachableError(
425
+ `lost the connection to Juno at ${target}: ${err.message}`
426
+ );
427
+ }
428
+ buffered += decoder.decode();
429
+ yield* lines(true);
430
+ }
431
+
432
+ /** The server's own words in an error answer: Huma's `detail`, else the body. */
433
+ function problemDetail(res) {
434
+ return (
435
+ res.parsed?.detail || res.parsed?.title || res.text.trim() || res.statusText
436
+ );
437
+ }
438
+
439
+ /** A UserError for a non-ok answer, carrying the server's own words. */
440
+ function failure(method, path, res) {
441
+ return new UserError(
442
+ `${method} ${path} failed (${res.status}): ${problemDetail(res)}`
443
+ );
444
+ }
445
+
446
+ /**
447
+ * The words of a 422: the gateway's own detail names the failing field (`/id`,
448
+ * `/ops/0/when`, `/` for the root); Huma's request validation puts it in
449
+ * `errors[]` instead, so those are appended.
450
+ */
451
+ function validationDetail(res) {
452
+ const parts = [res.parsed?.detail || res.text.trim() || res.statusText];
453
+ for (const e of res.parsed?.errors ?? []) {
454
+ parts.push(e.location ? `${e.location}: ${e.message}` : e.message);
88
455
  }
456
+ return parts.filter(Boolean).join('; ');
89
457
  }
@@ -0,0 +1,42 @@
1
+ import { warnIfStale } from './installed-skill.js';
2
+
3
+ /**
4
+ * The kinds of working copy with an authoring skill index, and the heading
5
+ * each is printed under. The index itself is the server's
6
+ * (GET /api/skills?authoring=<kind>): the same name and description lines the
7
+ * in-app flow and integration authors are given, so it cannot drift from the
8
+ * skills.
9
+ */
10
+ export const AUTHORING_HEADINGS = {
11
+ flow: 'Skills for writing a flow',
12
+ integration: 'Skills for writing an integration',
13
+ };
14
+
15
+ /** The heading line, saying how a listed skill is loaded. */
16
+ export function authoringHeading(kind) {
17
+ return `${AUTHORING_HEADINGS[kind]} (load one with \`juno skill <name>\`):`;
18
+ }
19
+
20
+ /**
21
+ * printAuthoringIndex prints a kind's authoring skills after `init` has
22
+ * written the working copy, as ` <name> — <description>`. Fetching it is a
23
+ * courtesy: the working copy is already on disk, so a failure is a note on
24
+ * stderr, never a failed init. It also says when an installed agent skill is
25
+ * stale, since the index carries the version the instance serves.
26
+ */
27
+ export async function printAuthoringIndex(kind, client, target, io) {
28
+ let index;
29
+ try {
30
+ index = await client.listSkills({ authoring: kind });
31
+ } catch (err) {
32
+ io.warn(`(skill index unavailable: ${err.message})`);
33
+ return;
34
+ }
35
+ const skills = index?.skills ?? [];
36
+ if (skills.length > 0) {
37
+ io.log('');
38
+ io.log(authoringHeading(kind));
39
+ for (const s of skills) io.log(` ${s.name} — ${s.description}`);
40
+ }
41
+ await warnIfStale(index, target, io);
42
+ }
@@ -0,0 +1,64 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ import { connect } from '../api.js';
5
+ import { UsageError } from '../errors.js';
6
+ import { SKILL_DIR } from '../installed-skill.js';
7
+
8
+ export const AGENT_SKILL_INSTALL_USAGE =
9
+ 'juno agent-skill install [--dir <path>] [--allow-change] [--working-copy] [--agents-md] [--json]';
10
+
11
+ /**
12
+ * `juno agent-skill install` writes the skill GET /api/agent-skill renders to
13
+ * `<dir>/SKILL.md` (default `.claude/skills/juno` under the current
14
+ * directory) and prints a Claude Code permission block on stdout: every read
15
+ * command, plus every change command with --allow-change, plus the working
16
+ * copy's npm scripts of the same tiers with --working-copy. No act command is
17
+ * ever in it — the server leaves them out, and the CLI adds nothing.
18
+ *
19
+ * `--agents-md` prints the same text as an AGENTS.md section instead and
20
+ * writes nothing; `--json` prints the route's body.
21
+ */
22
+ export async function agentSkillInstall(args, io) {
23
+ if (args._.length > 0) {
24
+ throw new UsageError(
25
+ `juno agent-skill install takes no argument; usage: ${AGENT_SKILL_INSTALL_USAGE}`
26
+ );
27
+ }
28
+ const { client } = await connect(args, io);
29
+ const skill = await client.getAgentSkill();
30
+
31
+ if (args.json) return printJSON(skill, io);
32
+ if (args['agents-md']) {
33
+ io.write(skill.agents_md);
34
+ return 0;
35
+ }
36
+
37
+ const dir = resolve(io.cwd, args.dir ?? SKILL_DIR);
38
+ const path = join(dir, 'SKILL.md');
39
+ await mkdir(dir, { recursive: true });
40
+ await writeFile(path, skill.skill_md, 'utf8');
41
+
42
+ const tiers = (t) => [
43
+ ...(t?.read ?? []),
44
+ ...(args['allow-change'] ? (t?.change ?? []) : []),
45
+ ];
46
+ const allow = tiers(skill.permissions);
47
+ if (args['working-copy'])
48
+ allow.push(...tiers(skill.working_copy_permissions));
49
+
50
+ io.warn(`wrote ${path} (version ${skill.version})`);
51
+ io.warn(
52
+ args['working-copy']
53
+ ? "Add these rules to this working copy's .claude/settings.json — not to user-wide settings, " +
54
+ "where they would also allow any other project's npm scripts:"
55
+ : 'Add these rules to .claude/settings.json (or ~/.claude/settings.json) to run them without a prompt:'
56
+ );
57
+ return printJSON({ permissions: { allow } }, io);
58
+ }
59
+
60
+ /** Compact off a TTY, indented on one, as every JSON the CLI prints. */
61
+ function printJSON(value, io) {
62
+ io.log(io.isTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value));
63
+ return 0;
64
+ }