@junoflow/cli 0.1.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +579 -54
- package/bin/juno.js +7 -0
- package/eslint.flow.config.js +33 -0
- package/package.json +7 -4
- package/src/api.js +395 -27
- package/src/authoring-skills.js +42 -0
- package/src/commands/agent-skill-install.js +64 -0
- package/src/commands/ask.js +60 -0
- package/src/commands/check.js +44 -16
- package/src/commands/config.js +26 -0
- package/src/commands/credential-set.js +68 -0
- package/src/commands/deploy.js +41 -50
- package/src/commands/flow-check.js +93 -0
- package/src/commands/flow-deploy.js +107 -0
- package/src/commands/flow-gen-types.js +30 -0
- package/src/commands/flow-init.js +59 -0
- package/src/commands/flow-pull.js +86 -0
- package/src/commands/{types.js → gen-types.js} +12 -5
- package/src/commands/init.js +28 -16
- package/src/commands/pull.js +70 -16
- package/src/commands/session-approve.js +73 -0
- package/src/commands/skill.js +73 -0
- package/src/commands/tool.js +249 -0
- package/src/commands/verify-types.js +4 -2
- package/src/config.js +34 -0
- package/src/diagnostics.js +14 -0
- package/src/errors.js +40 -0
- package/src/exit.js +22 -0
- package/src/flow-project.js +128 -0
- package/src/help.js +199 -0
- package/src/index.js +506 -75
- package/src/installed-skill.js +55 -0
- package/src/prompt.js +196 -0
- package/src/stamp.js +10 -9
- package/src/suggest.js +67 -0
- package/src/target.js +22 -7
- package/src/verbs.js +34 -0
- package/src/watch.js +50 -0
- package/verbs.json +227 -0
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.
|
|
4
|
-
"description": "
|
|
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.
|
|
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 {
|
|
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(
|
|
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(
|
|
58
|
+
async scaffold(id, { name, description, target } = {}) {
|
|
22
59
|
const body = await this.#json('POST', '/api/integrations/scaffold', {
|
|
23
|
-
|
|
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,
|
|
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
|
-
|
|
81
|
+
id: id ?? undefined,
|
|
44
82
|
});
|
|
45
83
|
}
|
|
46
84
|
|
|
47
|
-
/** GET /api/integrations/{
|
|
48
|
-
async getIntegration(
|
|
49
|
-
return this.#json('GET', `/api/integrations/${encodeURIComponent(
|
|
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
|
-
|
|
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
|
-
|
|
354
|
+
return await this.fetch(url, {
|
|
57
355
|
method,
|
|
58
|
-
headers
|
|
356
|
+
headers,
|
|
59
357
|
body: body ? JSON.stringify(body) : undefined,
|
|
60
358
|
});
|
|
61
359
|
} catch (err) {
|
|
62
|
-
throw new
|
|
360
|
+
throw new UnreachableError(
|
|
63
361
|
`cannot reach Juno at ${this.target}: ${err.message}`
|
|
64
362
|
);
|
|
65
363
|
}
|
|
364
|
+
}
|
|
66
365
|
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|