@junoflow/cli 0.3.0 → 0.9.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 +693 -52
- package/app-client.js +306 -0
- package/bin/juno.js +7 -0
- package/eslint.app.config.js +34 -0
- package/eslint.flow.config.js +35 -0
- package/package.json +12 -4
- package/src/api.js +541 -30
- package/src/app-dev/build.js +134 -0
- package/src/app-dev/harness.js +108 -0
- package/src/app-dev/panel.html +336 -0
- package/src/app-dev/panel.js +290 -0
- package/src/app-dev/server.js +347 -0
- package/src/app-dev/stream.js +224 -0
- package/src/app-dev/watch.js +47 -0
- package/src/app-project.js +133 -0
- package/src/authoring-skills.js +43 -0
- package/src/commands/agent-skill-install.js +64 -0
- package/src/commands/app-check.js +75 -0
- package/src/commands/app-deploy.js +102 -0
- package/src/commands/app-dev.js +141 -0
- package/src/commands/app-gen-types.js +37 -0
- package/src/commands/app-init.js +46 -0
- package/src/commands/app-pull.js +106 -0
- package/src/commands/ask.js +60 -0
- package/src/commands/check.js +71 -27
- package/src/commands/config.js +26 -0
- package/src/commands/credential-set.js +68 -0
- package/src/commands/deploy.js +95 -51
- package/src/commands/flow-check.js +75 -0
- package/src/commands/flow-deploy.js +110 -0
- package/src/commands/flow-fixture.js +67 -0
- package/src/commands/flow-gen-types.js +37 -0
- package/src/commands/flow-init.js +59 -0
- package/src/commands/flow-pull.js +115 -0
- package/src/commands/{types.js → gen-types.js} +12 -5
- package/src/commands/init.js +28 -16
- package/src/commands/pull.js +111 -63
- 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 +78 -0
- package/src/help.js +199 -0
- package/src/index.js +567 -75
- package/src/installed-skill.js +55 -0
- package/src/project.js +76 -8
- 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 +87 -0
- package/src/working-copy.js +262 -0
- package/tsconfig.page.json +15 -0
- package/verbs.json +276 -0
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,19 +41,24 @@ 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
|
/**
|
|
17
54
|
* POST /api/integrations/scaffold — a whole starting project, path →
|
|
18
|
-
* content. `target` is what this client reached the instance at
|
|
19
|
-
*
|
|
55
|
+
* content. `target` is what this client reached the instance at, recorded
|
|
56
|
+
* as juno.target; the server falls back to its public URL without 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
|
});
|
|
@@ -32,58 +70,531 @@ export class JunoClient {
|
|
|
32
70
|
return this.#json('POST', '/api/typegen/integration', { manifest });
|
|
33
71
|
}
|
|
34
72
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
73
|
+
/**
|
|
74
|
+
* POST /api/integrations/ingest — deploy, or validate only with dryRun.
|
|
75
|
+
* `baseVersion` undefined sends none (a copy that records no base, --force,
|
|
76
|
+
* or --id naming another install). A 409 is a ConflictError: the install's
|
|
77
|
+
* active version is not the copy's base.
|
|
78
|
+
*
|
|
79
|
+
* It does not go through #workingCopyJSON, whose 422-without-diagnostics
|
|
80
|
+
* wording differs from what this route has always printed; only the 409 is
|
|
81
|
+
* shared.
|
|
82
|
+
*/
|
|
83
|
+
async ingest(project, { dryRun = false, id, baseVersion } = {}) {
|
|
84
|
+
const path = `/api/integrations/ingest${dryRun ? '?dry_run=true' : ''}`;
|
|
85
|
+
const res = await this.#request('POST', path, {
|
|
39
86
|
manifest: project.manifest,
|
|
40
87
|
files: project.files,
|
|
41
88
|
package_json: project.packageJSON ?? undefined,
|
|
42
89
|
lockfile: project.lockfile ?? undefined,
|
|
43
|
-
|
|
90
|
+
id: id ?? undefined,
|
|
91
|
+
base_version: baseVersion ?? undefined,
|
|
92
|
+
});
|
|
93
|
+
if (res.status === 409) throw new ConflictError(problemDetail(res));
|
|
94
|
+
return this.#answer('POST', path, res);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** GET /api/integrations/{id} — the manifest and sources Juno holds. */
|
|
98
|
+
async getIntegration(id) {
|
|
99
|
+
return this.#json('GET', `/api/integrations/${encodeURIComponent(id)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* GET /api/integrations/{id}/versions/{v} — one stored version, keyed as
|
|
104
|
+
* the Go struct spells it (`Manifest`, `SourceFiles`), or null when the
|
|
105
|
+
* instance has no such version.
|
|
106
|
+
*/
|
|
107
|
+
async getIntegrationVersion(id, version) {
|
|
108
|
+
return this.#versionOrNull(
|
|
109
|
+
`/api/integrations/${encodeURIComponent(id)}/versions/${version}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* GET /api/flows/{id}/versions/{v} — one stored version (`manifest_json`,
|
|
115
|
+
* `source_files`), or null when the instance has no such version.
|
|
116
|
+
*/
|
|
117
|
+
async getFlowVersion(id, version) {
|
|
118
|
+
return this.#versionOrNull(
|
|
119
|
+
`/api/flows/${encodeURIComponent(id)}/versions/${version}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* GET /api/flows/{id}/runs/{seq}/fixture — `{fixture, notes}`, run `seq`
|
|
125
|
+
* as a flowHarness scenario. A refusal (a run still going, one that
|
|
126
|
+
* suspended or was cancelled) is a 422 and an unknown flow or run a 404;
|
|
127
|
+
* both are a UserError in the server's own words.
|
|
128
|
+
*/
|
|
129
|
+
async runFixture(flowId, seq) {
|
|
130
|
+
const path = `/api/flows/${encodeURIComponent(flowId)}/runs/${seq}/fixture`;
|
|
131
|
+
const res = await this.#request('GET', path);
|
|
132
|
+
if (res.status === 422 || res.status === 404) {
|
|
133
|
+
throw new UserError(problemDetail(res));
|
|
134
|
+
}
|
|
135
|
+
return this.#answer('GET', path, res);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* GET /api/apps/{id}/versions/{v} — one stored version (`manifest`,
|
|
140
|
+
* `files`), or null when the instance has no such version.
|
|
141
|
+
*/
|
|
142
|
+
async getAppVersion(id, version) {
|
|
143
|
+
return this.#versionOrNull(
|
|
144
|
+
`/api/apps/${encodeURIComponent(id)}/versions/${version}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** #versionOrNull is a GET whose 404 is an answer: there is no such version. */
|
|
149
|
+
async #versionOrNull(path) {
|
|
150
|
+
const res = await this.#request('GET', path);
|
|
151
|
+
if (res.status === 404) return null;
|
|
152
|
+
return this.#answer('GET', path, res);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* POST /api/flows/scaffold — a flow working copy, `{files: {path: content},
|
|
157
|
+
* warnings?: [string]}`: the active version's, or a stub for an id with
|
|
158
|
+
* none. The warnings are a stored manifest the live catalog now refuses;
|
|
159
|
+
* the working copy is still rendered, because it is how that gets fixed.
|
|
160
|
+
*/
|
|
161
|
+
async scaffoldFlow(id, { target } = {}) {
|
|
162
|
+
const body = await this.#workingCopyJSON('/api/flows/scaffold', {
|
|
163
|
+
id,
|
|
164
|
+
target: target ?? undefined,
|
|
44
165
|
});
|
|
166
|
+
return { files: body?.files ?? {}, warnings: body?.warnings ?? [] };
|
|
45
167
|
}
|
|
46
168
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
169
|
+
/**
|
|
170
|
+
* POST /api/typegen/flow — `{manifest, types: {"types/flow-env.d.ts",
|
|
171
|
+
* "types/flow-api.d.ts"}}` for a manifest, from the live catalog.
|
|
172
|
+
*/
|
|
173
|
+
async typegenFlow(manifest) {
|
|
174
|
+
return this.#workingCopyJSON('/api/typegen/flow', { manifest });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* POST /api/flows/ingest — verify a flow working copy and, unless dryRun,
|
|
179
|
+
* commit and activate it. `files` is the working copy's src/ tree, path →
|
|
180
|
+
* content. `baseVersion` undefined sends none (a new flow,
|
|
181
|
+
* or --force). A 409 is a ConflictError; a 422 with diagnostics is a
|
|
182
|
+
* DiagnosticsError.
|
|
183
|
+
*/
|
|
184
|
+
async ingestFlow({ flowId, manifest, files, baseVersion }, { dryRun } = {}) {
|
|
185
|
+
const query = dryRun ? '?dry_run=true' : '';
|
|
186
|
+
return this.#workingCopyJSON(`/api/flows/ingest${query}`, {
|
|
187
|
+
flow_id: flowId,
|
|
188
|
+
manifest,
|
|
189
|
+
files,
|
|
190
|
+
base_version: baseVersion ?? undefined,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* POST /api/apps/scaffold — an app working copy, `{files: {path:
|
|
196
|
+
* content}}`: the active version's, or a stub for an id with none.
|
|
197
|
+
*/
|
|
198
|
+
async scaffoldApp(id, { target } = {}) {
|
|
199
|
+
const body = await this.#workingCopyJSON('/api/apps/scaffold', {
|
|
200
|
+
id,
|
|
201
|
+
target: target ?? undefined,
|
|
202
|
+
});
|
|
203
|
+
return body?.files ?? {};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* POST /api/typegen/app — `{manifest, types: {"types/app-api.d.ts", …}}`
|
|
208
|
+
* for an edited manifest, from the live catalog.
|
|
209
|
+
*/
|
|
210
|
+
async typegenApp(manifest) {
|
|
211
|
+
return this.#workingCopyJSON('/api/typegen/app', { manifest });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* POST /api/apps/ingest — verify an app working copy and store it as a
|
|
216
|
+
* version: activated, or with `activate: false` left as the draft
|
|
217
|
+
* `~draft/` serves. A 422 with diagnostics is a DiagnosticsError.
|
|
218
|
+
* `baseVersion` undefined sends none (a copy that records no base, or
|
|
219
|
+
* --force). When activating, a 409 is a ConflictError: the app's active
|
|
220
|
+
* version is not the copy's base. A draft is never refused; the answer's
|
|
221
|
+
* `stale_base` carries the sentence instead.
|
|
222
|
+
*/
|
|
223
|
+
async ingestApp(
|
|
224
|
+
{ manifest, files, lockfile },
|
|
225
|
+
{ activate = true, baseVersion } = {}
|
|
226
|
+
) {
|
|
227
|
+
return this.#workingCopyJSON('/api/apps/ingest', {
|
|
228
|
+
manifest,
|
|
229
|
+
files,
|
|
230
|
+
lockfile: lockfile ?? undefined,
|
|
231
|
+
activate,
|
|
232
|
+
base_version: baseVersion ?? undefined,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* #workingCopyJSON is #json for the flow and app working-copy routes, which
|
|
238
|
+
* answer two more failures in their own words: a 409 (the working copy is
|
|
239
|
+
* stale) and a 422 with no diagnostics (an id that is not kebab-case, an
|
|
240
|
+
* archive path outside src/, page/ and public/).
|
|
241
|
+
*/
|
|
242
|
+
async #workingCopyJSON(path, body) {
|
|
243
|
+
const res = await this.#request('POST', path, body);
|
|
244
|
+
if (res.status === 409) throw new ConflictError(problemDetail(res));
|
|
245
|
+
if (res.status === 422 && !res.parsed?.diagnostics?.length) {
|
|
246
|
+
throw new UserError(validationDetail(res));
|
|
247
|
+
}
|
|
248
|
+
if (res.ok) return jsonBody('POST', path, res);
|
|
249
|
+
if (res.parsed?.diagnostics?.length) {
|
|
250
|
+
throw new DiagnosticsError(
|
|
251
|
+
res.parsed.detail || 'verification failed',
|
|
252
|
+
res.parsed.diagnostics
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
throw failure('POST', path, res);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* POST /api/tools/{name} — one tool call. A 200 is the tool's answer and is
|
|
260
|
+
* returned whatever it says, `{"error": …}` included: a domain failure is a
|
|
261
|
+
* result, and the caller decides the exit code. `text` is the body exactly as
|
|
262
|
+
* it arrived, so it can be printed unchanged.
|
|
263
|
+
*/
|
|
264
|
+
async callTool(name, input) {
|
|
265
|
+
const path = `/api/tools/${encodeURIComponent(name)}`;
|
|
266
|
+
const res = await this.#request('POST', path, input);
|
|
267
|
+
if (res.status === 422) {
|
|
268
|
+
throw new UsageError(
|
|
269
|
+
`invalid input for ${name}: ${validationDetail(res)}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
if (res.status === 404) {
|
|
273
|
+
throw new UsageError(
|
|
274
|
+
`${this.target} does not serve the tool "${name}"; ` +
|
|
275
|
+
'the instance is probably older than this CLI'
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (!res.ok) throw failure('POST', path, res);
|
|
279
|
+
return { result: jsonBody('POST', path, res), text: res.text };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* GET /api/tools — every served tool with its description, input schema
|
|
284
|
+
* and effect. Help reads it, so the text a person sees is the server's.
|
|
285
|
+
*/
|
|
286
|
+
async listTools() {
|
|
287
|
+
const body = await this.#json('GET', '/api/tools');
|
|
288
|
+
return body?.tools ?? [];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* GET /api/skills — `{skills: [{name, description}], agent_skill_version}`,
|
|
293
|
+
* sorted by name. `authoring` (`flow` or `integration`) narrows it to the
|
|
294
|
+
* skills for writing that kind of working copy.
|
|
295
|
+
*/
|
|
296
|
+
async listSkills({ authoring } = {}) {
|
|
297
|
+
const query = authoring
|
|
298
|
+
? `?authoring=${encodeURIComponent(authoring)}`
|
|
299
|
+
: '';
|
|
300
|
+
return this.#json('GET', `/api/skills${query}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* GET /api/agent-skill — `{version, skill_md, agents_md, permissions,
|
|
305
|
+
* working_copy_permissions}`: the skill a coding agent installs and the
|
|
306
|
+
* permission rules that go with it, both rendered by the server.
|
|
307
|
+
*/
|
|
308
|
+
async getAgentSkill() {
|
|
309
|
+
return this.#json('GET', '/api/agent-skill');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* GET /api/skills/{name} — `{name, description, body}`, the body exactly as
|
|
314
|
+
* the in-app load_skill returns it, or null when the instance serves no
|
|
315
|
+
* skill of that name.
|
|
316
|
+
*/
|
|
317
|
+
async getSkill(name) {
|
|
318
|
+
const path = `/api/skills/${encodeURIComponent(name)}`;
|
|
319
|
+
const res = await this.#request('GET', path);
|
|
320
|
+
if (res.status === 404) return null;
|
|
321
|
+
if (!res.ok) throw failure('GET', path, res);
|
|
322
|
+
return jsonBody('GET', path, res);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* GET /api/secrets/{id} — the credential's `{name, description}`, or null
|
|
327
|
+
* when there is none (a built-in id answers 404 too). The route also
|
|
328
|
+
* answers the plaintext value; it is dropped here, so it never leaves this
|
|
329
|
+
* method.
|
|
330
|
+
*/
|
|
331
|
+
async getCredentialMeta(id) {
|
|
332
|
+
const path = `/api/secrets/${encodeURIComponent(id)}`;
|
|
333
|
+
const res = await this.#request('GET', path);
|
|
334
|
+
if (res.status === 404) return null;
|
|
335
|
+
if (!res.ok) throw failure('GET', path, res);
|
|
336
|
+
jsonBody('GET', path, res);
|
|
337
|
+
return {
|
|
338
|
+
name: res.parsed.name ?? '',
|
|
339
|
+
description: res.parsed.description ?? '',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** POST /api/secrets — a new credential. */
|
|
344
|
+
async createCredential({ id, name, description, value }) {
|
|
345
|
+
await this.#credentialWrite('POST', '/api/secrets', id, {
|
|
346
|
+
id,
|
|
347
|
+
name,
|
|
348
|
+
description,
|
|
349
|
+
value,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** PUT /api/secrets/{id} — replace an existing credential's value. */
|
|
354
|
+
async updateCredential(id, { name, description, value }) {
|
|
355
|
+
const path = `/api/secrets/${encodeURIComponent(id)}`;
|
|
356
|
+
await this.#credentialWrite('PUT', path, id, { name, description, value });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* #credentialWrite sends a credential write and returns nothing: PUT
|
|
361
|
+
* answers the stored plaintext, which no caller has any use for. A 422 is
|
|
362
|
+
* the server refusing the request (a reserved built-in id, a missing
|
|
363
|
+
* description), printed in its own words as a usage error.
|
|
364
|
+
*/
|
|
365
|
+
async #credentialWrite(method, path, id, body) {
|
|
366
|
+
const res = await this.#request(method, path, body);
|
|
367
|
+
if (res.status === 422) {
|
|
368
|
+
throw new UsageError(
|
|
369
|
+
`cannot set credential "${id}": ${validationDetail(res)}`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (!res.ok) throw failure(method, path, res);
|
|
50
373
|
}
|
|
51
374
|
|
|
52
375
|
async #json(method, path, body) {
|
|
376
|
+
return this.#answer(method, path, await this.#request(method, path, body));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** #answer is #json's reading of an answer already received. */
|
|
380
|
+
#answer(method, path, res) {
|
|
381
|
+
if (res.ok) return jsonBody(method, path, res);
|
|
382
|
+
|
|
383
|
+
// A 422 is the interesting failure: it carries the compiler's own
|
|
384
|
+
// findings, and reprinting them beats summarising them.
|
|
385
|
+
if (res.parsed?.diagnostics?.length) {
|
|
386
|
+
throw new DiagnosticsError(
|
|
387
|
+
res.parsed.detail || 'verification failed',
|
|
388
|
+
res.parsed.diagnostics
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
throw failure(method, path, res);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* POST /api/agent/prompt — one exchange with the in-app assistant. `stream`
|
|
396
|
+
* is written into the body here, so the request and how its answer is read
|
|
397
|
+
* cannot disagree.
|
|
398
|
+
*
|
|
399
|
+
* - `stream: false`: `{status, event, text}` — 200 is a turn that ended,
|
|
400
|
+
* 202 a `pending` one; `text` is the body as it arrived.
|
|
401
|
+
* - `stream: true`: `{status: 200, events}`, an async iterator over the
|
|
402
|
+
* NDJSON lines as `{event, text}`, read as they arrive.
|
|
403
|
+
* - 409: `{status: 409, conflict}`, the server's words. The caller decides
|
|
404
|
+
* what each one means (prompt.js); this method only reports it.
|
|
405
|
+
*
|
|
406
|
+
* A 400 is a malformed `--session` or `--wait` (UsageError); a 404 is
|
|
407
|
+
* observing a session that does not exist (UserError).
|
|
408
|
+
*/
|
|
409
|
+
async prompt(body, { stream }) {
|
|
410
|
+
const path = '/api/agent/prompt';
|
|
411
|
+
const res = await this.#send('POST', path, { ...body, stream });
|
|
412
|
+
if (stream && res.status === 200) {
|
|
413
|
+
return { status: 200, events: ndjson(res.body, this.target) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const answer = await this.#read(res);
|
|
417
|
+
if (answer.status === 409) {
|
|
418
|
+
return { status: 409, conflict: problemDetail(answer) };
|
|
419
|
+
}
|
|
420
|
+
if (answer.status === 400) throw new UsageError(problemDetail(answer));
|
|
421
|
+
if (answer.status === 404) throw new UserError(problemDetail(answer));
|
|
422
|
+
if (!answer.ok) throw failure('POST', path, answer);
|
|
423
|
+
return {
|
|
424
|
+
status: answer.status,
|
|
425
|
+
event: jsonBody('POST', path, answer),
|
|
426
|
+
text: answer.text,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* GET /api/agent/{id} — one session's `{status, pending_input, …}`, or null
|
|
432
|
+
* when there is none.
|
|
433
|
+
*/
|
|
434
|
+
async getAgentSession(id) {
|
|
435
|
+
const path = `/api/agent/${encodeURIComponent(id)}`;
|
|
436
|
+
const res = await this.#request('GET', path);
|
|
437
|
+
if (res.status === 404) return null;
|
|
438
|
+
if (!res.ok) throw failure('GET', path, res);
|
|
439
|
+
return jsonBody('GET', path, res);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* #request sends a request and reads its whole answer. It throws only when
|
|
444
|
+
* there is no answer at all; every status comes back to the caller, which
|
|
445
|
+
* knows what a 404 or a 422 means for its route.
|
|
446
|
+
*/
|
|
447
|
+
async #request(method, path, body) {
|
|
448
|
+
return this.#read(await this.#send(method, path, body));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* #send is the one place a request leaves this CLI, returning the fetch
|
|
453
|
+
* Response unread so a stream can be read as it arrives.
|
|
454
|
+
*
|
|
455
|
+
* A redirect is not followed. Juno's API never redirects, so a 3xx comes
|
|
456
|
+
* from something in front of it, typically an auth proxy sending the
|
|
457
|
+
* request to its sign-in page. Following it would hand the caller that
|
|
458
|
+
* page's HTML as a 200.
|
|
459
|
+
*/
|
|
460
|
+
async #send(method, path, body) {
|
|
53
461
|
const url = `${this.target}${path}`;
|
|
462
|
+
// Every request carries the header, the existing commands' included:
|
|
463
|
+
// one client, one header.
|
|
464
|
+
const headers = { 'X-Juno-Client': this.client };
|
|
465
|
+
if (body) headers['content-type'] = 'application/json';
|
|
54
466
|
let res;
|
|
55
467
|
try {
|
|
56
468
|
res = await this.fetch(url, {
|
|
57
469
|
method,
|
|
58
|
-
headers
|
|
470
|
+
headers,
|
|
59
471
|
body: body ? JSON.stringify(body) : undefined,
|
|
472
|
+
redirect: 'manual',
|
|
60
473
|
});
|
|
61
474
|
} catch (err) {
|
|
62
|
-
throw new
|
|
475
|
+
throw new UnreachableError(
|
|
63
476
|
`cannot reach Juno at ${this.target}: ${err.message}`
|
|
64
477
|
);
|
|
65
478
|
}
|
|
479
|
+
if (res.status >= 300 && res.status < 400) {
|
|
480
|
+
const location = res.headers?.get?.('location');
|
|
481
|
+
throw new UserError(
|
|
482
|
+
`${method} ${path} was redirected (${res.status})` +
|
|
483
|
+
(location ? ` to ${location}` : '') +
|
|
484
|
+
`: ${this.target} looks like it is behind an auth proxy or is ` +
|
|
485
|
+
'not the Juno API. Point the CLI at an address that reaches Juno ' +
|
|
486
|
+
'directly with --url or $JUNO_URL (inside a working copy, ' +
|
|
487
|
+
"package.json's juno.target is used otherwise)"
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return res;
|
|
491
|
+
}
|
|
66
492
|
|
|
67
|
-
|
|
493
|
+
async #read(res) {
|
|
494
|
+
let text;
|
|
495
|
+
try {
|
|
496
|
+
text = await res.text();
|
|
497
|
+
} catch (err) {
|
|
498
|
+
throw new UnreachableError(
|
|
499
|
+
`lost the connection to Juno at ${this.target}: ${err.message}`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
68
502
|
let parsed = null;
|
|
69
503
|
try {
|
|
70
504
|
parsed = text ? JSON.parse(text) : null;
|
|
71
505
|
} catch {
|
|
72
|
-
// A non-JSON body from a proxy or a wrong URL; reported
|
|
506
|
+
// A non-JSON body from a proxy or a wrong URL; reported as-is.
|
|
73
507
|
}
|
|
508
|
+
return {
|
|
509
|
+
ok: res.ok,
|
|
510
|
+
status: res.status,
|
|
511
|
+
statusText: res.statusText,
|
|
512
|
+
text,
|
|
513
|
+
parsed,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
}
|
|
74
517
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
518
|
+
/**
|
|
519
|
+
* ndjson reads a response body one line at a time, yielding each parsed line
|
|
520
|
+
* with its exact text. A connection that drops mid-stream is an
|
|
521
|
+
* UnreachableError, like one that never connected: either way there is no
|
|
522
|
+
* answer, and a retry that reuses the message id is safe.
|
|
523
|
+
*/
|
|
524
|
+
async function* ndjson(body, target) {
|
|
525
|
+
const decoder = new TextDecoder();
|
|
526
|
+
let buffered = '';
|
|
527
|
+
const lines = function* (final) {
|
|
528
|
+
let nl;
|
|
529
|
+
while ((nl = buffered.indexOf('\n')) >= 0 || (final && buffered)) {
|
|
530
|
+
const text = nl >= 0 ? buffered.slice(0, nl) : buffered;
|
|
531
|
+
buffered = nl >= 0 ? buffered.slice(nl + 1) : '';
|
|
532
|
+
if (!text.trim()) continue;
|
|
533
|
+
let event;
|
|
534
|
+
try {
|
|
535
|
+
event = JSON.parse(text);
|
|
536
|
+
} catch {
|
|
537
|
+
throw new UserError(
|
|
538
|
+
`the prompt stream sent a line that is not JSON: ${text}`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
yield { event, text };
|
|
84
542
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
543
|
+
};
|
|
544
|
+
try {
|
|
545
|
+
for await (const chunk of body) {
|
|
546
|
+
buffered += decoder.decode(chunk, { stream: true });
|
|
547
|
+
yield* lines(false);
|
|
548
|
+
}
|
|
549
|
+
} catch (err) {
|
|
550
|
+
if (err instanceof UserError) throw err;
|
|
551
|
+
throw new UnreachableError(
|
|
552
|
+
`lost the connection to Juno at ${target}: ${err.message}`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
buffered += decoder.decode();
|
|
556
|
+
yield* lines(true);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* jsonBody is a 2xx answer's parsed body, which every route this client calls
|
|
561
|
+
* answers as a JSON object. Anything else came from something other than
|
|
562
|
+
* Juno, such as a proxy's HTML page or a wrong URL. It is an error, never an
|
|
563
|
+
* empty success, because a caller reading fields off null either crashes or
|
|
564
|
+
* quietly does nothing.
|
|
565
|
+
*/
|
|
566
|
+
function jsonBody(method, path, res) {
|
|
567
|
+
if (res.parsed === null || typeof res.parsed !== 'object') {
|
|
568
|
+
throw new UserError(
|
|
569
|
+
`${method} ${path} answered something that is not JSON: ${res.text.trim()}`
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
return res.parsed;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The server's own words in an error answer: Huma's `detail`, else the body. */
|
|
576
|
+
function problemDetail(res) {
|
|
577
|
+
return (
|
|
578
|
+
res.parsed?.detail || res.parsed?.title || res.text.trim() || res.statusText
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** A UserError for a non-ok answer, carrying the server's own words. */
|
|
583
|
+
function failure(method, path, res) {
|
|
584
|
+
return new UserError(
|
|
585
|
+
`${method} ${path} failed (${res.status}): ${problemDetail(res)}`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The words of a 422: the gateway's own detail names the failing field (`/id`,
|
|
591
|
+
* `/ops/0/when`, `/` for the root); Huma's request validation puts it in
|
|
592
|
+
* `errors[]` instead, so those are appended.
|
|
593
|
+
*/
|
|
594
|
+
function validationDetail(res) {
|
|
595
|
+
const parts = [res.parsed?.detail || res.text.trim() || res.statusText];
|
|
596
|
+
for (const e of res.parsed?.errors ?? []) {
|
|
597
|
+
parts.push(e.location ? `${e.location}: ${e.message}` : e.message);
|
|
88
598
|
}
|
|
599
|
+
return parts.filter(Boolean).join('; ');
|
|
89
600
|
}
|