@standards/cli 1.0.0-alpha.419 → 1.0.0-alpha.421
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/dist/bin.mjs +1850 -386
- package/guide/agents.md +162 -0
- package/guide/core.md +88 -0
- package/guide/records.md +100 -0
- package/guide/schema-and-bundles.md +94 -0
- package/package.json +8 -5
package/dist/bin.mjs
CHANGED
|
@@ -4,30 +4,57 @@ import { t as discoverBundles } from "./discover-bundles-DPpEZVv0.mjs";
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { styleText } from "node:util";
|
|
6
6
|
import { BundleConflictError, StandardsRequestError, createTransport, syncBundle } from "@standards/client";
|
|
7
|
-
import { Command } from "commander";
|
|
8
|
-
import { BUNDLE_LOCALES, COUNT_MODES,
|
|
9
|
-
import Table from "cli-table3";
|
|
10
|
-
import { createAppClient } from "@standards/client/app";
|
|
7
|
+
import { Command, Option } from "commander";
|
|
8
|
+
import { BUNDLE_LOCALES, COUNT_MODES, DECISION_TYPE_AGENT_SETUP, DECISION_TYPE_BUNDLE_SETUP, ENVIRONMENT_HARNESSES, ENVIRONMENT_IMAGES, ENVIRONMENT_SIZES, LINK_MAX_MS, NotFoundError, PROVIDER_NAMES, RECORD_ACCESS_ROLES, RESOURCE_VISIBILITIES, SchemaError, SchemaErrorCode, ValidationError, canonicalStringify, diffSchemaState, environmentHarness, formatByteSize, formatDecisionLink, isEnvironmentHarnessId, isPlainRecord, parseRecordAccessRole, parseSchemaState, parseSkillSource, schemaStateSourcePayload } from "@standards/schema";
|
|
11
9
|
import { createWriteStream } from "node:fs";
|
|
12
|
-
import { chmod, mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { chmod, lstat, mkdir, readFile, readdir, readlink, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
13
11
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
14
|
-
import
|
|
12
|
+
import Table from "cli-table3";
|
|
13
|
+
import { StandardsRequestError as StandardsRequestError$1, TURN_END_TYPES, createAppClient, openAgentStream } from "@standards/client/app";
|
|
14
|
+
import { homedir, hostname } from "node:os";
|
|
15
15
|
import { Readable } from "node:stream";
|
|
16
16
|
import { pipeline } from "node:stream/promises";
|
|
17
17
|
import { buildOperationPath, recordsOperations } from "@standards/schema/api";
|
|
18
|
-
import { stdin
|
|
18
|
+
import { stdin } from "node:process";
|
|
19
19
|
import { createInterface } from "node:readline/promises";
|
|
20
|
-
import {
|
|
21
|
-
import
|
|
20
|
+
import { CLI_CLIENT_ID, CLI_DEVICE_LABEL_MAX, CLI_LOGIN_SCOPE, isSafeSkillFilePath, skillFileText, slugify } from "@standards/constants";
|
|
21
|
+
import * as oauth from "oauth4webapi";
|
|
22
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
23
|
+
// src/discovery/order-bundles.ts
|
|
24
|
+
/**
|
|
25
|
+
* Orders discovered bundles so each comes after the bundles it `uses`.
|
|
26
|
+
* Bundles a `uses` names that were not discovered are already the host's concern.
|
|
27
|
+
*/
|
|
28
|
+
async function orderByUses(discovered) {
|
|
29
|
+
const byName = new Map(discovered.map((entry) => [entry.bundle.name, entry]));
|
|
30
|
+
const ordered = [];
|
|
31
|
+
const visited = /* @__PURE__ */ new Set();
|
|
32
|
+
async function visit(entry) {
|
|
33
|
+
if (visited.has(entry.bundle.name)) return;
|
|
34
|
+
visited.add(entry.bundle.name);
|
|
35
|
+
for (const used of (await entry.bundle.build()).uses) {
|
|
36
|
+
const dependency = byName.get(used);
|
|
37
|
+
if (dependency) await visit(dependency);
|
|
38
|
+
}
|
|
39
|
+
ordered.push(entry);
|
|
40
|
+
}
|
|
41
|
+
for (const entry of discovered) await visit(entry);
|
|
42
|
+
return ordered;
|
|
43
|
+
}
|
|
22
44
|
// src/output.ts
|
|
23
45
|
const MAX_CELL_WIDTH = 50;
|
|
24
46
|
function truncate(value, maxLength) {
|
|
25
47
|
if (value.length <= maxLength) return value;
|
|
26
48
|
return `${value.slice(0, maxLength - 1)}…`;
|
|
27
49
|
}
|
|
28
|
-
|
|
50
|
+
/** A cell's text: a decoded `Date` as the ISO string the server sent, an object as JSON. */
|
|
51
|
+
function cellText(value) {
|
|
29
52
|
if (value === null || value === void 0) return "";
|
|
30
|
-
|
|
53
|
+
if (value instanceof Date) return value.toISOString();
|
|
54
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
55
|
+
}
|
|
56
|
+
function escapeCsvValue(value) {
|
|
57
|
+
const str = cellText(value);
|
|
31
58
|
if (str.includes(",") || str.includes("\"") || str.includes("\n")) return `"${str.replace(/"/g, "\"\"")}"`;
|
|
32
59
|
return str;
|
|
33
60
|
}
|
|
@@ -50,11 +77,7 @@ function formatTable(items) {
|
|
|
50
77
|
}
|
|
51
78
|
});
|
|
52
79
|
for (const item of items) {
|
|
53
|
-
const row = headers.map((h) =>
|
|
54
|
-
const val = item[h];
|
|
55
|
-
if (val === null || val === void 0) return "";
|
|
56
|
-
return truncate(typeof val === "object" ? JSON.stringify(val) : String(val), MAX_CELL_WIDTH);
|
|
57
|
-
});
|
|
80
|
+
const row = headers.map((h) => truncate(cellText(item[h]), MAX_CELL_WIDTH));
|
|
58
81
|
table.push(row);
|
|
59
82
|
}
|
|
60
83
|
return table.toString();
|
|
@@ -110,9 +133,41 @@ function parseCount(flag, value) {
|
|
|
110
133
|
if (!Number.isInteger(parsed) || parsed < 0) throw new ValidationError(`${flag} must be a non-negative integer`, []);
|
|
111
134
|
return parsed;
|
|
112
135
|
}
|
|
136
|
+
/** A `<n><unit>` duration in milliseconds; an unknown unit or one outside `[min, max]` is refused. */
|
|
137
|
+
function parseDuration(flag, value, rules) {
|
|
138
|
+
const { units, min = 1, max = Number.POSITIVE_INFINITY } = rules;
|
|
139
|
+
const match = new RegExp(`^(\\d+)(${Object.keys(units).join("|")})$`).exec(value.trim());
|
|
140
|
+
const unit = match?.[2] === void 0 ? void 0 : units[match[2]];
|
|
141
|
+
const ms = match && unit !== void 0 ? Number(match[1]) * unit : NaN;
|
|
142
|
+
if (!(ms >= min && ms <= max)) throw new ValidationError(`${flag} takes a duration such as ${rules.example}, ${rules.limit}: got "${value}"`, []);
|
|
143
|
+
return ms;
|
|
144
|
+
}
|
|
145
|
+
/** The `--harness` value that names the built-in agent: no harness at all. */
|
|
146
|
+
const STANDARDS_HARNESS = "standards";
|
|
147
|
+
/** `--harness` ids: catalogue ids, or `standards` alone (the built-in agent), which is `null`. */
|
|
148
|
+
function parseHarnessIds(ids) {
|
|
149
|
+
if (ids.length === 1 && ids[0] === "standards") return null;
|
|
150
|
+
const unknown = ids.filter((id) => !isEnvironmentHarnessId(id));
|
|
151
|
+
if (ids.length === 0 || unknown.length > 0) {
|
|
152
|
+
const known = [...ENVIRONMENT_HARNESSES.map((harness) => harness.id), STANDARDS_HARNESS];
|
|
153
|
+
throw new ValidationError(`--harness takes ${known.join(", ")}${unknown.length > 0 ? `: got "${unknown.join(", ")}"` : ""}`, []);
|
|
154
|
+
}
|
|
155
|
+
return ids.filter(isEnvironmentHarnessId);
|
|
156
|
+
}
|
|
157
|
+
/** Every item of a cursor-paged read, following `nextCursor` to the end. */
|
|
158
|
+
async function readAllPages(read) {
|
|
159
|
+
const items = [];
|
|
160
|
+
let cursor;
|
|
161
|
+
do {
|
|
162
|
+
const page = await read(cursor);
|
|
163
|
+
items.push(...page.items);
|
|
164
|
+
cursor = page.nextCursor ?? void 0;
|
|
165
|
+
} while (cursor);
|
|
166
|
+
return items;
|
|
167
|
+
}
|
|
113
168
|
function getClientFromCommand(cmd) {
|
|
114
169
|
const root = getGlobalOptions(cmd);
|
|
115
|
-
if (!(root.apiUrl && root.apiKey)) throw new
|
|
170
|
+
if (!(root.apiUrl && root.apiKey)) throw new ValidationError("No Standards instance configured. Run \"standards login\" or pass --api-key.", []);
|
|
116
171
|
return createClient({
|
|
117
172
|
apiUrl: root.apiUrl,
|
|
118
173
|
apiKey: root.apiKey,
|
|
@@ -124,99 +179,80 @@ function getAppClientFromCommand(cmd) {
|
|
|
124
179
|
return createAppClient(getClientFromCommand(cmd));
|
|
125
180
|
}
|
|
126
181
|
/**
|
|
127
|
-
* The `--tenant` guard every admin command shares. The API takes the tenant in
|
|
128
|
-
* the body as a confirmation of intent, never as a target selector: it refuses
|
|
129
|
-
* any value other than the authenticated tenant. The CLI cannot invent it, so
|
|
130
|
-
* it asks for it rather than guessing the primary tenant.
|
|
131
|
-
*
|
|
132
|
-
* `message` is passed whole rather than composed: each command's exact wording
|
|
133
|
-
* is what its users (and its tests) read.
|
|
134
|
-
*/
|
|
135
|
-
function requireTenant(cmd, message) {
|
|
136
|
-
const { tenant } = getGlobalOptions(cmd);
|
|
137
|
-
if (!tenant) throw new Error(message);
|
|
138
|
-
return tenant;
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
182
|
* The `--yes` guard for a destructive or outbound command: shared rather than
|
|
142
183
|
* inlined so the refusal is a typed `ValidationError`, not a raw `Error`. The
|
|
143
184
|
* commands that still inline their own check belong here when they move.
|
|
144
185
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
186
|
+
* `message` is passed whole rather than composed: each command's exact
|
|
187
|
+
* wording is what its users (and its tests) read.
|
|
147
188
|
*/
|
|
148
189
|
function requireConfirmation(yes, message) {
|
|
149
190
|
if (!yes) throw new ValidationError(message, []);
|
|
150
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* A page of the web app, on the API's origin: a profile's API URL is
|
|
194
|
+
* `https://<host>/v1`, and the app is served at `https://<host>`.
|
|
195
|
+
*/
|
|
196
|
+
function appUrl(cmd, path = "/") {
|
|
197
|
+
const { apiUrl } = getGlobalOptions(cmd);
|
|
198
|
+
if (!apiUrl) throw new ValidationError("No Standards instance configured. Run \"standards login\" or pass --api-url.", []);
|
|
199
|
+
return new URL(path, new URL(apiUrl).origin).toString();
|
|
200
|
+
}
|
|
201
|
+
/** A conversation's page in the app: the chat router's `/chat/<id>`. */
|
|
202
|
+
function conversationUrl(cmd, sessionId) {
|
|
203
|
+
return appUrl(cmd, `/chat/${encodeURIComponent(sessionId)}`);
|
|
204
|
+
}
|
|
205
|
+
/** A command's success line on stdout: a green check, then `message`. */
|
|
206
|
+
function printDone(message) {
|
|
207
|
+
process.stdout.write(`${styleText("green", "✓")} ${message}\n`);
|
|
208
|
+
}
|
|
151
209
|
/** Renders any caught value as a message string — shared by every command's top-level `catch`. */
|
|
152
210
|
function messageOf(error) {
|
|
153
211
|
return error instanceof Error ? error.message : String(error);
|
|
154
212
|
}
|
|
155
|
-
// src/commands/ai.ts
|
|
156
|
-
const SCOPES = ["me", "workspace"];
|
|
157
|
-
/** `GET /ai/usage` reads inclusive UTC days; anything else reaches the API as a 400. */
|
|
158
|
-
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/u;
|
|
159
|
-
function parseDay(flag, value) {
|
|
160
|
-
if (!ISO_DAY.test(value)) throw new ValidationError(`${flag} must be a YYYY-MM-DD date`, []);
|
|
161
|
-
return value;
|
|
162
|
-
}
|
|
163
213
|
/**
|
|
164
|
-
* The
|
|
165
|
-
*
|
|
214
|
+
* The one name lookup of the CLI: the single item whose `field` equals `name`.
|
|
215
|
+
* None → `NotFoundError` (`No <noun> named "x"`); several → `ValidationError`
|
|
216
|
+
* listing them, so the caller passes an id instead of the CLI guessing.
|
|
166
217
|
*/
|
|
167
|
-
function
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
});
|
|
181
|
-
ai.command("wallet").description("Show the tenant's billing mode and AI credit state").action(async (_opts, cmd) => {
|
|
182
|
-
formatOutput(await getAppClientFromCommand(cmd).ai.wallet({}), getFormat(cmd));
|
|
183
|
-
});
|
|
218
|
+
function findNamed(items, field, name, noun, describe = (item) => item.id) {
|
|
219
|
+
const matches = items.filter((item) => item[field] === name);
|
|
220
|
+
const [match] = matches;
|
|
221
|
+
if (!match) throw new NotFoundError(noun, name, void 0, `No ${noun} named "${name}"`);
|
|
222
|
+
if (matches.length > 1) throw new ValidationError(`${matches.length} ${noun}s are named "${name}": ${matches.map(describe).join(", ")}. Pass the id.`, []);
|
|
223
|
+
return match;
|
|
224
|
+
}
|
|
225
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
226
|
+
/** An environment's id from its id or its name (`findNamed` over `environments.list`). */
|
|
227
|
+
async function resolveEnvironmentId(client, nameOrId) {
|
|
228
|
+
if (UUID.test(nameOrId)) return nameOrId;
|
|
229
|
+
const { environments } = await client.environments.list({});
|
|
230
|
+
return findNamed(environments, "name", nameOrId, "environment", (env) => `${env.id} (${env.ownedBy})`).id;
|
|
184
231
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
write(` API Key: ${root.apiKey ? `${root.apiKey.slice(0, 8)}...` : styleText("red", "not set")}`);
|
|
194
|
-
write("");
|
|
195
|
-
const keys = await client.apiKeys.list({});
|
|
196
|
-
write(styleText("green", "✓ API key is valid"));
|
|
197
|
-
if (Array.isArray(keys) && keys.length > 0) write(` Active keys: ${keys.filter((key) => !key.revokedAt).length}`);
|
|
198
|
-
});
|
|
232
|
+
/**
|
|
233
|
+
* Whether a streaming or overview command writes for a person: `--format` was
|
|
234
|
+
* left at its default. An explicit `--format json` asks for machine output.
|
|
235
|
+
*/
|
|
236
|
+
function isHumanOutput(cmd) {
|
|
237
|
+
let root = cmd;
|
|
238
|
+
while (root.parent) root = root.parent;
|
|
239
|
+
return root.getOptionValueSource("format") === "default";
|
|
199
240
|
}
|
|
200
|
-
// src/discovery/order-bundles.ts
|
|
201
241
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
242
|
+
* A secret's value, from piped stdin only: a TTY is refused (no masked prompt
|
|
243
|
+
* exists, and a visible one would leak to scrollback), argv is never read.
|
|
204
244
|
*/
|
|
205
|
-
async function
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
ordered.push(entry);
|
|
217
|
-
}
|
|
218
|
-
for (const entry of discovered) await visit(entry);
|
|
219
|
-
return ordered;
|
|
245
|
+
async function readPipedValue(command) {
|
|
246
|
+
if (process.stdin.isTTY) throw new ValidationError(`Pipe the value: printf %s "$VALUE" | standards ${command}`, []);
|
|
247
|
+
const chunks = [];
|
|
248
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
249
|
+
const value = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
250
|
+
if (!value) throw new ValidationError("The piped value is empty", []);
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
/** Whether a person sits at this terminal: both stdin and stdout are TTYs. Agents and CI are not. */
|
|
254
|
+
function isInteractive() {
|
|
255
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
220
256
|
}
|
|
221
257
|
// src/commands/bundles.ts
|
|
222
258
|
const HASH_PREFIX_LENGTH = 7;
|
|
@@ -270,6 +306,15 @@ function collectDecision(value, decisions) {
|
|
|
270
306
|
function collectPieceId(value, pieceIds) {
|
|
271
307
|
return [...pieceIds, value];
|
|
272
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* An add or update applies exactly the plan a `--dry-run` printed: its
|
|
311
|
+
* fingerprint is required, checked here so the refusal is typed and nothing
|
|
312
|
+
* reaches the API.
|
|
313
|
+
*/
|
|
314
|
+
function requireFingerprint(fingerprint, dryRun) {
|
|
315
|
+
if (fingerprint) return fingerprint;
|
|
316
|
+
throw new ValidationError(`Pass the fingerprint of the plan to apply: run "${dryRun}" first, then "--fingerprint <fingerprint>".`, []);
|
|
317
|
+
}
|
|
273
318
|
/** The input `preview` and `add` share: whole bundles, in one locale. */
|
|
274
319
|
function addInput(names, opts) {
|
|
275
320
|
return {
|
|
@@ -285,8 +330,8 @@ function withAddOptions(command) {
|
|
|
285
330
|
/**
|
|
286
331
|
* The workspace's bundles: sync the ones the project declares, list what is
|
|
287
332
|
* installed and where each stands, remove one, add a bundle the workspace may
|
|
288
|
-
* add as a copy it owns (`
|
|
289
|
-
* and update such a copy (`update-
|
|
333
|
+
* add as a copy it owns (`add --dry-run`, then `add` with the plan's fingerprint)
|
|
334
|
+
* and update such a copy (`update --dry-run`, then `update` with its fingerprint).
|
|
290
335
|
*/
|
|
291
336
|
function registerBundlesCommand(program) {
|
|
292
337
|
const bundles = program.command("bundles").description("Sync and manage the workspace's bundles");
|
|
@@ -308,29 +353,35 @@ function registerBundlesCommand(program) {
|
|
|
308
353
|
const { available } = await getAppClientFromCommand(cmd).bundles.list({ locale: parseLocale(opts.locale) });
|
|
309
354
|
formatOutput(available, getFormat(cmd));
|
|
310
355
|
}));
|
|
311
|
-
withAddOptions(bundles.command("
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}));
|
|
321
|
-
bundles.command("update-preview").description("Plan an added bundle's update, piece by piece, and the plan's fingerprint").argument("<name>", "bundle name").action((name, _opts, cmd) => run(async () => {
|
|
322
|
-
formatOutput(await getAppClientFromCommand(cmd).bundles.updatePreview({ name }), getFormat(cmd));
|
|
323
|
-
}));
|
|
324
|
-
bundles.command("update").description("Update an added bundle as its preview showed; nothing is removed").argument("<name>", "bundle name").requiredOption("--fingerprint <fingerprint>", "the fingerprint `bundles update-preview` returned").option("--take <pieceId>", "take the new version of a piece you changed (repeatable)", collectPieceId, []).option("--skip-missing <pieceId>", "do not reinstall a missing piece (repeatable)", collectPieceId, []).option("--decision <pieceId=choice>", "how to settle a collision: bind, skip or rename:<name> (repeatable)", collectDecision, {}).action((name, opts, cmd) => run(async () => {
|
|
325
|
-
const { applied } = await getAppClientFromCommand(cmd).bundles.update({
|
|
326
|
-
name,
|
|
327
|
-
fingerprint: opts.fingerprint,
|
|
328
|
-
take: opts.take,
|
|
329
|
-
skipMissing: opts.skipMissing,
|
|
330
|
-
decisions: opts.decision
|
|
356
|
+
withAddOptions(bundles.command("add").description("Add bundles as copies the workspace owns (--dry-run prints the plan)").option("--dry-run", "print what is new, what collides, and the plan's fingerprint").option("--fingerprint <fingerprint>", "the fingerprint `bundles add --dry-run` printed").option("--decision <pieceId=choice>", "how to settle a collision: bind, skip or rename:<name> (repeatable)", collectDecision, {})).action((names, opts, cmd) => {
|
|
357
|
+
const fingerprint = opts.dryRun ? void 0 : requireFingerprint(opts.fingerprint, "bundles add --dry-run");
|
|
358
|
+
return run(async () => {
|
|
359
|
+
const client = getAppClientFromCommand(cmd);
|
|
360
|
+
formatOutput(fingerprint === void 0 ? await client.bundles.preview(addInput(names, opts)) : await client.bundles.add({
|
|
361
|
+
...addInput(names, opts),
|
|
362
|
+
decisions: opts.decision,
|
|
363
|
+
fingerprint
|
|
364
|
+
}), getFormat(cmd));
|
|
331
365
|
});
|
|
332
|
-
|
|
333
|
-
}))
|
|
366
|
+
});
|
|
367
|
+
bundles.command("update").description("Update an added bundle as its plan showed; nothing is removed (--dry-run prints the plan)").argument("<name>", "bundle name").option("--dry-run", "print the update plan, piece by piece, and its fingerprint").option("--fingerprint <fingerprint>", "the fingerprint `bundles update --dry-run` printed").option("--take <pieceId>", "take the new version of a piece you changed (repeatable)", collectPieceId, []).option("--skip-missing <pieceId>", "do not reinstall a missing piece (repeatable)", collectPieceId, []).option("--decision <pieceId=choice>", "how to settle a collision: bind, skip or rename:<name> (repeatable)", collectDecision, {}).action((name, opts, cmd) => {
|
|
368
|
+
const fingerprint = opts.dryRun ? void 0 : requireFingerprint(opts.fingerprint, `bundles update ${name} --dry-run`);
|
|
369
|
+
return run(async () => {
|
|
370
|
+
const client = getAppClientFromCommand(cmd);
|
|
371
|
+
if (fingerprint === void 0) {
|
|
372
|
+
formatOutput(await client.bundles.updatePreview({ name }), getFormat(cmd));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const { applied } = await client.bundles.update({
|
|
376
|
+
name,
|
|
377
|
+
fingerprint,
|
|
378
|
+
take: opts.take,
|
|
379
|
+
skipMissing: opts.skipMissing,
|
|
380
|
+
decisions: opts.decision
|
|
381
|
+
});
|
|
382
|
+
console.info(`${name} updated, ${applied} applied`);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
334
385
|
bundles.command("remove").description("Remove a synced bundle; its pieces are demoted, never deleted. An added bundle is refused: uninstall it from the Bundles window").argument("<name>", "bundle name").option("--yes", "remove without confirmation").action((name, opts, cmd) => run(async () => {
|
|
335
386
|
requireConfirmation(opts.yes, `Removing bundle "${name}" demotes its objects to workspace-owned. Re-run with "--yes" to confirm.`);
|
|
336
387
|
await getAppClientFromCommand(cmd).bundles.remove({ name });
|
|
@@ -405,50 +456,210 @@ function registerConnectorsCommand(program) {
|
|
|
405
456
|
id,
|
|
406
457
|
...opts.removeImported ? { removeImported: "true" } : {}
|
|
407
458
|
});
|
|
408
|
-
|
|
459
|
+
printDone(`Connector connection ${id} disconnected.`);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
// src/commands/decisions.ts
|
|
463
|
+
function isOption(value) {
|
|
464
|
+
return isPlainRecord(value) && typeof value.value === "string" && typeof value.label === "string";
|
|
465
|
+
}
|
|
466
|
+
function questionsOf(decision) {
|
|
467
|
+
const raw = decision.questions?.questions;
|
|
468
|
+
if (!Array.isArray(raw)) return [];
|
|
469
|
+
return raw.flatMap((entry) => {
|
|
470
|
+
if (!(isPlainRecord(entry) && typeof entry.id === "string")) return [];
|
|
471
|
+
return [{
|
|
472
|
+
id: entry.id,
|
|
473
|
+
question: typeof entry.question === "string" ? entry.question : "",
|
|
474
|
+
multiSelect: entry.multiSelect === true,
|
|
475
|
+
options: Array.isArray(entry.options) ? entry.options.filter(isOption) : []
|
|
476
|
+
}];
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
function setupVariablesOf(decision) {
|
|
480
|
+
if (decision.type !== DECISION_TYPE_AGENT_SETUP) return [];
|
|
481
|
+
const raw = decision.questions?.items;
|
|
482
|
+
if (!Array.isArray(raw)) return [];
|
|
483
|
+
return raw.flatMap((item) => {
|
|
484
|
+
if (!(isPlainRecord(item) && item.kind === "env_var" && typeof item.key === "string")) return [];
|
|
485
|
+
const scope = item.scope === "agent" || item.scope === "workspace" ? item.scope : void 0;
|
|
486
|
+
return [{
|
|
487
|
+
key: item.key,
|
|
488
|
+
label: typeof item.label === "string" ? item.label : item.key,
|
|
489
|
+
scope
|
|
490
|
+
}];
|
|
409
491
|
});
|
|
410
492
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
493
|
+
/**
|
|
494
|
+
* The command that sets one variable a setup decision asks for: a workspace
|
|
495
|
+
* variable reaches every environment, any other is the caller's own.
|
|
496
|
+
*/
|
|
497
|
+
function secretCommand(variable) {
|
|
498
|
+
const scope = variable.scope === "workspace" ? "--environments all" : "--private";
|
|
499
|
+
return `printf %s "$VALUE" | standards secrets set ${variable.key} ${scope}`;
|
|
500
|
+
}
|
|
501
|
+
function numberedOptions(question) {
|
|
502
|
+
return question.options.map((option, index) => `${index + 1}. ${option.label}`);
|
|
503
|
+
}
|
|
504
|
+
/** The session a decision is about, when it is one's. */
|
|
505
|
+
function sessionIdOf(decision) {
|
|
506
|
+
return decision.links.find((link) => link.targetType === "session")?.targetId;
|
|
507
|
+
}
|
|
508
|
+
/** Where the app shows a decision: its conversation, or the app's home (the decisions inbox). */
|
|
509
|
+
function decisionUrl(cmd, decision) {
|
|
510
|
+
const sessionId = sessionIdOf(decision);
|
|
511
|
+
return sessionId ? conversationUrl(cmd, sessionId) : appUrl(cmd);
|
|
512
|
+
}
|
|
513
|
+
/** A decision as one block of text: title, why, change, numbered options, setup commands. */
|
|
514
|
+
function renderDecision(decision) {
|
|
515
|
+
const lines = [`${decision.title} (${decision.type}, ${decision.id})`];
|
|
516
|
+
if (decision.why) lines.push(` why: ${decision.why}`);
|
|
517
|
+
if (decision.change) lines.push(` change: ${decision.change}`);
|
|
518
|
+
for (const [index, question] of questionsOf(decision).entries()) {
|
|
519
|
+
const multi = question.multiSelect ? " (several: 1,3)" : "";
|
|
520
|
+
lines.push(` Q${index + 1}. ${question.question}${multi}`);
|
|
521
|
+
for (const option of numberedOptions(question)) lines.push(` ${option}`);
|
|
522
|
+
}
|
|
523
|
+
for (const variable of setupVariablesOf(decision)) lines.push(` ${variable.label}: ${secretCommand(variable)}`);
|
|
524
|
+
if (decision.links.length > 0) lines.push(` links: ${decision.links.map(formatDecisionLink).join(", ")}`);
|
|
525
|
+
lines.push(` answer: standards decisions answer ${decision.id} …`);
|
|
526
|
+
return `${lines.join("\n")}\n`;
|
|
416
527
|
}
|
|
417
|
-
function
|
|
418
|
-
return
|
|
528
|
+
function summarizeDecision$1(decision) {
|
|
529
|
+
return {
|
|
530
|
+
id: decision.id,
|
|
531
|
+
type: decision.type,
|
|
532
|
+
kind: decision.kind,
|
|
533
|
+
title: decision.title,
|
|
534
|
+
sender: decision.sender?.label ?? null,
|
|
535
|
+
questions: questionsOf(decision).map((question) => ({
|
|
536
|
+
id: question.id,
|
|
537
|
+
question: question.question,
|
|
538
|
+
...question.multiSelect && { multiSelect: true },
|
|
539
|
+
options: numberedOptions(question)
|
|
540
|
+
}))
|
|
541
|
+
};
|
|
419
542
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
543
|
+
function describeDecision(cmd, decision) {
|
|
544
|
+
return {
|
|
545
|
+
...summarizeDecision$1(decision),
|
|
546
|
+
why: decision.why ?? null,
|
|
547
|
+
change: decision.change ?? null,
|
|
548
|
+
links: decision.links.map(formatDecisionLink),
|
|
549
|
+
setup: setupVariablesOf(decision).map((variable) => ({
|
|
550
|
+
key: variable.key,
|
|
551
|
+
label: variable.label,
|
|
552
|
+
command: secretCommand(variable)
|
|
553
|
+
})),
|
|
554
|
+
url: decisionUrl(cmd, decision)
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/** The decisions of one `decisions.list` state, every page; `undefined` is everything not archived. */
|
|
558
|
+
async function readDecisions(client, state, link) {
|
|
559
|
+
return (await readAllPages((cursor) => client.decisions.list({
|
|
560
|
+
...state && { state },
|
|
561
|
+
...link && { link },
|
|
562
|
+
...cursor && { cursor }
|
|
563
|
+
}))).map((item) => item.decision);
|
|
564
|
+
}
|
|
565
|
+
/** The caller's open decisions, every page; with `link` (`session:<id>`), only those about it. */
|
|
566
|
+
function readOpenDecisions(client, link) {
|
|
567
|
+
return readDecisions(client, "open", link);
|
|
568
|
+
}
|
|
569
|
+
/** No `decisions.get` route: the open decisions first, then every state, until the id shows. */
|
|
570
|
+
async function findDecision(client, id) {
|
|
571
|
+
for (const state of ["open", void 0]) {
|
|
572
|
+
const found = (await readDecisions(client, state)).find((decision) => decision.id === id);
|
|
573
|
+
if (found) return found;
|
|
574
|
+
}
|
|
575
|
+
throw new NotFoundError("decision", id, void 0, `No decision "${id}"`);
|
|
576
|
+
}
|
|
577
|
+
/** An option number or label (case-insensitive) as the option's value; anything else is free text. */
|
|
578
|
+
function optionValue(question, raw) {
|
|
579
|
+
const trimmed = raw.trim();
|
|
580
|
+
if (/^\d+$/.test(trimmed)) {
|
|
581
|
+
const option = question.options[Number(trimmed) - 1];
|
|
582
|
+
if (option) return option.value;
|
|
583
|
+
}
|
|
584
|
+
const lower = trimmed.toLowerCase();
|
|
585
|
+
return question.options.find((option) => option.label.toLowerCase() === lower || option.value.toLowerCase() === lower)?.value ?? raw;
|
|
586
|
+
}
|
|
587
|
+
function plural(count, noun) {
|
|
588
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
589
|
+
}
|
|
590
|
+
function oneWord(args, words) {
|
|
591
|
+
const [word, ...rest] = args;
|
|
592
|
+
if (word === void 0 || rest.length > 0 || !words.includes(word)) throw new ValidationError(`Answer with one of: ${words.join(", ")}`, []);
|
|
593
|
+
return word;
|
|
594
|
+
}
|
|
595
|
+
function questionAnswer(decision, args, flags) {
|
|
596
|
+
if (flags.skip) {
|
|
597
|
+
if (args.length > 0) throw new ValidationError("--skip takes no answer", []);
|
|
429
598
|
return {
|
|
430
|
-
|
|
431
|
-
|
|
599
|
+
kind: "answers",
|
|
600
|
+
skipped: true,
|
|
601
|
+
answers: {}
|
|
432
602
|
};
|
|
433
603
|
}
|
|
604
|
+
const questions = questionsOf(decision);
|
|
605
|
+
if (args.length !== questions.length) throw new ValidationError(`${plural(questions.length, "question")}, ${plural(args.length, "answer")}: pass one answer per question, in order`, []);
|
|
606
|
+
return {
|
|
607
|
+
kind: "answers",
|
|
608
|
+
skipped: false,
|
|
609
|
+
answers: Object.fromEntries(questions.map((question, index) => {
|
|
610
|
+
const raw = args[index] ?? "";
|
|
611
|
+
const value = question.multiSelect ? parseCsvList(raw).map((part) => optionValue(question, part)) : optionValue(question, raw);
|
|
612
|
+
return [question.id, value];
|
|
613
|
+
}))
|
|
614
|
+
};
|
|
434
615
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
616
|
+
/**
|
|
617
|
+
* The answer body by the decision's kind, as the app's card builds it. Kinds
|
|
618
|
+
* the CLI cannot answer (fyi, report, a connector to reconnect) are refused
|
|
619
|
+
* before any call, with where the app answers them.
|
|
620
|
+
*/
|
|
621
|
+
function answerFor(cmd, decision, args, flags) {
|
|
622
|
+
switch (decision.kind) {
|
|
623
|
+
case "approval": return {
|
|
624
|
+
kind: "approval",
|
|
625
|
+
approved: oneWord(args, ["yes", "no"]) === "yes",
|
|
626
|
+
...flags.reason && { reason: flags.reason }
|
|
627
|
+
};
|
|
628
|
+
case "question": return questionAnswer(decision, args, flags);
|
|
629
|
+
case "review": return {
|
|
630
|
+
kind: "review",
|
|
631
|
+
outcome: oneWord(args, ["yes", "changes"]) === "yes" ? "approved" : "changes",
|
|
632
|
+
...flags.feedback && { feedback: flags.feedback }
|
|
633
|
+
};
|
|
634
|
+
case "handoff":
|
|
635
|
+
if (decision.type === DECISION_TYPE_AGENT_SETUP) return {
|
|
636
|
+
kind: "setup",
|
|
637
|
+
action: oneWord(args, ["continue", "skip"]) === "continue" ? "continue" : "skip"
|
|
447
638
|
};
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
639
|
+
if (decision.type === DECISION_TYPE_BUNDLE_SETUP) {
|
|
640
|
+
oneWord(args, ["configure"]);
|
|
641
|
+
return { kind: "configure" };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
throw new ValidationError(`This decision is answered in the app: ${decisionUrl(cmd, decision)}`, []);
|
|
645
|
+
}
|
|
646
|
+
function registerDecisionsCommand(program) {
|
|
647
|
+
const decisions = program.command("decisions").description("List, read and answer the decisions agents wait on");
|
|
648
|
+
decisions.command("list").description("List open decisions, options numbered from 1").option("--all", "every decision not archived, answered ones included").action(async (opts, cmd) => {
|
|
649
|
+
formatOutput((await readDecisions(getAppClientFromCommand(cmd), opts.all ? void 0 : "open")).map(summarizeDecision$1), getFormat(cmd));
|
|
650
|
+
});
|
|
651
|
+
decisions.command("show").description("Show a decision: why, change, numbered options, links, setup commands").argument("<id>", "decision id").action(async (id, _opts, cmd) => {
|
|
652
|
+
formatOutput(describeDecision(cmd, await findDecision(getAppClientFromCommand(cmd), id)), getFormat(cmd));
|
|
653
|
+
});
|
|
654
|
+
decisions.command("answer").description("Answer a decision: yes/no, an option number or label per question, changes, continue/skip, configure").argument("<id>", "decision id").argument("[answer...]", "the answer, one per question for a question").option("--reason <reason>", "why, with an approval").option("--feedback <feedback>", "what to change, with a review's changes").option("--skip", "skip every question").action(async (id, args, flags, cmd) => {
|
|
655
|
+
const client = getAppClientFromCommand(cmd);
|
|
656
|
+
const decision = await findDecision(client, id);
|
|
657
|
+
const answer = answerFor(cmd, decision, args, flags);
|
|
658
|
+
await client.decisions.answer({
|
|
659
|
+
decisionId: decision.id,
|
|
660
|
+
answer
|
|
661
|
+
});
|
|
662
|
+
printDone(`Decision ${decision.id} answered.`);
|
|
452
663
|
});
|
|
453
664
|
}
|
|
454
665
|
// src/commands/documents.ts
|
|
@@ -524,33 +735,237 @@ function registerDocumentsCommand(program) {
|
|
|
524
735
|
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
525
736
|
});
|
|
526
737
|
}
|
|
527
|
-
// src/commands/
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
function
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
738
|
+
// src/commands/environments.ts
|
|
739
|
+
/** `--harness`: catalogue ids, or `standards` (no harness), which is `null`. */
|
|
740
|
+
function parseHarnesses(value) {
|
|
741
|
+
return parseHarnessIds(parseCsvList(value));
|
|
742
|
+
}
|
|
743
|
+
function collect(value, previous = []) {
|
|
744
|
+
return [...previous, value];
|
|
745
|
+
}
|
|
746
|
+
/** The body fields `create` and `update` share, from the same flags. */
|
|
747
|
+
function sharedFields(opts) {
|
|
748
|
+
if (opts.visibility === "workspace" && !opts.confirmShared) throw new ValidationError("A workspace environment shares its machines with every member: add --confirm-shared", []);
|
|
749
|
+
return {
|
|
750
|
+
...opts.name !== void 0 && { name: opts.name },
|
|
751
|
+
...opts.repo && { repositories: opts.repo.map((fullName) => ({ fullName })) },
|
|
752
|
+
...opts.size && { size: opts.size },
|
|
753
|
+
...opts.maxSize && { maxSize: opts.maxSize },
|
|
754
|
+
...opts.image && { image: opts.image },
|
|
755
|
+
...opts.visibility && { visibility: opts.visibility },
|
|
756
|
+
...opts.visibility === "workspace" && { confirmSharedMachine: true }
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/** The flags `create` and `update` share. */
|
|
760
|
+
function withEnvironmentFlags(command) {
|
|
761
|
+
return command.option("--repo <owner/name>", "a repository it clones (repeatable)", collect).addOption(new Option("--size <size>", "the size its machines start at").choices(ENVIRONMENT_SIZES)).addOption(new Option("--max-size <size>", "the largest size a run may ask for").choices(ENVIRONMENT_SIZES)).addOption(new Option("--image <image>", "its base image").choices(ENVIRONMENT_IMAGES)).addOption(new Option("--visibility <visibility>", "who may use it").choices(["private", "workspace"])).option("--confirm-shared", "confirm a workspace environment shares its machines");
|
|
762
|
+
}
|
|
763
|
+
function registerEnvironmentsCommand(program) {
|
|
764
|
+
const environments = program.command("environments").description("Manage the environments agent machines run in");
|
|
765
|
+
environments.command("list").description("List the defaults and the environments you can use").action(async (_opts, cmd) => {
|
|
766
|
+
formatOutput(await getAppClientFromCommand(cmd).environments.list({}), getFormat(cmd));
|
|
767
|
+
});
|
|
768
|
+
environments.command("get").description("Show an environment with its scripts and last build log").argument("<env>", "environment name or id").action(async (env, _opts, cmd) => {
|
|
769
|
+
const client = getAppClientFromCommand(cmd);
|
|
770
|
+
const id = await resolveEnvironmentId(client, env);
|
|
771
|
+
formatOutput(await client.environments.get({ id }), getFormat(cmd));
|
|
772
|
+
});
|
|
773
|
+
environments.command("harnesses").description("List the harnesses an environment can accept, with their models").action(async (_opts, cmd) => {
|
|
774
|
+
const client = getAppClientFromCommand(cmd);
|
|
775
|
+
formatOutput(await Promise.all(ENVIRONMENT_HARNESSES.map(async (harness) => ({
|
|
776
|
+
id: harness.id,
|
|
777
|
+
label: harness.label,
|
|
778
|
+
models: (await client.environments.harnessModels({ harness: harness.id })).models
|
|
779
|
+
}))), getFormat(cmd));
|
|
780
|
+
});
|
|
781
|
+
withEnvironmentFlags(environments.command("create").description("Create an environment").requiredOption("--name <name>", "its name").requiredOption("--harness <id,…|standards>", "the harnesses it accepts, or standards")).action(async (opts, cmd) => {
|
|
782
|
+
const body = {
|
|
783
|
+
...sharedFields(opts),
|
|
784
|
+
name: opts.name,
|
|
785
|
+
acceptedHarnesses: parseHarnesses(opts.harness)
|
|
786
|
+
};
|
|
787
|
+
formatOutput(await getAppClientFromCommand(cmd).environments.create(body), getFormat(cmd));
|
|
788
|
+
});
|
|
789
|
+
withEnvironmentFlags(environments.command("update").description("Update an environment").argument("<env>", "environment name or id").option("--name <name>", "its new name").option("--harness <id,…>", "the harnesses it accepts")).action(async (env, opts, cmd) => {
|
|
790
|
+
const acceptedHarnesses = opts.harness === void 0 ? void 0 : parseHarnesses(opts.harness);
|
|
791
|
+
if (acceptedHarnesses === null) throw new ValidationError("A Standards environment is chosen at creation only", []);
|
|
792
|
+
const client = getAppClientFromCommand(cmd);
|
|
793
|
+
const id = await resolveEnvironmentId(client, env);
|
|
794
|
+
formatOutput(await client.environments.update({
|
|
795
|
+
id,
|
|
796
|
+
...sharedFields(opts),
|
|
797
|
+
...acceptedHarnesses && { acceptedHarnesses }
|
|
798
|
+
}), getFormat(cmd));
|
|
799
|
+
});
|
|
800
|
+
environments.command("build").description("Start building an environment's image").argument("<env>", "environment name or id").action(async (env, _opts, cmd) => {
|
|
801
|
+
const client = getAppClientFromCommand(cmd);
|
|
802
|
+
const id = await resolveEnvironmentId(client, env);
|
|
803
|
+
await client.environments.build({ id });
|
|
804
|
+
printDone(`Build started for environment ${id}.`);
|
|
805
|
+
});
|
|
806
|
+
environments.command("archive").description("Archive an environment").argument("<env>", "environment name or id").option("--yes", "archive without confirmation").action(async (env, opts, cmd) => {
|
|
807
|
+
requireConfirmation(opts.yes, "Archiving an environment is explicit. Re-run with \"--yes\" to confirm.");
|
|
808
|
+
const client = getAppClientFromCommand(cmd);
|
|
809
|
+
const id = await resolveEnvironmentId(client, env);
|
|
810
|
+
await client.environments.archive({ id });
|
|
811
|
+
printDone(`Environment ${id} archived.`);
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
// src/commands/files.ts
|
|
815
|
+
function registerFilesCommand(program) {
|
|
816
|
+
const files = program.command("files").description("Upload, list, download and delete files");
|
|
817
|
+
files.command("list").description("List files (with --ids: exactly those, in that order)").option("--ids <ids>", "comma-separated file IDs; the other filters and the page do not apply").option("--limit <n>", "max files to return").option("--offset <n>", "number of files to skip").option("--folder <path>", "only files in this folder path").option("--mime-type <type>", "only files of this MIME type").action(async (opts, cmd) => {
|
|
818
|
+
formatOutput(await getAppClientFromCommand(cmd).files.list({
|
|
819
|
+
...opts.ids !== void 0 ? { ids: parseCsvList(opts.ids).join(",") } : {},
|
|
820
|
+
...opts.limit !== void 0 ? { limit: parseCount("--limit", opts.limit) } : {},
|
|
821
|
+
...opts.offset !== void 0 ? { offset: parseCount("--offset", opts.offset) } : {},
|
|
822
|
+
...opts.folder ? { folderPath: opts.folder } : {},
|
|
823
|
+
...opts.mimeType ? { mimeType: opts.mimeType } : {}
|
|
552
824
|
}), getFormat(cmd));
|
|
553
825
|
});
|
|
826
|
+
files.command("get").description("Get a file by ID").argument("<id>", "file ID").action(async (id, _opts, cmd) => {
|
|
827
|
+
formatOutput(await getAppClientFromCommand(cmd).files.get({ id }), getFormat(cmd));
|
|
828
|
+
});
|
|
829
|
+
files.command("upload").description("Upload local files (private); prints the stored files and their IDs").argument("<paths...>", "local file paths (at most 10)").action(async (paths, _opts, cmd) => {
|
|
830
|
+
const uploads = await Promise.all(paths.map(async (path) => new File([await readFile(path)], basename(path))));
|
|
831
|
+
formatOutput(await getAppClientFromCommand(cmd).files.upload({ files: uploads }), getFormat(cmd));
|
|
832
|
+
});
|
|
833
|
+
files.command("content").description("Download a file's bytes (to stdout, or -o <file>)").argument("<id>", "file ID").option("-o, --output <file>", "write to a file instead of stdout").action(async (id, opts, cmd) => {
|
|
834
|
+
const { blob } = await getAppClientFromCommand(cmd).files.content({ id });
|
|
835
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
836
|
+
if (opts.output) return await writeFile(opts.output, bytes);
|
|
837
|
+
process.stdout.write(bytes);
|
|
838
|
+
});
|
|
839
|
+
files.command("delete").description("Delete a file you uploaded and its stored bytes (not a file in a document pack)").argument("<id>", "file ID").option("--yes", "delete without confirmation").action(async (id, opts, cmd) => {
|
|
840
|
+
requireConfirmation(opts.yes, "Deleting a file purges its bytes. Re-run with \"--yes\" to confirm.");
|
|
841
|
+
await getAppClientFromCommand(cmd).files.delete({ id });
|
|
842
|
+
printDone(`File ${id} deleted.`);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
// src/commands/guide.ts
|
|
846
|
+
const PACKAGE_NAME = "@standards/cli";
|
|
847
|
+
const DEFAULT_TOPIC = "core";
|
|
848
|
+
/**
|
|
849
|
+
* The `guide/` directory shipped next to the package's `package.json`, found
|
|
850
|
+
* through the package's own export of its manifest: it resolves the same from
|
|
851
|
+
* `src/commands/` (tests), from `dist/bin.mjs` and from any `dist/` chunk.
|
|
852
|
+
*/
|
|
853
|
+
function guideDirectory() {
|
|
854
|
+
const manifest = createRequire(import.meta.url).resolve(`${PACKAGE_NAME}/package.json`);
|
|
855
|
+
return join(dirname(manifest), "guide");
|
|
856
|
+
}
|
|
857
|
+
/** The topic names, from the guide directory's `*.md` files, sorted. */
|
|
858
|
+
async function guideTopics(dir) {
|
|
859
|
+
return (await readdir(dir)).filter((file) => file.endsWith(".md")).map((file) => file.slice(0, -3)).sort();
|
|
860
|
+
}
|
|
861
|
+
/** `standards guide [topic]`: the usage guide matching the installed version. */
|
|
862
|
+
function registerGuideCommand(program) {
|
|
863
|
+
program.command("guide").description("Print the usage guide for this version of the CLI (core by default)").argument("[topic]", "guide topic", DEFAULT_TOPIC).option("--list", "list the topics, one per line").action(async (topic, opts) => {
|
|
864
|
+
const dir = guideDirectory();
|
|
865
|
+
const topics = await guideTopics(dir);
|
|
866
|
+
if (opts.list) {
|
|
867
|
+
process.stdout.write(topics.map((name) => `${name}\n`).join(""));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (!topics.includes(topic)) throw new ValidationError(`Unknown guide topic "${topic}". Topics: ${topics.join(", ")}`, []);
|
|
871
|
+
process.stdout.write(await readFile(join(dir, `${topic}.md`)));
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
// src/commands/init.ts
|
|
875
|
+
/** The line that marks a `SKILL.md` as this command's own, the only one it overwrites. */
|
|
876
|
+
const SKILL_STUB_MARKER = "<!-- standards-cli-stub -->";
|
|
877
|
+
const SKILL_DIR = join(".agents", "skills", "standards");
|
|
878
|
+
const LINK_PATH = join(".claude", "skills", "standards");
|
|
879
|
+
/** Relative, so the link survives a moved or mounted home or project. */
|
|
880
|
+
const LINK_TARGET = join("..", "..", SKILL_DIR);
|
|
881
|
+
const SKILL_STUB = `---
|
|
882
|
+
name: standards
|
|
883
|
+
description: Drive a Standards workspace from the shell with the \`standards\` CLI. It covers records and documents, schema and bundles, skills, MCP servers and connectors, and Standards agents (environments and their secrets, machines and their links, sessions on the claude-code, codex or cursor harnesses, the decisions they stop on, monitoring with status and watch). Use it whenever a task reads or changes Standards data, configures or starts an agent session, answers an agent's decision, or follows running sessions.
|
|
884
|
+
---
|
|
885
|
+
|
|
886
|
+
${SKILL_STUB_MARKER}
|
|
887
|
+
|
|
888
|
+
# standards
|
|
889
|
+
|
|
890
|
+
This file is a discovery stub, not the usage guide. Before running any \`standards\` command, load the guide from the CLI:
|
|
891
|
+
|
|
892
|
+
\`\`\`bash
|
|
893
|
+
standards guide core # start here: login, global flags, output, exit codes, the agent loop
|
|
894
|
+
standards guide --list # every topic
|
|
895
|
+
\`\`\`
|
|
896
|
+
|
|
897
|
+
For agent work (environments, secrets, sessions, decisions, machines, links, watch), also load:
|
|
898
|
+
|
|
899
|
+
\`\`\`bash
|
|
900
|
+
standards guide agents
|
|
901
|
+
\`\`\`
|
|
902
|
+
|
|
903
|
+
The CLI serves the guide matching its installed version, so it never goes stale. This stub cannot change between releases, which is why it only points at \`standards guide\`.
|
|
904
|
+
|
|
905
|
+
Re-run \`standards init\` (or \`standards init --global\`) to refresh it.
|
|
906
|
+
`;
|
|
907
|
+
async function lstatOrNull(path) {
|
|
908
|
+
try {
|
|
909
|
+
return await lstat(path);
|
|
910
|
+
} catch (error) {
|
|
911
|
+
if (error.code === "ENOENT") return null;
|
|
912
|
+
throw error;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
async function isOwnStub(file) {
|
|
916
|
+
if (!(await lstatOrNull(file))?.isFile()) return false;
|
|
917
|
+
return (await readFile(file, "utf8")).includes(SKILL_STUB_MARKER);
|
|
918
|
+
}
|
|
919
|
+
function refuse(path, reason) {
|
|
920
|
+
throw new ValidationError(`${path} ${reason}: "standards init" does not overwrite it. Move it away and re-run.`, []);
|
|
921
|
+
}
|
|
922
|
+
/** What already sits at the link path: nothing, our link, our copy, or something to refuse. */
|
|
923
|
+
async function linkState(link) {
|
|
924
|
+
const stats = await lstatOrNull(link);
|
|
925
|
+
if (!stats) return "missing";
|
|
926
|
+
if (stats.isSymbolicLink()) {
|
|
927
|
+
const target = await readlink(link);
|
|
928
|
+
if (target === LINK_TARGET) return "linked";
|
|
929
|
+
refuse(link, `is a symbolic link to ${target}`);
|
|
930
|
+
}
|
|
931
|
+
if (stats.isDirectory() && await isOwnStub(join(link, "SKILL.md"))) return "copied";
|
|
932
|
+
return refuse(link, "already exists");
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Writes the stub under `root` and links `.claude/skills/standards` to it. Every
|
|
936
|
+
* check runs before the first write, so a refusal leaves the disk untouched.
|
|
937
|
+
* Returns the lines to print.
|
|
938
|
+
*/
|
|
939
|
+
async function installSkillStub(root) {
|
|
940
|
+
const skillDir = join(root, SKILL_DIR);
|
|
941
|
+
const skillFile = join(skillDir, "SKILL.md");
|
|
942
|
+
const link = join(root, LINK_PATH);
|
|
943
|
+
const dirStats = await lstatOrNull(skillDir);
|
|
944
|
+
if (dirStats && !dirStats.isDirectory()) refuse(skillDir, "is not a directory");
|
|
945
|
+
if (await lstatOrNull(skillFile) && !await isOwnStub(skillFile)) refuse(skillFile, "was not written by standards init");
|
|
946
|
+
const state = await linkState(link);
|
|
947
|
+
await mkdir(skillDir, { recursive: true });
|
|
948
|
+
await writeFile(skillFile, SKILL_STUB);
|
|
949
|
+
const lines = [`Wrote ${skillFile}`];
|
|
950
|
+
if (state === "linked") return [...lines, `Kept ${link} -> ${LINK_TARGET}`];
|
|
951
|
+
if (state === "missing") {
|
|
952
|
+
await mkdir(dirname(link), { recursive: true });
|
|
953
|
+
try {
|
|
954
|
+
await symlink(LINK_TARGET, link, "dir");
|
|
955
|
+
return [...lines, `Linked ${link} -> ${LINK_TARGET}`];
|
|
956
|
+
} catch {
|
|
957
|
+
await mkdir(link, { recursive: true });
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
await writeFile(join(link, "SKILL.md"), SKILL_STUB);
|
|
961
|
+
return [...lines, `Copied the stub to ${join(link, "SKILL.md")}`];
|
|
962
|
+
}
|
|
963
|
+
/** `standards init [--global]`: installs the skill stub coding agents discover. */
|
|
964
|
+
function registerInitCommand(program) {
|
|
965
|
+
program.command("init").description("Install the standards skill stub for coding agents (.agents/skills and .claude/skills)").option("--global", "install under your home directory instead of the current one").action(async (opts) => {
|
|
966
|
+
const lines = await installSkillStub(opts.global ? homedir() : process.cwd());
|
|
967
|
+
process.stdout.write(lines.map((line) => `${line}\n`).join(""));
|
|
968
|
+
});
|
|
554
969
|
}
|
|
555
970
|
// src/commands/invitations.ts
|
|
556
971
|
/** Invitation ids are `randomUUID` server-side, so anything else is a caller mistake. */
|
|
@@ -592,12 +1007,10 @@ function registerInvitationsCommand(program) {
|
|
|
592
1007
|
}
|
|
593
1008
|
// src/commands/keys.ts
|
|
594
1009
|
async function resolveRoleId(client, name) {
|
|
595
|
-
|
|
596
|
-
if (!match) throw new Error(`Unknown role "${name}". Roles are listed in the app's People settings.`);
|
|
597
|
-
return match.id;
|
|
1010
|
+
return findNamed(await client.permissions.listRoles({}), "name", name, "role").id;
|
|
598
1011
|
}
|
|
599
1012
|
function registerKeysCommand(program) {
|
|
600
|
-
const keys = program.command("keys").description("Manage Standards API keys");
|
|
1013
|
+
const keys = program.command("keys").description("Manage Standards API keys").addHelpText("after", "\nA browser-login profile cannot create keys nor revoke another key: log in with --key, or manage keys in Settings → API keys.");
|
|
601
1014
|
keys.command("list").description("List API keys").action(async (_opts, cmd) => {
|
|
602
1015
|
formatOutput(await getAppClientFromCommand(cmd).apiKeys.list({}), getFormat(cmd));
|
|
603
1016
|
});
|
|
@@ -617,7 +1030,66 @@ function registerKeysCommand(program) {
|
|
|
617
1030
|
keys.command("revoke").description("Revoke an API key").argument("<id>", "API key ID").option("--yes", "revoke without confirmation").action(async (id, opts, cmd) => {
|
|
618
1031
|
if (!opts.yes) throw new Error("Revoking API keys is explicit. Re-run with \"--yes\" to confirm.");
|
|
619
1032
|
await getAppClientFromCommand(cmd).apiKeys.revoke({ id });
|
|
620
|
-
|
|
1033
|
+
printDone(`API key ${id} revoked.`);
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
// src/commands/links.ts
|
|
1037
|
+
const UNIT_MS$1 = {
|
|
1038
|
+
m: 6e4,
|
|
1039
|
+
h: 36e5,
|
|
1040
|
+
d: 864e5
|
|
1041
|
+
};
|
|
1042
|
+
/** `30m`, `2h`, `1d` in milliseconds; anything else, zero or past `LINK_MAX_MS` is refused. */
|
|
1043
|
+
function parseLinkDuration(value) {
|
|
1044
|
+
return parseDuration("--for", value, {
|
|
1045
|
+
units: UNIT_MS$1,
|
|
1046
|
+
example: "30m, 2h or 1d",
|
|
1047
|
+
limit: `at most ${LINK_MAX_MS / UNIT_MS$1.h}h`,
|
|
1048
|
+
max: LINK_MAX_MS
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
function registerLinksCommand(program) {
|
|
1052
|
+
const links = program.command("links").description("Open and manage the links machines publish");
|
|
1053
|
+
links.command("open").description("Print the link's URL with a single-use, 60-second token: open it, never share it").argument("<linkId>", "link id").action(async (id, _opts, cmd) => {
|
|
1054
|
+
const { url } = await getAppClientFromCommand(cmd).links.open({ id });
|
|
1055
|
+
process.stdout.write(`${url}\n`);
|
|
1056
|
+
});
|
|
1057
|
+
links.command("relaunch").description("Rerun the link's command, its machine woken first").argument("<linkId>", "link id").action(async (id, _opts, cmd) => {
|
|
1058
|
+
formatOutput(await getAppClientFromCommand(cmd).links.relaunch({ id }), getFormat(cmd));
|
|
1059
|
+
});
|
|
1060
|
+
links.command("extend").description("Make the link expire a duration from now").argument("<linkId>", "link id").requiredOption("--for <duration>", "30m, 2h, 1d…").action(async (id, opts, cmd) => {
|
|
1061
|
+
const durationMs = parseLinkDuration(opts.for);
|
|
1062
|
+
formatOutput(await getAppClientFromCommand(cmd).links.setDuration({
|
|
1063
|
+
id,
|
|
1064
|
+
durationMs
|
|
1065
|
+
}), getFormat(cmd));
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
// src/commands/machines.ts
|
|
1069
|
+
function registerMachinesCommand(program) {
|
|
1070
|
+
const machines = program.command("machines").description("Inspect and destroy agent machines");
|
|
1071
|
+
machines.command("list").description("List the machines you can read, every page").option("--env <env>", "only one environment's machines (name or id)").action(async (opts, cmd) => {
|
|
1072
|
+
const client = getAppClientFromCommand(cmd);
|
|
1073
|
+
const environmentId = opts.env ? await resolveEnvironmentId(client, opts.env) : void 0;
|
|
1074
|
+
formatOutput(await readAllPages((cursor) => client.machines.list({
|
|
1075
|
+
...environmentId && { environmentId },
|
|
1076
|
+
...cursor && { cursor }
|
|
1077
|
+
})), getFormat(cmd));
|
|
1078
|
+
});
|
|
1079
|
+
machines.command("get").description("Show a machine: its lease, pilot and links").argument("<id>", "machine id").action(async (id, _opts, cmd) => {
|
|
1080
|
+
formatOutput(await getAppClientFromCommand(cmd).machines.get({ id }), getFormat(cmd));
|
|
1081
|
+
});
|
|
1082
|
+
machines.command("runs").description("List the runs and conversation turns of a machine, every page").argument("<id>", "machine id").action(async (id, _opts, cmd) => {
|
|
1083
|
+
const client = getAppClientFromCommand(cmd);
|
|
1084
|
+
formatOutput(await readAllPages((cursor) => client.machines.runs({
|
|
1085
|
+
id,
|
|
1086
|
+
...cursor && { cursor }
|
|
1087
|
+
})), getFormat(cmd));
|
|
1088
|
+
});
|
|
1089
|
+
machines.command("destroy").description("Destroy a machine").argument("<id>", "machine id").option("--yes", "destroy without confirmation").action(async (id, opts, cmd) => {
|
|
1090
|
+
requireConfirmation(opts.yes, "Destroying a machine is explicit. Re-run with \"--yes\" to confirm.");
|
|
1091
|
+
await getAppClientFromCommand(cmd).machines.destroy({ id });
|
|
1092
|
+
printDone(`Machine ${id} destroyed.`);
|
|
621
1093
|
});
|
|
622
1094
|
}
|
|
623
1095
|
// src/commands/mcp.ts
|
|
@@ -676,12 +1148,12 @@ function registerMcpCommand(program) {
|
|
|
676
1148
|
id,
|
|
677
1149
|
trusted
|
|
678
1150
|
});
|
|
679
|
-
|
|
1151
|
+
printDone(`MCP server ${id} ${trusted ? "trusted" : "untrusted"}.`);
|
|
680
1152
|
});
|
|
681
1153
|
mcp.command("disconnect").description("Disconnect (delete) an MCP server").argument("<id>", "MCP server ID").option("--yes", "disconnect without confirmation").action(async (id, opts, cmd) => {
|
|
682
1154
|
if (!opts.yes) throw new ValidationError("Disconnecting an MCP server is explicit. Re-run with \"--yes\" to confirm.", []);
|
|
683
1155
|
await getAppClientFromCommand(cmd).mcpServers.delete({ id });
|
|
684
|
-
|
|
1156
|
+
printDone(`MCP server ${id} disconnected.`);
|
|
685
1157
|
});
|
|
686
1158
|
}
|
|
687
1159
|
// src/commands/meetings.ts
|
|
@@ -703,18 +1175,6 @@ function buildQuery(opts) {
|
|
|
703
1175
|
if (opts.offset !== void 0) query.offset = parseCount("--offset", opts.offset);
|
|
704
1176
|
return query;
|
|
705
1177
|
}
|
|
706
|
-
/** "needs_action" is no answer, so the API takes only the schema's answers. */
|
|
707
|
-
function parseResponse(response) {
|
|
708
|
-
if (MEETING_RESPONSES.includes(response)) return response;
|
|
709
|
-
throw new ValidationError(`response must be one of: ${MEETING_RESPONSES.join(", ")}`, []);
|
|
710
|
-
}
|
|
711
|
-
/** `--auto` clears the override; `--force` / `--skip` set it. Exactly one flag. */
|
|
712
|
-
function parseOverride(opts) {
|
|
713
|
-
const chosen = RECORDING_OVERRIDES.filter((override) => opts[override]);
|
|
714
|
-
if (opts.auto) chosen.push(null);
|
|
715
|
-
if (chosen.length !== 1) throw new ValidationError("record needs exactly one of --force, --skip or --auto", []);
|
|
716
|
-
return chosen[0] ?? null;
|
|
717
|
-
}
|
|
718
1178
|
/** The query flags `list` and `for-record` share — both take the same body shape. */
|
|
719
1179
|
function withQueryOptions(command) {
|
|
720
1180
|
return command.option("--search <text>", "match title, description and location").option("--since <iso>", "inclusive ISO lower bound on the start").option("--before <iso>", "exclusive ISO upper bound on the start").option("--addresses <emails>", "comma-separated participant addresses to restrict to").option("--order <order>", "asc for what is coming next, desc to read history").option("--limit <n>", "max meetings to return").option("--offset <n>", "number of meetings to skip");
|
|
@@ -738,72 +1198,6 @@ function registerMeetingsCommand(program) {
|
|
|
738
1198
|
meetings.command("transcript").description("Read a meeting's completed transcript").argument("<id>", "meeting ID").action(async (id, _opts, cmd) => {
|
|
739
1199
|
formatOutput(await getAppClientFromCommand(cmd).meetings.transcript({ meetingId: id }), getFormat(cmd));
|
|
740
1200
|
});
|
|
741
|
-
meetings.command("record").description("Force, skip, or hand back to the workspace policy the recording of a meeting").argument("<id>", "meeting ID").option("--force", "record this meeting whatever the workspace policy says").option("--skip", "never record this meeting").option("--auto", "clear the override and follow the workspace policy").action(async (id, opts, cmd) => {
|
|
742
|
-
const override = parseOverride(opts);
|
|
743
|
-
formatOutput(await getAppClientFromCommand(cmd).meetings.setRecording({
|
|
744
|
-
meetingId: id,
|
|
745
|
-
override
|
|
746
|
-
}), getFormat(cmd));
|
|
747
|
-
});
|
|
748
|
-
meetings.command("respond").description("Answer a meeting invitation").argument("<id>", "meeting ID").argument("<response>", `one of: ${MEETING_RESPONSES.join(", ")}`).option("--notify-organizer", "send the answer to the organizer").action(async (id, response, opts, cmd) => {
|
|
749
|
-
formatOutput(await getAppClientFromCommand(cmd).meetings.respond({
|
|
750
|
-
meetingId: id,
|
|
751
|
-
response: parseResponse(response),
|
|
752
|
-
notifyOrganizer: opts.notifyOrganizer === true
|
|
753
|
-
}), getFormat(cmd));
|
|
754
|
-
});
|
|
755
|
-
meetings.command("stop-recording").description("Stop the recording of a meeting in progress").argument("<id>", "meeting ID").action(async (id, _opts, cmd) => {
|
|
756
|
-
formatOutput(await getAppClientFromCommand(cmd).meetings.stopRecording({ meetingId: id }), getFormat(cmd));
|
|
757
|
-
});
|
|
758
|
-
meetings.command("grants-recompute").description("Recompute the attendee grants of every private meeting of the tenant").action(async (_opts, cmd) => {
|
|
759
|
-
const tenant = requireTenant(cmd, "A meeting grants recompute names its tenant explicitly. Pass \"--tenant <id>\".");
|
|
760
|
-
await getAppClientFromCommand(cmd).meetingsAdmin.recomputeGrants({ tenantId: tenant });
|
|
761
|
-
process.stdout.write(`${styleText("green", "✓")} Meeting grants recompute accepted for tenant ${tenant}.\n`);
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
// src/commands/memory.ts
|
|
765
|
-
function registerMemoryCommand(program) {
|
|
766
|
-
program.command("memory").description("Maintain agent memory").command("backfill").description("Rebuild agent memory from the tenant's last 30 days of sessions and meetings").option("--yes", "backfill without confirmation").action(async (opts, cmd) => {
|
|
767
|
-
if (!opts.yes) throw new ValidationError("A memory backfill spends model calls on the last 30 days of evidence. Re-run with \"--yes\" to confirm.", []);
|
|
768
|
-
const tenant = requireTenant(cmd, "A memory backfill names its tenant explicitly. Pass \"--tenant <id>\" with the tenant you are authenticated in.");
|
|
769
|
-
await getAppClientFromCommand(cmd).memoryAdmin.backfill({ tenantId: tenant });
|
|
770
|
-
process.stdout.write(`${styleText("green", "✓")} Memory backfill queued for tenant ${tenant}. It runs in the background — follow the server logs for progress.\n`);
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
// src/drift/reconciliation-prompt.ts
|
|
774
|
-
function renderReconciliationPrompt(state) {
|
|
775
|
-
const lines = [
|
|
776
|
-
"# Standards schema reconciliation",
|
|
777
|
-
"",
|
|
778
|
-
"Compare deployed source with shared runtime. This excludes unbuilt local TypeScript and personal preferences.",
|
|
779
|
-
"Review the shared changes, update the fluent declarations, then deploy. Copies stay retained after a deploy: reset them in architect mode to return to the source.",
|
|
780
|
-
"Use content with detailView/listView and .tab or .aside for view contents. Reading this prompt changes no files or workspace state.",
|
|
781
|
-
""
|
|
782
|
-
];
|
|
783
|
-
const { changes } = diffSchemaState(state);
|
|
784
|
-
if (changes.length === 0) lines.push("No differences.");
|
|
785
|
-
for (const entry of changes) lines.push(`## ${entry.change} ${JSON.stringify(entry.address)}`, `Status: ${entry.status}${entry.status === "copy" ? " (retained)" : ""}`, "```json", prettyCanonical(entry.effective ?? entry.source), "```", "");
|
|
786
|
-
return `${lines.join("\n").trimEnd()}\n`;
|
|
787
|
-
}
|
|
788
|
-
// src/commands/pull.ts
|
|
789
|
-
function registerPullCommand(program) {
|
|
790
|
-
program.command("pull").description("Read deployed source vs shared runtime; excludes unbuilt local TypeScript").option("--json", "Emit canonical shared state JSON instead of guidance").action(async (opts, cmd) => {
|
|
791
|
-
let code = 0;
|
|
792
|
-
try {
|
|
793
|
-
const state = await readSchemaState(getAppClientFromCommand(cmd));
|
|
794
|
-
console.info(opts.json ? prettyCanonical(state) : renderReconciliationPrompt(state).trimEnd());
|
|
795
|
-
} catch (error) {
|
|
796
|
-
console.error(styleText("red", `✗ ${messageOf(error)}`));
|
|
797
|
-
code = 2;
|
|
798
|
-
}
|
|
799
|
-
process.exit(code);
|
|
800
|
-
});
|
|
801
|
-
}
|
|
802
|
-
// src/commands/quotas.ts
|
|
803
|
-
function registerQuotasCommand(program) {
|
|
804
|
-
program.command("quotas").description("Inspect the tenant's remaining capacity").command("list").description("List one state per quota key (limit is null when nothing is enforced)").action(async (_opts, cmd) => {
|
|
805
|
-
formatOutput(await getAppClientFromCommand(cmd).quotas.list({}), getFormat(cmd));
|
|
806
|
-
});
|
|
807
1201
|
}
|
|
808
1202
|
// src/commands/records.ts
|
|
809
1203
|
function parseSortFlag(sort) {
|
|
@@ -845,12 +1239,16 @@ function registerRecordsCommand(program) {
|
|
|
845
1239
|
id
|
|
846
1240
|
}), getFormat(cmd));
|
|
847
1241
|
});
|
|
848
|
-
records.command("create").description("Create a new record").argument("<object>", "object name").option("--data <json>", "record data as JSON string").option("--parent <id>", "parent record ID (hierarchical objects only)").action(async (objectName, opts, cmd) => {
|
|
1242
|
+
records.command("create").description("Create a new record").argument("<object>", "object name").option("--data <json>", "record data as JSON string").option("--parent <id>", "parent record ID (hierarchical objects only)").option("--metadata <json>", "record metadata as a JSON object (application state beside its values)").action(async (objectName, opts, cmd) => {
|
|
849
1243
|
const data = opts.data ? JSON.parse(opts.data) : {};
|
|
1244
|
+
const options = {
|
|
1245
|
+
...opts.parent ? { parentId: opts.parent } : {},
|
|
1246
|
+
...opts.metadata !== void 0 ? { metadata: JSON.parse(opts.metadata) } : {}
|
|
1247
|
+
};
|
|
850
1248
|
formatOutput(await getAppClientFromCommand(cmd).records.create({
|
|
851
1249
|
objectName,
|
|
852
1250
|
data,
|
|
853
|
-
...
|
|
1251
|
+
...Object.keys(options).length > 0 ? { options } : {}
|
|
854
1252
|
}), getFormat(cmd));
|
|
855
1253
|
});
|
|
856
1254
|
records.command("update").description("Update a record").argument("<object>", "object name").argument("<id>", "record ID").option("--data <json>", "fields to update as JSON string").action(async (objectName, id, opts, cmd) => {
|
|
@@ -861,32 +1259,37 @@ function registerRecordsCommand(program) {
|
|
|
861
1259
|
values: data
|
|
862
1260
|
}), getFormat(cmd));
|
|
863
1261
|
});
|
|
864
|
-
records.command("delete").description("Delete
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1262
|
+
records.command("delete").description("Delete records (--dry-run counts what the delete would take)").argument("<object>", "object name").argument("<ids...>", "record IDs").option("--dry-run", "count the broken links and descendants, delete nothing").action(async (objectName, ids, opts, cmd) => {
|
|
1263
|
+
const client = getAppClientFromCommand(cmd);
|
|
1264
|
+
if (opts.dryRun) {
|
|
1265
|
+
formatOutput(await client.records.deletionCount({
|
|
1266
|
+
objectName,
|
|
1267
|
+
ids
|
|
1268
|
+
}), getFormat(cmd));
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const [id] = ids;
|
|
1272
|
+
if (id !== void 0 && ids.length === 1) {
|
|
1273
|
+
await client.records.delete({
|
|
1274
|
+
objectName,
|
|
1275
|
+
id
|
|
1276
|
+
});
|
|
1277
|
+
process.stdout.write(`Record ${id} deleted.\n`);
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
formatOutput(await client.records.bulkDelete({
|
|
873
1281
|
objectName,
|
|
874
1282
|
ids
|
|
875
1283
|
}), getFormat(cmd));
|
|
876
1284
|
});
|
|
877
|
-
records.command("move
|
|
878
|
-
|
|
1285
|
+
records.command("move").description("Move a record and its descendants under a parent (--dry-run shows the access impact)").argument("<object>", "object name").argument("<id>", "record ID").option("--parent <id>", "new parent record ID").option("--top-level", "move to the top level").option("--dry-run", "show who would gain and lose access, move nothing").action(async (objectName, id, opts, cmd) => {
|
|
1286
|
+
const client = getAppClientFromCommand(cmd);
|
|
1287
|
+
const body = {
|
|
879
1288
|
objectName,
|
|
880
1289
|
recordId: id,
|
|
881
1290
|
parentId: parseMoveTarget(opts)
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
records.command("move").description("Move a record and its descendants under a parent (preview with move-impact)").argument("<object>", "object name").argument("<id>", "record ID").option("--parent <id>", "new parent record ID").option("--top-level", "move to the top level").action(async (objectName, id, opts, cmd) => {
|
|
885
|
-
formatOutput(await getAppClientFromCommand(cmd).records.move({
|
|
886
|
-
objectName,
|
|
887
|
-
recordId: id,
|
|
888
|
-
parentId: parseMoveTarget(opts)
|
|
889
|
-
}), getFormat(cmd));
|
|
1291
|
+
};
|
|
1292
|
+
formatOutput(opts.dryRun ? await client.records.moveImpact(body) : await client.records.move(body), getFormat(cmd));
|
|
890
1293
|
});
|
|
891
1294
|
records.command("search").description("Full-text search records").argument("<object>", "object name").requiredOption("--query <q>", "search query string").option("--limit <n>", "max records to return").option("--offset <n>", "number of records to skip").option("--sort <sort>", "sort rule as \"attribute:direction\"").option("--filter <json>", "filter state as JSON string").option("--fields <names>", "comma-separated reference attributes to hydrate (empty for none, omit for all)").option("--count <mode>", COUNT_OPTION_DESCRIPTION).action(async (objectName, opts, cmd) => {
|
|
892
1295
|
const body = {
|
|
@@ -940,6 +1343,16 @@ function registerRecordsCommand(program) {
|
|
|
940
1343
|
files: new File([fileContent], fileName)
|
|
941
1344
|
}), getFormat(cmd));
|
|
942
1345
|
});
|
|
1346
|
+
records.command("attach-file").description("Attach an already uploaded file to a record").argument("<object>", "object name (e.g. contacts)").argument("<recordId>", "record ID").requiredOption("--file-id <fileId>", "ID of a private uploaded file").option("--attribute <attr>", "document attribute (defaults to the record's attachments)").option("--document-id <documentId>", "append to this document's pack instead of creating one").option("--title <title>", "title of a new document (defaults to the file name without its extension)").action(async (objectName, recordId, opts, cmd) => {
|
|
1347
|
+
formatOutput(await getAppClientFromCommand(cmd).records.attachFile({
|
|
1348
|
+
objectName,
|
|
1349
|
+
id: recordId,
|
|
1350
|
+
fileId: opts.fileId,
|
|
1351
|
+
...opts.attribute ? { attributeName: opts.attribute } : {},
|
|
1352
|
+
...opts.documentId ? { documentId: opts.documentId } : {},
|
|
1353
|
+
...opts.title ? { title: opts.title } : {}
|
|
1354
|
+
}), getFormat(cmd));
|
|
1355
|
+
});
|
|
943
1356
|
const access = records.command("access").description("Share a private record with other actors (readers and editors)");
|
|
944
1357
|
access.command("list").description("Show who can reach a private record beyond its owner").argument("<object>", "object name").argument("<id>", "record ID").action(async (objectName, id, _opts, cmd) => {
|
|
945
1358
|
formatOutput(await getAppClientFromCommand(cmd).recordAccess.list({
|
|
@@ -973,9 +1386,6 @@ function registerRecordsCommand(program) {
|
|
|
973
1386
|
// src/config.ts
|
|
974
1387
|
const DEFAULT_API_URL = "http://localhost:4100/v1";
|
|
975
1388
|
const ENV_PROFILE_NAME = "sandbox";
|
|
976
|
-
function getDefaultApiUrl() {
|
|
977
|
-
return DEFAULT_API_URL;
|
|
978
|
-
}
|
|
979
1389
|
function getConfigPath() {
|
|
980
1390
|
const configDir = process.env.STANDARDS_CONFIG_DIR ?? join(homedir(), ".standards");
|
|
981
1391
|
return join(configDir, "config.json");
|
|
@@ -1008,7 +1418,9 @@ async function upsertProfile(input) {
|
|
|
1008
1418
|
const config = await readConfig();
|
|
1009
1419
|
config.profiles[input.name] = {
|
|
1010
1420
|
apiUrl: input.apiUrl,
|
|
1011
|
-
apiKey: input.apiKey
|
|
1421
|
+
apiKey: input.apiKey,
|
|
1422
|
+
...input.keyId ? { keyId: input.keyId } : {},
|
|
1423
|
+
...input.keyName ? { keyName: input.keyName } : {}
|
|
1012
1424
|
};
|
|
1013
1425
|
config.currentProfile = input.name;
|
|
1014
1426
|
await writeConfig(config);
|
|
@@ -1019,6 +1431,7 @@ async function setCurrentProfile(name) {
|
|
|
1019
1431
|
config.currentProfile = name;
|
|
1020
1432
|
await writeConfig(config);
|
|
1021
1433
|
}
|
|
1434
|
+
/** Removes a profile, the current one by default. */
|
|
1022
1435
|
async function removeProfile(name) {
|
|
1023
1436
|
const config = await readConfig();
|
|
1024
1437
|
const profileName = name ?? config.currentProfile;
|
|
@@ -1043,22 +1456,6 @@ async function listProfiles() {
|
|
|
1043
1456
|
});
|
|
1044
1457
|
return profiles;
|
|
1045
1458
|
}
|
|
1046
|
-
async function getActiveProfile() {
|
|
1047
|
-
const config = await readConfig();
|
|
1048
|
-
if (!config.currentProfile) {
|
|
1049
|
-
const envProfile = getEnvProfile();
|
|
1050
|
-
return envProfile ? {
|
|
1051
|
-
name: ENV_PROFILE_NAME,
|
|
1052
|
-
...envProfile
|
|
1053
|
-
} : null;
|
|
1054
|
-
}
|
|
1055
|
-
const profile = config.profiles[config.currentProfile];
|
|
1056
|
-
if (!profile) return null;
|
|
1057
|
-
return {
|
|
1058
|
-
name: config.currentProfile,
|
|
1059
|
-
...profile
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
1459
|
function getEnvProfile() {
|
|
1063
1460
|
if (!process.env.STANDARDS_API_KEY) return null;
|
|
1064
1461
|
return {
|
|
@@ -1087,57 +1484,297 @@ async function resolveCliConfig(options) {
|
|
|
1087
1484
|
apiKey: process.env.STANDARDS_API_KEY
|
|
1088
1485
|
};
|
|
1089
1486
|
}
|
|
1487
|
+
// src/commands/device-login.ts
|
|
1488
|
+
/** The pre-registered public client of the CLI on every Standards instance. */
|
|
1489
|
+
const CLIENT = {
|
|
1490
|
+
client_id: CLI_CLIENT_ID,
|
|
1491
|
+
token_endpoint_auth_method: "none"
|
|
1492
|
+
};
|
|
1493
|
+
/** RFC 8628 §3.5: a `slow_down` adds five seconds to the polling interval. */
|
|
1494
|
+
const SLOW_DOWN_SECONDS = 5;
|
|
1495
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
1496
|
+
/** The server refuses more `replaces` ids than this (`cliLoginBody`). */
|
|
1497
|
+
const MAX_REPLACES = 20;
|
|
1498
|
+
/** A login that ends without a profile: the line to print and the exit code (spec, Terminal). */
|
|
1499
|
+
var LoginFailure = class extends Error {
|
|
1500
|
+
constructor(message, exitCode) {
|
|
1501
|
+
super(message);
|
|
1502
|
+
this.exitCode = exitCode;
|
|
1503
|
+
this.name = "LoginFailure";
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
/** This machine's name as the server accepts a device label: 1-64 printable ASCII characters. */
|
|
1507
|
+
function deviceLabel() {
|
|
1508
|
+
return hostname().replace(/[^\x20-\x7E]/g, "").trim().slice(0, CLI_DEVICE_LABEL_MAX).trim() || "unknown device";
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* `standards login` through the browser (RFC 8628): discovers the instance's `cli`
|
|
1512
|
+
* resource, shows the code, polls until the person approves, then mints the CLI's
|
|
1513
|
+
* personal key once with the access token and stores it as a profile.
|
|
1514
|
+
*/
|
|
1515
|
+
async function deviceLogin(options) {
|
|
1516
|
+
const { signal } = options;
|
|
1517
|
+
try {
|
|
1518
|
+
const resource = new URL(`${options.apiUrl.replace(/\/+$/, "")}/cli`);
|
|
1519
|
+
const http = {
|
|
1520
|
+
signal,
|
|
1521
|
+
[oauth.allowInsecureRequests]: resource.protocol === "http:"
|
|
1522
|
+
};
|
|
1523
|
+
const as = await discoverAuthorizationServer(resource, options.apiUrl, http);
|
|
1524
|
+
const authorization = await oauth.processDeviceAuthorizationResponse(as, CLIENT, await oauth.deviceAuthorizationRequest(as, CLIENT, oauth.None(), {
|
|
1525
|
+
scope: CLI_LOGIN_SCOPE,
|
|
1526
|
+
resource: resource.href,
|
|
1527
|
+
device: options.device
|
|
1528
|
+
}, http));
|
|
1529
|
+
print(`To connect, open ${authorization.verification_uri} and enter the code:\n\n`);
|
|
1530
|
+
print(` ${authorization.user_code}\n\n`);
|
|
1531
|
+
print("Press Enter to open the browser…\n");
|
|
1532
|
+
options.waitForEnter().then(() => options.openBrowser(authorization.verification_uri).catch(() => print("Open the link above on any device.\n"))).catch(() => void 0);
|
|
1533
|
+
print("Waiting for you to approve… (Ctrl-C to cancel)\n");
|
|
1534
|
+
return await storeKey(options, await pollForToken(as, authorization, http));
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
if (signal.aborted && !(error instanceof LoginFailure)) throw new LoginFailure("Login cancelled. Nothing was saved.", 130);
|
|
1537
|
+
throw error;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
/** RFC 9728 metadata of the `cli` resource, then RFC 8414 metadata of its authorization server. */
|
|
1541
|
+
async function discoverAuthorizationServer(resource, apiUrl, http) {
|
|
1542
|
+
const response = await oauth.resourceDiscoveryRequest(resource, http);
|
|
1543
|
+
if (response.status === 404) throw new LoginFailure(`This instance does not offer browser login. Create a personal key in Settings → API keys, then run standards login --url ${apiUrl} --key <key>.`, 1);
|
|
1544
|
+
const issuerUrl = (await oauth.processResourceDiscoveryResponse(resource, response)).authorization_servers?.[0];
|
|
1545
|
+
if (!issuerUrl) throw new Error(`The ${resource.href} metadata names no authorization server.`);
|
|
1546
|
+
const issuer = new URL(issuerUrl);
|
|
1547
|
+
return oauth.processDiscoveryResponse(issuer, await oauth.discoveryRequest(issuer, {
|
|
1548
|
+
...http,
|
|
1549
|
+
algorithm: "oauth2"
|
|
1550
|
+
}));
|
|
1551
|
+
}
|
|
1552
|
+
/** Polls the token endpoint at the server's interval, slower after each `slow_down`. */
|
|
1553
|
+
async function pollForToken(as, authorization, http) {
|
|
1554
|
+
let interval = authorization.interval ?? DEFAULT_INTERVAL_SECONDS;
|
|
1555
|
+
for (;;) {
|
|
1556
|
+
await sleep$2(interval * 1e3, http.signal);
|
|
1557
|
+
try {
|
|
1558
|
+
const response = await oauth.deviceCodeGrantRequest(as, CLIENT, oauth.None(), authorization.device_code, http);
|
|
1559
|
+
return (await oauth.processDeviceCodeResponse(as, CLIENT, response)).access_token;
|
|
1560
|
+
} catch (error) {
|
|
1561
|
+
if (!(error instanceof oauth.ResponseBodyError)) throw error;
|
|
1562
|
+
switch (error.error) {
|
|
1563
|
+
case "authorization_pending": continue;
|
|
1564
|
+
case "slow_down":
|
|
1565
|
+
interval += SLOW_DOWN_SECONDS;
|
|
1566
|
+
continue;
|
|
1567
|
+
case "access_denied": throw new LoginFailure("✗ Login was declined in the browser. Run standards login to try again.", 1);
|
|
1568
|
+
case "expired_token": throw new LoginFailure("✗ The code expired. Run standards login to get a new one.", 1);
|
|
1569
|
+
default: throw error;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Mints the key once with the access token (sent as the transport's bearer), then
|
|
1576
|
+
* writes the profile. `replaces` names the keys of every profile on the same instance
|
|
1577
|
+
* (D8); a profile whose key the server revoked goes with it. The access token is not kept.
|
|
1578
|
+
*/
|
|
1579
|
+
async function storeKey(options, accessToken) {
|
|
1580
|
+
const config = await readConfig();
|
|
1581
|
+
const replaces = Object.values(config.profiles).filter((profile) => profile.apiUrl === options.apiUrl && profile.keyId).map((profile) => profile.keyId).slice(0, MAX_REPLACES);
|
|
1582
|
+
let minted;
|
|
1583
|
+
try {
|
|
1584
|
+
minted = await createAppClient(createClient({
|
|
1585
|
+
apiUrl: options.apiUrl,
|
|
1586
|
+
apiKey: accessToken
|
|
1587
|
+
})).cliLogin.createKey({
|
|
1588
|
+
device: options.device,
|
|
1589
|
+
...replaces.length ? { replaces } : {}
|
|
1590
|
+
}, { signal: options.signal });
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
if (options.signal.aborted) throw new LoginFailure(`Login cancelled. A key may have been created; revoke "Standards CLI - ${options.device}" in Settings → API keys if so.`, 130);
|
|
1593
|
+
throw new LoginFailure(`✗ Approved, but the key could not be created (${error instanceof StandardsRequestError && error.status > 0 ? String(error.status) : "network error"}). Run standards login again.`, 1);
|
|
1594
|
+
}
|
|
1595
|
+
const profileName = options.name ?? (slugify(minted.workspace.name) || "standards");
|
|
1596
|
+
await upsertProfile({
|
|
1597
|
+
name: profileName,
|
|
1598
|
+
apiUrl: options.apiUrl,
|
|
1599
|
+
apiKey: minted.apiKey.secret,
|
|
1600
|
+
keyId: minted.apiKey.id,
|
|
1601
|
+
keyName: minted.apiKey.name
|
|
1602
|
+
});
|
|
1603
|
+
const replaced = new Set(minted.replaced);
|
|
1604
|
+
const removedProfiles = Object.entries(config.profiles).filter(([name, { keyId }]) => name !== profileName && keyId !== void 0 && replaced.has(keyId)).map(([name]) => name);
|
|
1605
|
+
for (const name of removedProfiles) await removeProfile(name);
|
|
1606
|
+
return {
|
|
1607
|
+
profileName,
|
|
1608
|
+
workspace: minted.workspace.name,
|
|
1609
|
+
replacedProfile: profileName in config.profiles,
|
|
1610
|
+
removedProfiles
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
/** A wait that Ctrl-C cuts short. */
|
|
1614
|
+
function sleep$2(ms, signal) {
|
|
1615
|
+
return new Promise((resolve, reject) => {
|
|
1616
|
+
if (signal.aborted) return reject(signal.reason);
|
|
1617
|
+
const timer = setTimeout(() => {
|
|
1618
|
+
signal.removeEventListener("abort", onAbort);
|
|
1619
|
+
resolve();
|
|
1620
|
+
}, ms);
|
|
1621
|
+
function onAbort() {
|
|
1622
|
+
clearTimeout(timer);
|
|
1623
|
+
reject(signal.reason);
|
|
1624
|
+
}
|
|
1625
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
function print(line) {
|
|
1629
|
+
process.stdout.write(line);
|
|
1630
|
+
}
|
|
1090
1631
|
// src/commands/root.ts
|
|
1091
|
-
|
|
1092
|
-
|
|
1632
|
+
/** `login` targets hosted Standards unless `--url` says otherwise (D3); other commands keep `DEFAULT_API_URL`. */
|
|
1633
|
+
const HOSTED_API_URL = "https://api.standards.new/v1";
|
|
1634
|
+
/** Over SSH the browser `open` would start is on the wrong machine: the person opens the link themselves. */
|
|
1635
|
+
async function openBrowser(url) {
|
|
1636
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) throw new Error("No local browser");
|
|
1637
|
+
const { default: open } = await import("open");
|
|
1638
|
+
await open(url);
|
|
1639
|
+
}
|
|
1640
|
+
/** Runs the browser login with Ctrl-C and Enter wired to this terminal, and exits with the spec's code on failure. */
|
|
1641
|
+
async function loginThroughBrowser(apiUrl, name) {
|
|
1642
|
+
if (!isInteractive()) {
|
|
1643
|
+
console.error(styleText("red", "✗ Non-interactive: set STANDARDS_API_KEY or run standards login --key <key>."));
|
|
1644
|
+
process.exit(2);
|
|
1645
|
+
}
|
|
1646
|
+
const controller = new AbortController();
|
|
1647
|
+
const cancel = () => controller.abort();
|
|
1648
|
+
process.once("SIGINT", cancel);
|
|
1649
|
+
const enter = createInterface({
|
|
1093
1650
|
input: stdin,
|
|
1094
|
-
|
|
1651
|
+
terminal: false
|
|
1095
1652
|
});
|
|
1096
1653
|
try {
|
|
1097
|
-
|
|
1654
|
+
const result = await deviceLogin({
|
|
1655
|
+
apiUrl,
|
|
1656
|
+
name,
|
|
1657
|
+
device: deviceLabel(),
|
|
1658
|
+
openBrowser,
|
|
1659
|
+
waitForEnter: () => new Promise((resolve) => enter.once("line", () => resolve())),
|
|
1660
|
+
signal: controller.signal
|
|
1661
|
+
});
|
|
1662
|
+
printDone(`Connected to ${result.workspace}. Profile "${result.profileName}" selected.`);
|
|
1663
|
+
if (result.replacedProfile) process.stdout.write(` Replaced the previous profile "${result.profileName}".\n`);
|
|
1664
|
+
for (const name of result.removedProfiles) process.stdout.write(` Removed profile "${name}": its key was replaced.\n`);
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
if (!(error instanceof LoginFailure)) throw error;
|
|
1667
|
+
console.error(error.exitCode === 130 ? error.message : styleText("red", error.message));
|
|
1668
|
+
process.exit(error.exitCode);
|
|
1098
1669
|
} finally {
|
|
1099
|
-
|
|
1670
|
+
process.off("SIGINT", cancel);
|
|
1671
|
+
enter.close();
|
|
1100
1672
|
}
|
|
1101
1673
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1674
|
+
/** `--key`: validate the pasted key against the instance, then store it (no `keyId`: logout cannot revoke it). */
|
|
1675
|
+
async function loginWithKey(apiUrl, apiKey, name) {
|
|
1676
|
+
await createAppClient(createClient({
|
|
1677
|
+
apiUrl,
|
|
1678
|
+
apiKey
|
|
1679
|
+
})).apiKeys.list({});
|
|
1680
|
+
await upsertProfile({
|
|
1681
|
+
name,
|
|
1682
|
+
apiUrl,
|
|
1683
|
+
apiKey
|
|
1684
|
+
});
|
|
1685
|
+
printDone(`Standards instance "${name}" saved and selected.`);
|
|
1686
|
+
process.stdout.write(` API URL: ${apiUrl}\n`);
|
|
1687
|
+
process.stdout.write(` API Key: ${apiKey.slice(0, 8)}...\n`);
|
|
1688
|
+
process.stdout.write(` Use it with: standards --instance ${name} <command>\n`);
|
|
1689
|
+
}
|
|
1690
|
+
/** How long `logout` waits on the revocation before warning and removing the profile anyway. */
|
|
1691
|
+
const REVOKE_TIMEOUT_MS = 1e4;
|
|
1692
|
+
/** D11: revoke the profile's key with that key itself, best effort: a failure only warns. */
|
|
1693
|
+
async function revokeKey(profile) {
|
|
1694
|
+
if (!profile?.keyId) return;
|
|
1695
|
+
try {
|
|
1106
1696
|
await createAppClient(createClient({
|
|
1107
|
-
apiUrl:
|
|
1108
|
-
apiKey
|
|
1109
|
-
})).apiKeys.
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1697
|
+
apiUrl: profile.apiUrl,
|
|
1698
|
+
apiKey: profile.apiKey
|
|
1699
|
+
})).apiKeys.revoke({ id: profile.keyId }, { signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS) });
|
|
1700
|
+
} catch {
|
|
1701
|
+
const keyName = profile.keyName ?? `Standards CLI - ${deviceLabel()}`;
|
|
1702
|
+
console.error(styleText("yellow", `! The key could not be revoked on the server. Revoke "${keyName}" in Settings → API keys.`));
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
function registerRootCommands(program) {
|
|
1706
|
+
program.command("login").description("Connect the CLI to a Standards instance through the browser, or with --key").option("--url <url>", "Standards API URL", HOSTED_API_URL).option("--key <key>", "API key to store for this instance (self-hosted instances, scripts)").option("--name <name>", "profile name (defaults to the workspace name, or \"local\" with --key)").action(async (opts) => {
|
|
1707
|
+
if (opts.key) {
|
|
1708
|
+
await loginWithKey(opts.url, opts.key, opts.name ?? "local");
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
await loginThroughBrowser(opts.url, opts.name);
|
|
1119
1712
|
});
|
|
1120
1713
|
program.command("use").description("Select the active Standards instance").argument("<name>", "instance name").action(async (name) => {
|
|
1121
1714
|
await setCurrentProfile(name);
|
|
1122
|
-
|
|
1715
|
+
printDone(`Standards instance "${name}" selected.`);
|
|
1123
1716
|
});
|
|
1124
1717
|
program.command("instances").description("List configured Standards instances").action(async (_opts, cmd) => {
|
|
1125
1718
|
formatOutput(await listProfiles(), getFormat(cmd));
|
|
1126
1719
|
});
|
|
1127
|
-
program.command("
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1720
|
+
program.command("whoami").description("Show the active profile, API URL and key prefix, and validate the key").action(async (_opts, cmd) => {
|
|
1721
|
+
const { profileName, apiUrl, apiKey } = getGlobalOptions(cmd);
|
|
1722
|
+
process.stdout.write(`Profile: ${profileName ?? "none (--api-key)"}\n`);
|
|
1723
|
+
process.stdout.write(`API URL: ${apiUrl}\n`);
|
|
1724
|
+
process.stdout.write(`API Key: ${apiKey?.slice(0, 8)}...\n`);
|
|
1725
|
+
await getAppClientFromCommand(cmd).apiKeys.list({});
|
|
1726
|
+
printDone("API key is valid");
|
|
1134
1727
|
});
|
|
1135
|
-
program.command("logout").description("Remove a Standards instance from local CLI config").argument("[name]", "instance name, defaults to current").action(async (name) => {
|
|
1136
|
-
await
|
|
1137
|
-
|
|
1728
|
+
program.command("logout").description("Remove a Standards instance from local CLI config and revoke its key").argument("[name]", "instance name, defaults to current").action(async (name) => {
|
|
1729
|
+
const config = await readConfig();
|
|
1730
|
+
const profileName = name ?? config.currentProfile;
|
|
1731
|
+
await revokeKey(profileName ? config.profiles[profileName] : void 0);
|
|
1732
|
+
await removeProfile(profileName);
|
|
1733
|
+
printDone("Standards instance removed.");
|
|
1138
1734
|
});
|
|
1139
1735
|
}
|
|
1736
|
+
// src/drift/shared-state.ts
|
|
1737
|
+
async function readSchemaState(client) {
|
|
1738
|
+
const state = parseSchemaState(await client.schemaState.get({}));
|
|
1739
|
+
if (createHash("sha256").update(canonicalStringify(schemaStateSourcePayload(state.entries))).digest("hex") !== state.sourceFingerprint) throw new ValidationError("Invalid schema state: sourceFingerprint does not match deployed source", []);
|
|
1740
|
+
return state;
|
|
1741
|
+
}
|
|
1742
|
+
function prettyCanonical(value) {
|
|
1743
|
+
return JSON.stringify(JSON.parse(canonicalStringify(value)), null, 2);
|
|
1744
|
+
}
|
|
1745
|
+
// src/drift/reconciliation-prompt.ts
|
|
1746
|
+
function renderReconciliationPrompt(state) {
|
|
1747
|
+
const lines = [
|
|
1748
|
+
"# Standards schema reconciliation",
|
|
1749
|
+
"",
|
|
1750
|
+
"Compare deployed source with shared runtime. This excludes unbuilt local TypeScript and personal preferences.",
|
|
1751
|
+
"Review the shared changes, update the fluent declarations, then deploy. Copies stay retained after a deploy: reset them in architect mode to return to the source.",
|
|
1752
|
+
"Use content with detailView/listView and .tab or .aside for view contents. Reading this prompt changes no files or workspace state.",
|
|
1753
|
+
""
|
|
1754
|
+
];
|
|
1755
|
+
const { changes } = diffSchemaState(state);
|
|
1756
|
+
if (changes.length === 0) lines.push("No differences.");
|
|
1757
|
+
for (const entry of changes) lines.push(`## ${entry.change} ${JSON.stringify(entry.address)}`, `Status: ${entry.status}${entry.status === "copy" ? " (retained)" : ""}`, "```json", prettyCanonical(entry.effective ?? entry.source), "```", "");
|
|
1758
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
1759
|
+
}
|
|
1140
1760
|
// src/commands/schema.ts
|
|
1761
|
+
async function runDiffCommand(client) {
|
|
1762
|
+
try {
|
|
1763
|
+
const diff = diffSchemaState(await readSchemaState(client));
|
|
1764
|
+
return {
|
|
1765
|
+
exitCode: diff.changes.length ? 1 : 0,
|
|
1766
|
+
diff
|
|
1767
|
+
};
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
return {
|
|
1770
|
+
exitCode: 2,
|
|
1771
|
+
errorMessage: messageOf(error)
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
function renderDiff(diff) {
|
|
1776
|
+
return ["Deployed source vs shared runtime", ...diff.changes.length ? diff.changes.map((entry) => `${entry.change} ${JSON.stringify(entry.address)}`) : ["No differences."]].join("\n");
|
|
1777
|
+
}
|
|
1141
1778
|
function registerSchemaCommand(program) {
|
|
1142
1779
|
const schema = program.command("schema").description("Inspect schema objects and attributes");
|
|
1143
1780
|
schema.command("list").description("List all schema objects").action(async (_opts, cmd) => {
|
|
@@ -1149,28 +1786,598 @@ function registerSchemaCommand(program) {
|
|
|
1149
1786
|
schema.command("groups").description("List the bundles that own objects, with their label").action(async (_opts, cmd) => {
|
|
1150
1787
|
formatOutput(await getAppClientFromCommand(cmd).schemaGroups.list({}), getFormat(cmd));
|
|
1151
1788
|
});
|
|
1789
|
+
schema.command("pull").description("Read deployed source vs shared runtime; excludes unbuilt local TypeScript (--format json for the canonical state)").action(async (_opts, cmd) => {
|
|
1790
|
+
let code = 0;
|
|
1791
|
+
try {
|
|
1792
|
+
const state = await readSchemaState(getAppClientFromCommand(cmd));
|
|
1793
|
+
console.info(isHumanOutput(cmd) ? renderReconciliationPrompt(state).trimEnd() : prettyCanonical(state));
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
console.error(styleText("red", `✗ ${messageOf(error)}`));
|
|
1796
|
+
code = 2;
|
|
1797
|
+
}
|
|
1798
|
+
process.exit(code);
|
|
1799
|
+
});
|
|
1800
|
+
schema.command("diff").description("Compare deployed source vs shared runtime; excludes unbuilt local TypeScript (exit 0 equal, 1 differences, 2 error)").action(async (_opts, cmd) => {
|
|
1801
|
+
let result;
|
|
1802
|
+
try {
|
|
1803
|
+
result = await runDiffCommand(getAppClientFromCommand(cmd));
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
result = {
|
|
1806
|
+
exitCode: 2,
|
|
1807
|
+
errorMessage: messageOf(error)
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
if (result.exitCode === 2) console.error(styleText("red", `✗ ${result.errorMessage}`));
|
|
1811
|
+
else console.info(isHumanOutput(cmd) ? renderDiff(result.diff) : prettyCanonical(result.diff));
|
|
1812
|
+
process.exit(result.exitCode);
|
|
1813
|
+
});
|
|
1152
1814
|
}
|
|
1153
|
-
// src/commands/
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1815
|
+
// src/commands/secrets.ts
|
|
1816
|
+
/** The owner flags are judged before stdin or any call: `--private` and `--env` exclude each other. */
|
|
1817
|
+
function checkOwnerFlags(opts) {
|
|
1818
|
+
if (opts.private && opts.env) throw new ValidationError("--private and --env name different owners: pass one", []);
|
|
1819
|
+
if (opts.repository && !opts.env) throw new ValidationError("--repository is only accepted with --env <env>", []);
|
|
1820
|
+
}
|
|
1821
|
+
async function resolveOwner(client, opts) {
|
|
1822
|
+
if (opts.private) return { kind: "private" };
|
|
1823
|
+
if (!opts.env) return { kind: "workspace" };
|
|
1824
|
+
const id = await resolveEnvironmentId(client, opts.env);
|
|
1825
|
+
return opts.repository ? {
|
|
1826
|
+
kind: "environment",
|
|
1827
|
+
id,
|
|
1828
|
+
repository: opts.repository
|
|
1829
|
+
} : {
|
|
1830
|
+
kind: "environment",
|
|
1831
|
+
id
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
/** A listed secret without its `value`: the CLI never prints one, sensitive or not. */
|
|
1835
|
+
function withoutValue(entry) {
|
|
1836
|
+
if (entry.kind !== "variable") return entry;
|
|
1837
|
+
const { value: _omitted, ...rest } = entry;
|
|
1838
|
+
return rest;
|
|
1839
|
+
}
|
|
1840
|
+
async function listOwnerSecrets(client, owner) {
|
|
1841
|
+
switch (owner.kind) {
|
|
1842
|
+
case "workspace": return await client.envVars.list({});
|
|
1843
|
+
case "private": return await client.privateEnvVars.list({});
|
|
1844
|
+
case "environment": return await client.environmentEnvVars.list({ id: owner.id });
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
/** What a new workspace secret reaches: `all`, or each named environment's id. */
|
|
1848
|
+
async function resolveReach(client, value) {
|
|
1849
|
+
const names = parseCsvList(value);
|
|
1850
|
+
if (names.length === 1 && names[0] === "all") return "all";
|
|
1851
|
+
if (names.includes("all")) throw new ValidationError("--environments takes \"all\" alone or environment names", []);
|
|
1852
|
+
return await Promise.all(names.map((name) => resolveEnvironmentId(client, name)));
|
|
1853
|
+
}
|
|
1854
|
+
async function setSecret(client, owner, entry, environments) {
|
|
1855
|
+
switch (owner.kind) {
|
|
1856
|
+
case "private": return await client.privateEnvVars.upsert(entry);
|
|
1857
|
+
case "environment": return await client.environmentEnvVars.batch({
|
|
1858
|
+
id: owner.id,
|
|
1859
|
+
set: [owner.repository ? {
|
|
1860
|
+
...entry,
|
|
1861
|
+
repository: owner.repository
|
|
1862
|
+
} : entry],
|
|
1863
|
+
remove: []
|
|
1864
|
+
});
|
|
1865
|
+
case "workspace": return await client.envVars.upsert(environments === void 0 ? entry : {
|
|
1866
|
+
...entry,
|
|
1867
|
+
environments: await resolveReach(client, environments)
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
async function deleteSecret(client, owner, key) {
|
|
1872
|
+
const variables = (await listOwnerSecrets(client, owner)).filter((entry) => entry.kind === "variable");
|
|
1873
|
+
if (owner.kind === "environment") {
|
|
1874
|
+
findNamed(variables.filter((entry) => entry.repository === owner.repository), "key", key, "secret");
|
|
1875
|
+
await client.environmentEnvVars.batch({
|
|
1876
|
+
id: owner.id,
|
|
1877
|
+
set: [],
|
|
1878
|
+
remove: [owner.repository ? {
|
|
1879
|
+
key,
|
|
1880
|
+
repository: owner.repository
|
|
1881
|
+
} : key]
|
|
1882
|
+
});
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
const { id } = findNamed(variables, "key", key, "secret");
|
|
1886
|
+
if (owner.kind === "private") await client.privateEnvVars.delete({ id });
|
|
1887
|
+
else await client.envVars.delete({ id });
|
|
1888
|
+
}
|
|
1889
|
+
function stripped(result) {
|
|
1890
|
+
return Array.isArray(result) ? result.map(withoutValue) : withoutValue(result);
|
|
1891
|
+
}
|
|
1892
|
+
/** A workspace secret that reaches no environment yet; an update keeps the key's reach. */
|
|
1893
|
+
function reachesNoEnvironment(result) {
|
|
1894
|
+
if (Array.isArray(result)) return false;
|
|
1895
|
+
const { environments } = result;
|
|
1896
|
+
return environments === void 0 || Array.isArray(environments) && environments.length === 0;
|
|
1897
|
+
}
|
|
1898
|
+
function registerSecretsCommand(program) {
|
|
1899
|
+
const secrets = program.command("secrets").description("Manage secrets: the workspace's, your own, or an environment's");
|
|
1900
|
+
secrets.command("list").description("List secrets (never their values)").option("--private", "your own secrets").option("--env <env>", "an environment's secrets (name or id)").action(async (opts, cmd) => {
|
|
1901
|
+
checkOwnerFlags(opts);
|
|
1902
|
+
const client = getAppClientFromCommand(cmd);
|
|
1903
|
+
formatOutput((await listOwnerSecrets(client, await resolveOwner(client, opts))).map(withoutValue), getFormat(cmd));
|
|
1904
|
+
});
|
|
1905
|
+
secrets.command("set").description("Set a secret from piped stdin: printf %s \"$VALUE\" | standards secrets set KEY").argument("<KEY>", "secret key").allowExcessArguments(false).option("--private", "your own secret").option("--env <env>", "an environment's secret (name or id)").option("--repository <owner/name>", "scope an environment's secret to one repository").option("--environments <env,…|all>", "the environments a new workspace secret reaches").option("--plain", "store it as non-sensitive").action(async (key, opts, cmd) => {
|
|
1906
|
+
checkOwnerFlags(opts);
|
|
1907
|
+
if (opts.environments !== void 0 && (opts.private || opts.env)) throw new ValidationError("--environments only applies to a workspace secret", []);
|
|
1908
|
+
const value = await readPipedValue(`secrets set ${key}`);
|
|
1909
|
+
const client = getAppClientFromCommand(cmd);
|
|
1910
|
+
const owner = await resolveOwner(client, opts);
|
|
1911
|
+
const result = await setSecret(client, owner, {
|
|
1912
|
+
key,
|
|
1913
|
+
value,
|
|
1914
|
+
isSensitive: !opts.plain
|
|
1915
|
+
}, opts.environments);
|
|
1916
|
+
formatOutput(stripped(result), getFormat(cmd));
|
|
1917
|
+
if (owner.kind === "workspace" && reachesNoEnvironment(result)) process.stderr.write("Reaches no environment yet: re-run with --environments <env,…|all>\n");
|
|
1918
|
+
});
|
|
1919
|
+
secrets.command("delete").description("Delete a secret").argument("<KEY>", "secret key").option("--private", "your own secret").option("--env <env>", "an environment's secret (name or id)").option("--repository <owner/name>", "the repository's value of an environment's secret").option("--yes", "delete without confirmation").action(async (key, opts, cmd) => {
|
|
1920
|
+
checkOwnerFlags(opts);
|
|
1921
|
+
requireConfirmation(opts.yes, "Deleting a secret is explicit. Re-run with \"--yes\" to confirm.");
|
|
1922
|
+
const client = getAppClientFromCommand(cmd);
|
|
1923
|
+
await deleteSecret(client, await resolveOwner(client, opts), key);
|
|
1924
|
+
printDone(`Secret ${key} deleted.`);
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
// src/follow-turn.ts
|
|
1928
|
+
/** Exit codes of a followed turn: completed, failed or cancelled, parked on a decision. */
|
|
1929
|
+
const TURN_EXIT = {
|
|
1930
|
+
completed: 0,
|
|
1931
|
+
failed: 1,
|
|
1932
|
+
decision: 3
|
|
1933
|
+
};
|
|
1934
|
+
/** The waits between reads of a parked turn's decisions, opened just after its commit. */
|
|
1935
|
+
const DECISION_RETRY_MS = [
|
|
1936
|
+
200,
|
|
1937
|
+
500,
|
|
1938
|
+
1e3,
|
|
1939
|
+
2e3
|
|
1940
|
+
];
|
|
1941
|
+
/**
|
|
1942
|
+
* Consecutive stream opens that may fail before a follow gives up (about 40 s
|
|
1943
|
+
* with the reader's back-off): a browser tab may wait out an outage, a shell
|
|
1944
|
+
* agent needs an exit. A deploy blip stays under it.
|
|
1945
|
+
*/
|
|
1946
|
+
const MAX_FAILED_OPENS = 10;
|
|
1947
|
+
/** The newest history page: its `committedSeq` is the cursor a stream opens after. */
|
|
1948
|
+
async function readHistoryPage(client, sessionId, limit) {
|
|
1949
|
+
const page = await client.agentMessages.list({
|
|
1950
|
+
id: sessionId,
|
|
1951
|
+
history: "1",
|
|
1952
|
+
...limit !== void 0 && { limit }
|
|
1953
|
+
});
|
|
1954
|
+
if (Array.isArray(page)) throw new SchemaError("The messages route did not answer a history window", SchemaErrorCode.INVARIANT_VIOLATION);
|
|
1955
|
+
return page;
|
|
1956
|
+
}
|
|
1957
|
+
function sleep$1(ms) {
|
|
1958
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1959
|
+
}
|
|
1960
|
+
/** The session's open decisions, read again until `wanted` exist or the retries run out. */
|
|
1961
|
+
async function waitForDecisions(client, sessionId, wanted) {
|
|
1962
|
+
const link = `session:${sessionId}`;
|
|
1963
|
+
let decisions = await readOpenDecisions(client, link);
|
|
1964
|
+
for (const delay of DECISION_RETRY_MS) {
|
|
1965
|
+
if (decisions.length >= wanted) break;
|
|
1966
|
+
await sleep$1(delay);
|
|
1967
|
+
decisions = await readOpenDecisions(client, link);
|
|
1968
|
+
}
|
|
1969
|
+
return decisions;
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Prints the decisions a parked session waits on and answers exit 3; when
|
|
1973
|
+
* none is open yet, says where to look.
|
|
1974
|
+
*/
|
|
1975
|
+
function reportDecisions(decisions, options) {
|
|
1976
|
+
if (decisions.length === 0) {
|
|
1977
|
+
process.stderr.write("decision not opened yet: run \"standards decisions list\"\n");
|
|
1978
|
+
return TURN_EXIT.decision;
|
|
1979
|
+
}
|
|
1980
|
+
for (const decision of decisions) options.write(options.human ? `\n${renderDecision(decision)}` : `${JSON.stringify({
|
|
1981
|
+
type: "decision",
|
|
1982
|
+
decision
|
|
1983
|
+
})}\n`);
|
|
1984
|
+
return TURN_EXIT.decision;
|
|
1985
|
+
}
|
|
1986
|
+
/** A writer that knows whether the text so far ends a line, so markers start their own. */
|
|
1987
|
+
function createTurnWriter(options) {
|
|
1988
|
+
let atLineStart = true;
|
|
1989
|
+
return {
|
|
1990
|
+
text(text) {
|
|
1991
|
+
if (text === "") return;
|
|
1992
|
+
if (options.human) {
|
|
1993
|
+
options.write(text);
|
|
1994
|
+
atLineStart = text.endsWith("\n");
|
|
1995
|
+
} else options.write(`${JSON.stringify({
|
|
1996
|
+
type: "text",
|
|
1997
|
+
text
|
|
1998
|
+
})}\n`);
|
|
1999
|
+
},
|
|
2000
|
+
/** A line of its own in human mode, an NDJSON object otherwise. */
|
|
2001
|
+
marker(line, json) {
|
|
2002
|
+
if (!options.human) {
|
|
2003
|
+
options.write(`${JSON.stringify(json)}\n`);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
options.write(`${atLineStart ? "" : "\n"}${line}\n`);
|
|
2007
|
+
atLineStart = true;
|
|
2008
|
+
},
|
|
2009
|
+
endLine() {
|
|
2010
|
+
if (options.human && !atLineStart) options.write("\n");
|
|
2011
|
+
atLineStart = true;
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* The end a session's state records once no turn is running: what a follow
|
|
2017
|
+
* resolves with when a 410 skipped the end event. `undefined` while running.
|
|
2018
|
+
*/
|
|
2019
|
+
function endFromState(state, inputRequests) {
|
|
2020
|
+
if (state.phase === "running") return void 0;
|
|
2021
|
+
const outcome = state.outcome ?? "completed";
|
|
2022
|
+
return {
|
|
2023
|
+
type: state.phase === "parked" ? "turn.completed" : `turn.${outcome}`,
|
|
2024
|
+
data: {
|
|
2025
|
+
outcome,
|
|
2026
|
+
phase: state.phase
|
|
2027
|
+
},
|
|
2028
|
+
inputRequests
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
function includes(ids, id) {
|
|
2032
|
+
return id !== void 0 && (ids ?? []).includes(id);
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Streams one turn to its end: adopts the turn (the one carrying our
|
|
2036
|
+
* `clientMessageId`, the named `turnId`, or the next to start), writes its
|
|
2037
|
+
* text and tool calls, and resolves with its end event. The shared reader
|
|
2038
|
+
* reconnects at the cursor; a 410 reopens at the newest page's `committedSeq`,
|
|
2039
|
+
* unless the session no longer runs a turn: its state then says how ours ended.
|
|
2040
|
+
* A refused stream (401 included: the CLI never re-authenticates) rejects.
|
|
2041
|
+
*/
|
|
2042
|
+
function streamTurn(client, sessionId, options) {
|
|
2043
|
+
const out = createTurnWriter(options);
|
|
2044
|
+
let cursor = options.cursor;
|
|
2045
|
+
let turnId = options.turnId;
|
|
2046
|
+
let inputRequests = 0;
|
|
2047
|
+
let failedOpens = 0;
|
|
2048
|
+
const toolCalls = /* @__PURE__ */ new Set();
|
|
2049
|
+
function adopt(event) {
|
|
2050
|
+
if (event.turnId === void 0) return;
|
|
2051
|
+
const ours = options.clientMessageId;
|
|
2052
|
+
if (ours === void 0) {
|
|
2053
|
+
if (event.type === "turn.started") turnId = event.turnId;
|
|
1168
2054
|
return;
|
|
1169
2055
|
}
|
|
1170
|
-
if (
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
2056
|
+
if ((event.type === "turn.started" || event.type === "message.consumed") && includes(event.clientMessageIds, ours)) turnId = event.turnId;
|
|
2057
|
+
}
|
|
2058
|
+
function foldChunk(data) {
|
|
2059
|
+
if (!isPlainRecord(data)) return;
|
|
2060
|
+
if (data.type === "text-delta" && typeof data.delta === "string") {
|
|
2061
|
+
out.text(data.delta);
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
if ((data.type === "tool-input-start" || data.type === "tool-input-available") && typeof data.toolName === "string" && typeof data.toolCallId === "string" && !toolCalls.has(data.toolCallId)) {
|
|
2065
|
+
toolCalls.add(data.toolCallId);
|
|
2066
|
+
out.marker(`→ tool ${data.toolName}`, {
|
|
2067
|
+
type: "tool",
|
|
2068
|
+
name: data.toolName
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
return new Promise((resolve, reject) => {
|
|
2073
|
+
function fail(error) {
|
|
2074
|
+
handle.close();
|
|
2075
|
+
reject(error);
|
|
2076
|
+
}
|
|
2077
|
+
function finish(end) {
|
|
2078
|
+
handle.close();
|
|
2079
|
+
out.endLine();
|
|
2080
|
+
resolve(end);
|
|
2081
|
+
}
|
|
2082
|
+
const handle = openAgentStream({
|
|
2083
|
+
open: async (id, after, signal, onRequest) => {
|
|
2084
|
+
try {
|
|
2085
|
+
const response = await client.agentSessions.stream({
|
|
2086
|
+
id,
|
|
2087
|
+
after
|
|
2088
|
+
}, {
|
|
2089
|
+
signal,
|
|
2090
|
+
onRequest,
|
|
2091
|
+
headers: { Accept: "text/event-stream" }
|
|
2092
|
+
});
|
|
2093
|
+
failedOpens = 0;
|
|
2094
|
+
return response;
|
|
2095
|
+
} catch (error) {
|
|
2096
|
+
failedOpens += 1;
|
|
2097
|
+
if (failedOpens >= MAX_FAILED_OPENS) fail(new ValidationError(`The session stream did not open after ${MAX_FAILED_OPENS} attempts`, []));
|
|
2098
|
+
throw error;
|
|
2099
|
+
}
|
|
2100
|
+
},
|
|
2101
|
+
sessionId,
|
|
2102
|
+
cursor: () => cursor,
|
|
2103
|
+
onEvent: (event) => {
|
|
2104
|
+
cursor = event.seq;
|
|
2105
|
+
if (turnId === void 0) adopt(event);
|
|
2106
|
+
if (turnId === void 0 || event.turnId !== turnId) return;
|
|
2107
|
+
if (TURN_END_TYPES.has(event.type)) {
|
|
2108
|
+
finish({
|
|
2109
|
+
type: event.type,
|
|
2110
|
+
data: isPlainRecord(event.data) ? event.data : {},
|
|
2111
|
+
inputRequests
|
|
2112
|
+
});
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
if (event.type === "chunk") foldChunk(event.data);
|
|
2116
|
+
else if (event.type === "step.reset") out.marker("↺ retrying step", { type: "reset" });
|
|
2117
|
+
else if (event.type === "input.requested") inputRequests += 1;
|
|
2118
|
+
},
|
|
2119
|
+
onControl: () => void 0,
|
|
2120
|
+
onStatus: (status, error) => {
|
|
2121
|
+
if (status !== "error" && status !== "auth-blocked") return;
|
|
2122
|
+
fail(error ?? new ValidationError("The session stream was refused", []));
|
|
2123
|
+
},
|
|
2124
|
+
onExpired: async () => {
|
|
2125
|
+
const page = await readHistoryPage(client, sessionId, 1);
|
|
2126
|
+
cursor = page.committedSeq;
|
|
2127
|
+
if (page.messages.some((message) => message.waiting)) return;
|
|
2128
|
+
const end = endFromState(page.sessionState ?? (await client.agentSessions.get({ id: sessionId })).state, inputRequests);
|
|
2129
|
+
if (end) finish(end);
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
function failureText(data) {
|
|
2135
|
+
if (typeof data.error === "string") return `: ${data.error}`;
|
|
2136
|
+
if (typeof data.code === "string") return `: ${data.code}`;
|
|
2137
|
+
return "";
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Follows one turn of a session to its end (spec §3) and answers its exit
|
|
2141
|
+
* code: 0 completed, 1 failed or cancelled, 3 parked on a decision (printed).
|
|
2142
|
+
* Never exits the process: the command does.
|
|
2143
|
+
*/
|
|
2144
|
+
async function followTurn(client, sessionId, options) {
|
|
2145
|
+
const end = await streamTurn(client, sessionId, options);
|
|
2146
|
+
const parked = end.type === "turn.completed" && end.data.phase === "parked";
|
|
2147
|
+
const outcome = typeof end.data.outcome === "string" ? end.data.outcome : end.type.replace("turn.", "");
|
|
2148
|
+
if (!options.human) options.write(`${JSON.stringify({
|
|
2149
|
+
type: "end",
|
|
2150
|
+
outcome,
|
|
2151
|
+
...parked && { phase: "parked" }
|
|
2152
|
+
})}\n`);
|
|
2153
|
+
if (end.type === "turn.cancelled") {
|
|
2154
|
+
if (options.human) process.stderr.write("✗ cancelled\n");
|
|
2155
|
+
return TURN_EXIT.failed;
|
|
2156
|
+
}
|
|
2157
|
+
if (end.type !== "turn.completed") {
|
|
2158
|
+
if (options.human) process.stderr.write(`✗ turn failed${failureText(end.data)}\n`);
|
|
2159
|
+
return TURN_EXIT.failed;
|
|
2160
|
+
}
|
|
2161
|
+
if (!parked) return TURN_EXIT.completed;
|
|
2162
|
+
return reportDecisions(await waitForDecisions(client, sessionId, Math.max(1, end.inputRequests)), options);
|
|
2163
|
+
}
|
|
2164
|
+
// src/commands/sessions.ts
|
|
2165
|
+
/** A session as one line of a list: what a model reads to pick the next one. */
|
|
2166
|
+
function summarizeSession(session) {
|
|
2167
|
+
return {
|
|
2168
|
+
id: session.id,
|
|
2169
|
+
name: session.name ?? null,
|
|
2170
|
+
phase: session.state.phase,
|
|
2171
|
+
outcome: session.state.outcome ?? null,
|
|
2172
|
+
updatedAt: session.updatedAt
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
/** The caller's sessions running or parked on a decision, optionally narrowed by `q` and `limit`. */
|
|
2176
|
+
async function readActiveSessions(client, query = {}) {
|
|
2177
|
+
return (await Promise.all(["running", "parked"].map((phase) => client.agentSessions.list({
|
|
2178
|
+
...query,
|
|
2179
|
+
phase
|
|
2180
|
+
})))).flat();
|
|
2181
|
+
}
|
|
2182
|
+
/** `--harness`: a catalogue id, or `standards` (the built-in agent), which sends none. */
|
|
2183
|
+
function parseHarness(value) {
|
|
2184
|
+
return value === void 0 ? void 0 : parseHarnessIds([value])?.[0];
|
|
2185
|
+
}
|
|
2186
|
+
function isProviderName(value) {
|
|
2187
|
+
return PROVIDER_NAMES.includes(value);
|
|
2188
|
+
}
|
|
2189
|
+
/** `--model`: a model of the harness's catalogue, or `<provider>/<model>` without a harness. */
|
|
2190
|
+
function parseModel(harness, value) {
|
|
2191
|
+
if (harness) return {
|
|
2192
|
+
provider: environmentHarness(harness).provider,
|
|
2193
|
+
model: value
|
|
2194
|
+
};
|
|
2195
|
+
const slash = value.indexOf("/");
|
|
2196
|
+
const provider = slash > 0 ? value.slice(0, slash) : "";
|
|
2197
|
+
const model = value.slice(slash + 1);
|
|
2198
|
+
if (!(isProviderName(provider) && model)) throw new ValidationError(`--model takes <provider>/<model> without --harness (providers: ${PROVIDER_NAMES.join(", ")}): got "${value}"`, []);
|
|
2199
|
+
return {
|
|
2200
|
+
provider,
|
|
2201
|
+
model
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
/** A shell word: the task as `sessions send` would take it back. */
|
|
2205
|
+
function shellQuote(value) {
|
|
2206
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
2207
|
+
}
|
|
2208
|
+
function write(text) {
|
|
2209
|
+
process.stdout.write(text);
|
|
2210
|
+
}
|
|
2211
|
+
/** The cursor a follow opens after: the newest page's `committedSeq`, read before sending. */
|
|
2212
|
+
async function cursorBeforeSend(client, id) {
|
|
2213
|
+
return (await readHistoryPage(client, id, 1)).committedSeq;
|
|
2214
|
+
}
|
|
2215
|
+
/** Sends `content` with a fresh `clientMessageId`, the id its turn is recognised by. */
|
|
2216
|
+
async function sendMessage(client, id, content) {
|
|
2217
|
+
const clientMessageId = randomUUID();
|
|
2218
|
+
await client.agentMessages.send({
|
|
2219
|
+
id,
|
|
2220
|
+
content,
|
|
2221
|
+
clientMessageId
|
|
2222
|
+
});
|
|
2223
|
+
return clientMessageId;
|
|
2224
|
+
}
|
|
2225
|
+
/** After a send: `{ id, url }` when detached, else the turn followed to its end. */
|
|
2226
|
+
async function followOrDetach(cmd, client, id, sent, detach) {
|
|
2227
|
+
const url = conversationUrl(cmd, id);
|
|
2228
|
+
if (detach) {
|
|
2229
|
+
formatOutput({
|
|
2230
|
+
id,
|
|
2231
|
+
url
|
|
2232
|
+
}, getFormat(cmd));
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
process.stderr.write(`session ${id} ${url}\n`);
|
|
2236
|
+
const code = await followTurn(client, id, {
|
|
2237
|
+
...sent,
|
|
2238
|
+
human: isHumanOutput(cmd),
|
|
2239
|
+
write
|
|
2240
|
+
});
|
|
2241
|
+
if (code !== 0) process.exitCode = code;
|
|
2242
|
+
}
|
|
2243
|
+
/** The last assistant text of a history page, or `null`. */
|
|
2244
|
+
function lastReplyOf(page) {
|
|
2245
|
+
for (const message of [...page.messages].reverse()) {
|
|
2246
|
+
if (message.role !== "assistant") continue;
|
|
2247
|
+
const text = message.parts.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
|
|
2248
|
+
if (text) return text;
|
|
2249
|
+
}
|
|
2250
|
+
return null;
|
|
2251
|
+
}
|
|
2252
|
+
function hasWaitingMessage(page) {
|
|
2253
|
+
return page.messages.some((message) => message.role === "user" && message.waiting === true);
|
|
2254
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* `sessions follow`: the running turn, or the next one to start; an idle
|
|
2257
|
+
* session with nothing waiting, or a parked one with its decisions open,
|
|
2258
|
+
* answers at once. The page is read before the session, so a turn that ends
|
|
2259
|
+
* between the two reads still has its end after the cursor.
|
|
2260
|
+
*/
|
|
2261
|
+
async function followSession(cmd, client, id) {
|
|
2262
|
+
const human = isHumanOutput(cmd);
|
|
2263
|
+
const page = await readHistoryPage(client, id, 10);
|
|
2264
|
+
const { state } = await client.agentSessions.get({ id });
|
|
2265
|
+
const follow = (turnId) => followTurn(client, id, {
|
|
2266
|
+
cursor: page.committedSeq,
|
|
2267
|
+
...turnId && { turnId },
|
|
2268
|
+
human,
|
|
2269
|
+
write
|
|
2270
|
+
});
|
|
2271
|
+
if (state.phase === "running") return follow(state.runningTurnId);
|
|
2272
|
+
if (state.phase === "parked") {
|
|
2273
|
+
const decisions = await waitForDecisions(client, id, 1);
|
|
2274
|
+
return decisions.length > 0 ? reportDecisions(decisions, {
|
|
2275
|
+
human,
|
|
2276
|
+
write
|
|
2277
|
+
}) : follow();
|
|
2278
|
+
}
|
|
2279
|
+
if (hasWaitingMessage(page)) return follow();
|
|
2280
|
+
const outcome = state.outcome ?? "completed";
|
|
2281
|
+
if (human) process.stderr.write(`session ${id} is idle (${outcome})\n`);
|
|
2282
|
+
else write(`${JSON.stringify({
|
|
2283
|
+
type: "end",
|
|
2284
|
+
outcome
|
|
2285
|
+
})}\n`);
|
|
2286
|
+
return outcome === "completed" ? TURN_EXIT.completed : TURN_EXIT.failed;
|
|
2287
|
+
}
|
|
2288
|
+
function isNotFound(error) {
|
|
2289
|
+
return error instanceof StandardsRequestError$1 && error.status === 404;
|
|
2290
|
+
}
|
|
2291
|
+
function registerSessionsCommand(program) {
|
|
2292
|
+
const sessions = program.command("sessions").description("Start, follow and steer agent sessions (exit 0 done, 1 failed, 3 decision)");
|
|
2293
|
+
sessions.command("start").description("Start a session on a task and follow its first turn").argument("<task>", "what the agent should do").option("--harness <id>", "claude-code, codex, cursor, or standards (the built-in agent)").option("--env <env>", "the environment it runs in (name or id)").addOption(new Option("--size <size>", "the machine size of its turns").choices(ENVIRONMENT_SIZES)).option("--model <id>", "a model of the harness (or <provider>/<model> without --harness)").option("--name <name>", "the conversation's title").option("--detach", "send and return { id, url } without following").action(async (task, opts, cmd) => {
|
|
2294
|
+
const client = getAppClientFromCommand(cmd);
|
|
2295
|
+
const harness = parseHarness(opts.harness);
|
|
2296
|
+
const model = opts.model === void 0 ? void 0 : parseModel(harness, opts.model);
|
|
2297
|
+
const environmentId = opts.env ? await resolveEnvironmentId(client, opts.env) : void 0;
|
|
2298
|
+
const id = randomUUID();
|
|
2299
|
+
await client.agentSessions.create({
|
|
2300
|
+
mode: "interactive",
|
|
2301
|
+
id,
|
|
2302
|
+
...harness && { harness },
|
|
2303
|
+
...environmentId && { environmentId },
|
|
2304
|
+
...opts.size && { size: opts.size },
|
|
2305
|
+
...model && { inferenceOverrides: { model } },
|
|
2306
|
+
...opts.name && { name: opts.name }
|
|
2307
|
+
});
|
|
2308
|
+
let clientMessageId;
|
|
2309
|
+
try {
|
|
2310
|
+
clientMessageId = await sendMessage(client, id, task);
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
process.stderr.write(`${JSON.stringify({
|
|
2313
|
+
id,
|
|
2314
|
+
url: conversationUrl(cmd, id)
|
|
2315
|
+
})}\n`);
|
|
2316
|
+
process.stderr.write(`Session created, message not sent: standards sessions send ${id} ${shellQuote(task)}\n`);
|
|
2317
|
+
throw error;
|
|
2318
|
+
}
|
|
2319
|
+
await followOrDetach(cmd, client, id, {
|
|
2320
|
+
cursor: 0,
|
|
2321
|
+
clientMessageId
|
|
2322
|
+
}, opts.detach);
|
|
2323
|
+
});
|
|
2324
|
+
sessions.command("send").description("Send a message to a session and follow the turn it starts").argument("<id>", "session id").argument("<message>", "the message").option("--detach", "send and return { id, url } without following").action(async (id, message, opts, cmd) => {
|
|
2325
|
+
const client = getAppClientFromCommand(cmd);
|
|
2326
|
+
await followOrDetach(cmd, client, id, {
|
|
2327
|
+
cursor: await cursorBeforeSend(client, id),
|
|
2328
|
+
clientMessageId: await sendMessage(client, id, message)
|
|
2329
|
+
}, opts.detach);
|
|
2330
|
+
});
|
|
2331
|
+
sessions.command("follow").description("Follow the running or next turn; returns at once when idle or waiting on a decision").argument("<id>", "session id").action(async (id, _opts, cmd) => {
|
|
2332
|
+
const code = await followSession(cmd, getAppClientFromCommand(cmd), id);
|
|
2333
|
+
if (code !== 0) process.exitCode = code;
|
|
2334
|
+
});
|
|
2335
|
+
sessions.command("list").description("List your sessions").option("--running", "only sessions running or parked on a decision").option("--q <text>", "match the title").option("--limit <n>", "at most n sessions (per phase with --running)").action(async (opts, cmd) => {
|
|
2336
|
+
const client = getAppClientFromCommand(cmd);
|
|
2337
|
+
const query = {
|
|
2338
|
+
...opts.q && { q: opts.q },
|
|
2339
|
+
...opts.limit !== void 0 && { limit: parseCount("--limit", opts.limit) }
|
|
2340
|
+
};
|
|
2341
|
+
formatOutput((opts.running ? await readActiveSessions(client, query) : await client.agentSessions.list(query)).map(summarizeSession), getFormat(cmd));
|
|
2342
|
+
});
|
|
2343
|
+
sessions.command("get").description("Show a session: phase, last reply, machine, links, open decisions").argument("<id>", "session id").action(async (id, _opts, cmd) => {
|
|
2344
|
+
const client = getAppClientFromCommand(cmd);
|
|
2345
|
+
const [session, page, machine, decisions] = await Promise.all([
|
|
2346
|
+
client.agentSessions.get({ id }),
|
|
2347
|
+
readHistoryPage(client, id),
|
|
2348
|
+
client.machines.forSession({ id }).catch((error) => {
|
|
2349
|
+
if (isNotFound(error)) return null;
|
|
2350
|
+
throw error;
|
|
2351
|
+
}),
|
|
2352
|
+
readOpenDecisions(client, `session:${id}`)
|
|
2353
|
+
]);
|
|
2354
|
+
formatOutput({
|
|
2355
|
+
session,
|
|
2356
|
+
phase: session.state.phase,
|
|
2357
|
+
lastReply: lastReplyOf(page),
|
|
2358
|
+
waiting: hasWaitingMessage(page),
|
|
2359
|
+
machine,
|
|
2360
|
+
links: machine?.links ?? [],
|
|
2361
|
+
decisions
|
|
2362
|
+
}, getFormat(cmd));
|
|
2363
|
+
});
|
|
2364
|
+
sessions.command("messages").description("The last messages of a session").argument("<id>", "session id").requiredOption("--last <n>", "how many, newest last (at most 100)").action(async (id, opts, cmd) => {
|
|
2365
|
+
const last = parseCount("--last", opts.last);
|
|
2366
|
+
if (last < 1) throw new ValidationError("--last must be at least 1", []);
|
|
2367
|
+
formatOutput((await readHistoryPage(getAppClientFromCommand(cmd), id, last)).messages, getFormat(cmd));
|
|
2368
|
+
});
|
|
2369
|
+
sessions.command("cancel").description("Stop the running turn, or a parked session's waits").argument("<id>", "session id").action(async (id, _opts, cmd) => {
|
|
2370
|
+
const client = getAppClientFromCommand(cmd);
|
|
2371
|
+
const { state } = await client.agentSessions.get({ id });
|
|
2372
|
+
const turnId = state.runningTurnId ?? state.lastTurnId;
|
|
2373
|
+
formatOutput(await client.agentSessions.cancel({
|
|
2374
|
+
id,
|
|
2375
|
+
...turnId && { turnId }
|
|
2376
|
+
}), getFormat(cmd));
|
|
2377
|
+
});
|
|
2378
|
+
sessions.command("archive").description("Archive a session").argument("<id>", "session id").action(async (id, _opts, cmd) => {
|
|
2379
|
+
await getAppClientFromCommand(cmd).agentSessions.archive({ id });
|
|
2380
|
+
printDone(`Session ${id} archived.`);
|
|
1174
2381
|
});
|
|
1175
2382
|
}
|
|
1176
2383
|
// src/commands/skills.ts
|
|
@@ -1310,17 +2517,13 @@ function catalogRow(result) {
|
|
|
1310
2517
|
installs
|
|
1311
2518
|
};
|
|
1312
2519
|
}
|
|
1313
|
-
/**
|
|
2520
|
+
/**
|
|
2521
|
+
* `standards skills search`: with a query, our index or skills.sh with
|
|
2522
|
+
* `community`; with none, skills.sh's all-time leaderboard, or the last 24
|
|
2523
|
+
* hours with `trending`, keeping each row's weekly installs.
|
|
2524
|
+
*/
|
|
1314
2525
|
async function runSkillsSearch(client, query, options = {}) {
|
|
1315
|
-
return (await client.skillCatalog.search({
|
|
1316
|
-
query,
|
|
1317
|
-
scope: options.community === true ? "community" : "official",
|
|
1318
|
-
limit: searchLimit(options.limit)
|
|
1319
|
-
})).map(catalogRow);
|
|
1320
|
-
}
|
|
1321
|
-
/** `standards skills top`: skills.sh's all-time leaderboard, or the last 24 hours with `trending`. */
|
|
1322
|
-
async function runSkillsTop(client, options = {}) {
|
|
1323
|
-
return (await client.skillCatalog.search({
|
|
2526
|
+
if (!query) return (await client.skillCatalog.search({
|
|
1324
2527
|
query: "",
|
|
1325
2528
|
scope: options.trending === true ? "trending" : "top",
|
|
1326
2529
|
limit: searchLimit(options.limit)
|
|
@@ -1328,8 +2531,13 @@ async function runSkillsTop(client, options = {}) {
|
|
|
1328
2531
|
...catalogRow(result),
|
|
1329
2532
|
weeklyInstalls: result.weeklyInstalls
|
|
1330
2533
|
}));
|
|
2534
|
+
return (await client.skillCatalog.search({
|
|
2535
|
+
query,
|
|
2536
|
+
scope: options.community === true ? "community" : "official",
|
|
2537
|
+
limit: searchLimit(options.limit)
|
|
2538
|
+
})).map(catalogRow);
|
|
1331
2539
|
}
|
|
1332
|
-
/** `standards skills
|
|
2540
|
+
/** `standards skills add --dry-run`: what a source would install, the source checked here first. */
|
|
1333
2541
|
async function runSkillsPreview(client, source) {
|
|
1334
2542
|
parseSkillSource(source);
|
|
1335
2543
|
return await client.skillCatalog.preview({ source });
|
|
@@ -1405,17 +2613,15 @@ function registerSkillsCommand(program) {
|
|
|
1405
2613
|
for (const warning of outcome.result.warnings) console.warn(styleText("yellow", `! ${warning}`));
|
|
1406
2614
|
formatOutput(outcome.result, getFormat(cmd));
|
|
1407
2615
|
});
|
|
1408
|
-
skills.command("search").description("Find skills to add: the official catalogue,
|
|
2616
|
+
skills.command("search").description("Find skills to add: the official catalogue, skills.sh with --community, or with no query skills.sh's leaderboard").argument("[query]", "what the skill should do").option("--community", "search skills.sh instead of the official catalogue").option("--trending", "with no query, rank by the last 24 hours instead of all time").option("--limit <n>", "max results to return (up to 50)").action(async (query, opts, cmd) => {
|
|
2617
|
+
if (!(query || opts.community)) throw new ValidationError("Pass a query, or \"--community\" to list skills.sh's leaderboard", []);
|
|
1409
2618
|
await printAnswer(cmd, () => runSkillsSearch(getAppClientFromCommand(cmd), query, opts));
|
|
1410
2619
|
});
|
|
1411
|
-
skills.command("
|
|
1412
|
-
await printAnswer(cmd, () =>
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
});
|
|
1417
|
-
skills.command("add").description("Install a skill from a GitHub repository: what its preview read lands, and an official skill then follows stable").argument("<source>", "owner/repo@skill or owner/repo@skill#ref").action(async (source, _opts, cmd) => {
|
|
1418
|
-
await printAnswer(cmd, () => runSkillsAdd(getAppClientFromCommand(cmd), source));
|
|
2620
|
+
skills.command("add").description("Install a skill from a GitHub repository: what its preview read lands, and an official skill then follows stable").argument("<source>", "owner/repo@skill or owner/repo@skill#ref").option("--dry-run", "show what the source would install, without installing it").action(async (source, opts, cmd) => {
|
|
2621
|
+
await printAnswer(cmd, () => {
|
|
2622
|
+
const client = getAppClientFromCommand(cmd);
|
|
2623
|
+
return opts.dryRun ? runSkillsPreview(client, source) : runSkillsAdd(client, source);
|
|
2624
|
+
});
|
|
1419
2625
|
});
|
|
1420
2626
|
skills.command("update").description("Bring an installed catalogue skill to the latest version of its source").argument("<name>", "name of the installed skill").option("--discard-changes", "overwrite the edits the workspace made to the skill").action(async (name, opts, cmd) => {
|
|
1421
2627
|
await printAnswer(cmd, () => runSkillsUpdate(getAppClientFromCommand(cmd), name, opts));
|
|
@@ -1424,6 +2630,261 @@ function registerSkillsCommand(program) {
|
|
|
1424
2630
|
await printAnswer(cmd, () => runSkillsRefresh(getAppClientFromCommand(cmd)));
|
|
1425
2631
|
});
|
|
1426
2632
|
}
|
|
2633
|
+
// src/commands/status.ts
|
|
2634
|
+
function summarizeDecision(decision) {
|
|
2635
|
+
return {
|
|
2636
|
+
id: decision.id,
|
|
2637
|
+
type: decision.type,
|
|
2638
|
+
title: decision.title,
|
|
2639
|
+
sessionId: sessionIdOf(decision) ?? null
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
function summarizeMachine(detail) {
|
|
2643
|
+
return {
|
|
2644
|
+
id: detail.machine.id,
|
|
2645
|
+
owner: detail.owner.label,
|
|
2646
|
+
pilot: detail.pilot,
|
|
2647
|
+
links: detail.links.map((link) => ({
|
|
2648
|
+
id: link.id,
|
|
2649
|
+
slug: link.slug,
|
|
2650
|
+
url: link.url
|
|
2651
|
+
}))
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
/** Sessions running or parked, and open decisions: the database reads `watch` makes every poll. */
|
|
2655
|
+
async function readSessionsAndDecisions(client) {
|
|
2656
|
+
const [sessions, decisions] = await Promise.all([readActiveSessions(client), readOpenDecisions(client)]);
|
|
2657
|
+
return {
|
|
2658
|
+
sessions: sessions.map(summarizeSession),
|
|
2659
|
+
decisions: decisions.map(summarizeDecision)
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
/** The running machines with their links, every page (the provider's listing, read sparingly). */
|
|
2663
|
+
async function readRunningMachines(client) {
|
|
2664
|
+
return (await readAllPages((cursor) => client.machines.list(cursor ? { cursor } : {}))).filter((item) => item.machine.status === "running").map(summarizeMachine);
|
|
2665
|
+
}
|
|
2666
|
+
function quoted(text) {
|
|
2667
|
+
return JSON.stringify(text);
|
|
2668
|
+
}
|
|
2669
|
+
function sessionTransition(previous, next) {
|
|
2670
|
+
if (previous === void 0) return next.phase === "idle" ? [] : [`session ${next.id} → ${next.phase}`];
|
|
2671
|
+
if (previous.phase === next.phase) return [];
|
|
2672
|
+
if (next.phase === "parked") return [`session ${next.id} → parked`];
|
|
2673
|
+
if (next.phase === "idle" && next.outcome !== null && next.outcome !== "completed") return [`session ${next.id} ${next.outcome}`];
|
|
2674
|
+
return [`session ${next.id} ${previous.phase}→${next.phase}`];
|
|
2675
|
+
}
|
|
2676
|
+
/** The session lines alone: a phase change is what makes `watch` read the machines. */
|
|
2677
|
+
function diffSessions(previous, next) {
|
|
2678
|
+
const before = new Map(previous.map((session) => [session.id, session]));
|
|
2679
|
+
return next.flatMap((session) => sessionTransition(before.get(session.id), session));
|
|
2680
|
+
}
|
|
2681
|
+
function linksOf(machines) {
|
|
2682
|
+
return machines.flatMap((machine) => machine.links);
|
|
2683
|
+
}
|
|
2684
|
+
/**
|
|
2685
|
+
* The transitions from one snapshot to the next, one line each, in order:
|
|
2686
|
+
* sessions, decisions, links, machines. Equal snapshots answer `[]`.
|
|
2687
|
+
*/
|
|
2688
|
+
function diffStatus(previous, next) {
|
|
2689
|
+
const decisionsBefore = new Set(previous.decisions.map((decision) => decision.id));
|
|
2690
|
+
const decisionsAfter = new Set(next.decisions.map((decision) => decision.id));
|
|
2691
|
+
const linksBefore = new Set(linksOf(previous.machines).map((link) => link.id));
|
|
2692
|
+
const machinesAfter = new Set(next.machines.map((machine) => machine.id));
|
|
2693
|
+
return [
|
|
2694
|
+
...diffSessions(previous.sessions, next.sessions),
|
|
2695
|
+
...next.decisions.filter((decision) => !decisionsBefore.has(decision.id)).map((decision) => `decision ${decision.id} opened ${quoted(decision.title)}`),
|
|
2696
|
+
...previous.decisions.filter((decision) => !decisionsAfter.has(decision.id)).map((decision) => `decision ${decision.id} answered`),
|
|
2697
|
+
...linksOf(next.machines).filter((link) => !linksBefore.has(link.id)).map((link) => `link ${link.slug} published ${link.url}`),
|
|
2698
|
+
...previous.machines.filter((machine) => !machinesAfter.has(machine.id)).map((machine) => `machine ${machine.id} stopped`)
|
|
2699
|
+
];
|
|
2700
|
+
}
|
|
2701
|
+
/** The current state as `snapshot` lines: what `watch` prints on its first poll. */
|
|
2702
|
+
function snapshotLines(snapshot) {
|
|
2703
|
+
return [
|
|
2704
|
+
...snapshot.sessions.map((session) => `snapshot session ${session.id} ${session.phase}`),
|
|
2705
|
+
...snapshot.decisions.map((decision) => `snapshot decision ${decision.id} ${quoted(decision.title)}`),
|
|
2706
|
+
...snapshot.machines.map((machine) => `snapshot machine ${machine.id} ${quoted(machine.owner)}`),
|
|
2707
|
+
...linksOf(snapshot.machines).map((link) => `snapshot link ${link.slug} ${link.url}`)
|
|
2708
|
+
];
|
|
2709
|
+
}
|
|
2710
|
+
function section(title, rows) {
|
|
2711
|
+
return `${title} (${rows.length})\n${rows.length === 0 ? " none\n" : rows.map((row) => ` ${row}\n`).join("")}`;
|
|
2712
|
+
}
|
|
2713
|
+
function renderStatus(snapshot) {
|
|
2714
|
+
return [
|
|
2715
|
+
section("Sessions", snapshot.sessions.map((session) => `${session.id} ${session.phase} ${session.name ?? ""}`)),
|
|
2716
|
+
section("Decisions", snapshot.decisions.map((decision) => `${decision.id} ${decision.type} ${decision.title}`)),
|
|
2717
|
+
section("Machines", snapshot.machines.map((machine) => [`${machine.id} ${machine.owner}`, ...machine.links.map((link) => ` ${link.url}`)].join("\n ")))
|
|
2718
|
+
].join("\n");
|
|
2719
|
+
}
|
|
2720
|
+
function registerStatusCommand(program) {
|
|
2721
|
+
program.command("status").description("Sessions running or parked, open decisions, running machines and their links").action(async (_opts, cmd) => {
|
|
2722
|
+
const client = getAppClientFromCommand(cmd);
|
|
2723
|
+
const [active, machines] = await Promise.all([readSessionsAndDecisions(client), readRunningMachines(client)]);
|
|
2724
|
+
const snapshot = {
|
|
2725
|
+
...active,
|
|
2726
|
+
machines
|
|
2727
|
+
};
|
|
2728
|
+
if (isHumanOutput(cmd)) process.stdout.write(renderStatus(snapshot));
|
|
2729
|
+
else formatOutput(snapshot, "json");
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
// src/commands/usage.ts
|
|
2733
|
+
const SCOPES = ["me", "workspace"];
|
|
2734
|
+
/** `GET /ai/usage` reads inclusive UTC days; anything else reaches the API as a 400. */
|
|
2735
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/u;
|
|
2736
|
+
/**
|
|
2737
|
+
* The window's own rules (maximum span, ordering) belong to the server, which
|
|
2738
|
+
* owns the calendar; the CLI only rejects what it can judge on its own.
|
|
2739
|
+
*/
|
|
2740
|
+
function parseDay(flag, value) {
|
|
2741
|
+
if (!ISO_DAY.test(value)) throw new ValidationError(`${flag} must be a YYYY-MM-DD date`, []);
|
|
2742
|
+
return value;
|
|
2743
|
+
}
|
|
2744
|
+
/** The current UTC month so far: its first day through today. */
|
|
2745
|
+
function currentMonth(now) {
|
|
2746
|
+
const today = now.toISOString().slice(0, 10);
|
|
2747
|
+
return {
|
|
2748
|
+
from: `${today.slice(0, 8)}01`,
|
|
2749
|
+
to: today
|
|
2750
|
+
};
|
|
2751
|
+
}
|
|
2752
|
+
/**
|
|
2753
|
+
* A read the caller may be refused (no `billing:read`, say) is `null` with its
|
|
2754
|
+
* reason on stderr; the command fails only when every read does.
|
|
2755
|
+
*/
|
|
2756
|
+
function settled(name, result) {
|
|
2757
|
+
if (result.status === "fulfilled") return result.value;
|
|
2758
|
+
process.stderr.write(`${styleText("yellow", `! ${name}: ${messageOf(result.reason)}`)}\n`);
|
|
2759
|
+
return null;
|
|
2760
|
+
}
|
|
2761
|
+
function registerUsageCommand(program) {
|
|
2762
|
+
program.command("usage").description("Show AI usage over a UTC day window, the AI wallet and the quotas").option("--from <day>", "first day of the window (YYYY-MM-DD, default: first of the month)").option("--to <day>", "last day of the window, inclusive (YYYY-MM-DD, default: today)").addOption(new Option("--scope <scope>", "me or workspace").choices(SCOPES).default("me")).action(async (opts, cmd) => {
|
|
2763
|
+
const month = currentMonth(/* @__PURE__ */ new Date());
|
|
2764
|
+
const params = {
|
|
2765
|
+
from: parseDay("--from", opts.from ?? month.from),
|
|
2766
|
+
to: parseDay("--to", opts.to ?? month.to),
|
|
2767
|
+
scope: opts.scope
|
|
2768
|
+
};
|
|
2769
|
+
const client = getAppClientFromCommand(cmd);
|
|
2770
|
+
const [usage, wallet, quotas] = await Promise.allSettled([
|
|
2771
|
+
client.ai.usage(params),
|
|
2772
|
+
client.ai.wallet({}),
|
|
2773
|
+
client.quotas.list({})
|
|
2774
|
+
]);
|
|
2775
|
+
if (usage.status === "rejected" && wallet.status === "rejected" && quotas.status === "rejected") throw usage.reason;
|
|
2776
|
+
formatOutput({
|
|
2777
|
+
usage: settled("usage", usage),
|
|
2778
|
+
wallet: settled("wallet", wallet),
|
|
2779
|
+
quotas: settled("quotas", quotas)
|
|
2780
|
+
}, getFormat(cmd));
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
// src/commands/watch.ts
|
|
2784
|
+
/** The shortest interval between two polls. */
|
|
2785
|
+
const MIN_INTERVAL_MS = 2e3;
|
|
2786
|
+
/** Machines (the provider's listing) are read at most this often unless a session changed phase. */
|
|
2787
|
+
const MACHINES_EVERY_MS = 6e4;
|
|
2788
|
+
/** Consecutive failed polls after which `watch` gives up. */
|
|
2789
|
+
const MAX_FAILURES = 5;
|
|
2790
|
+
const UNIT_MS = {
|
|
2791
|
+
ms: 1,
|
|
2792
|
+
s: 1e3,
|
|
2793
|
+
m: 6e4
|
|
2794
|
+
};
|
|
2795
|
+
/** `10s`, `2s`, `1m`, `500ms`; under `2s` is refused. */
|
|
2796
|
+
function parseInterval(value) {
|
|
2797
|
+
return parseDuration("--interval", value, {
|
|
2798
|
+
units: UNIT_MS,
|
|
2799
|
+
example: "10s or 1m",
|
|
2800
|
+
limit: "at least 2s",
|
|
2801
|
+
min: MIN_INTERVAL_MS
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
function sleep(ms) {
|
|
2805
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2806
|
+
}
|
|
2807
|
+
/**
|
|
2808
|
+
* The sessions that left the running and parked lists since the last poll,
|
|
2809
|
+
* read once more so their new phase (idle, failed) shows; one gone for good
|
|
2810
|
+
* (archived, deleted) is dropped.
|
|
2811
|
+
*/
|
|
2812
|
+
async function readLeftSessions(client, previous, current) {
|
|
2813
|
+
const listed = new Set(current.map((session) => session.id));
|
|
2814
|
+
const left = previous.filter((session) => session.phase !== "idle" && !listed.has(session.id));
|
|
2815
|
+
return (await Promise.all(left.map((session) => client.agentSessions.get({ id: session.id }).then(summarizeSession, () => null)))).filter((session) => session !== null);
|
|
2816
|
+
}
|
|
2817
|
+
function isOpenedDecision(line) {
|
|
2818
|
+
return line.startsWith("decision ") && line.includes(" opened ");
|
|
2819
|
+
}
|
|
2820
|
+
function isSnapshotDecision(line) {
|
|
2821
|
+
return line.startsWith("snapshot decision ");
|
|
2822
|
+
}
|
|
2823
|
+
/** The decision lines last: the one a waiting agent acts on ends the output. */
|
|
2824
|
+
function decisionsLast(lines, isDecision) {
|
|
2825
|
+
return [...lines.filter((line) => !isDecision(line)), ...lines.filter(isDecision)];
|
|
2826
|
+
}
|
|
2827
|
+
/**
|
|
2828
|
+
* Polls until a decision is open (`--until decision`, 3: one already open at
|
|
2829
|
+
* the first poll counts) or 5 polls fail in a row (1).
|
|
2830
|
+
*/
|
|
2831
|
+
async function watch(client, options) {
|
|
2832
|
+
const emit = (type, line) => process.stdout.write(options.human ? `${line}\n` : `${JSON.stringify({
|
|
2833
|
+
type,
|
|
2834
|
+
line
|
|
2835
|
+
})}\n`);
|
|
2836
|
+
let previous;
|
|
2837
|
+
let machinesReadAt = Number.NEGATIVE_INFINITY;
|
|
2838
|
+
let failures = 0;
|
|
2839
|
+
for (;;) {
|
|
2840
|
+
try {
|
|
2841
|
+
const active = await readSessionsAndDecisions(client);
|
|
2842
|
+
const sessions = previous ? [...active.sessions, ...await readLeftSessions(client, previous.sessions, active.sessions)] : active.sessions;
|
|
2843
|
+
const phaseChanged = previous === void 0 || diffSessions(previous.sessions, sessions).length > 0;
|
|
2844
|
+
let machines = previous?.machines ?? [];
|
|
2845
|
+
if (phaseChanged || Date.now() - machinesReadAt >= MACHINES_EVERY_MS) {
|
|
2846
|
+
machines = await readRunningMachines(client).catch((error) => {
|
|
2847
|
+
emit("machines-failed", `machines read failed: ${messageOf(error)}`);
|
|
2848
|
+
return previous?.machines ?? [];
|
|
2849
|
+
});
|
|
2850
|
+
machinesReadAt = Date.now();
|
|
2851
|
+
}
|
|
2852
|
+
const next = {
|
|
2853
|
+
sessions,
|
|
2854
|
+
decisions: active.decisions,
|
|
2855
|
+
machines
|
|
2856
|
+
};
|
|
2857
|
+
failures = 0;
|
|
2858
|
+
if (previous === void 0) {
|
|
2859
|
+
const waiting = options.untilDecision && next.decisions.length > 0;
|
|
2860
|
+
const lines = snapshotLines(next);
|
|
2861
|
+
for (const line of waiting ? decisionsLast(lines, isSnapshotDecision) : lines) emit("snapshot", line);
|
|
2862
|
+
if (waiting) return TURN_EXIT.decision;
|
|
2863
|
+
} else {
|
|
2864
|
+
const lines = diffStatus(previous, next);
|
|
2865
|
+
for (const line of decisionsLast(lines, isOpenedDecision)) emit("transition", line);
|
|
2866
|
+
if (options.untilDecision && lines.some(isOpenedDecision)) return TURN_EXIT.decision;
|
|
2867
|
+
}
|
|
2868
|
+
previous = next;
|
|
2869
|
+
} catch (error) {
|
|
2870
|
+
failures += 1;
|
|
2871
|
+
emit("poll-failed", `poll failed: ${messageOf(error)}`);
|
|
2872
|
+
if (failures >= MAX_FAILURES) return TURN_EXIT.failed;
|
|
2873
|
+
}
|
|
2874
|
+
await sleep(options.intervalMs);
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
function registerWatchCommand(program) {
|
|
2878
|
+
program.command("watch").description("One line per change of sessions, decisions, machines and links (exit 3 on --until decision)").option("--interval <duration>", "time between polls, at least 2s", "10s").addOption(new Option("--until <event>", "exit 3 when this happens").choices(["decision"])).action(async (opts, cmd) => {
|
|
2879
|
+
const intervalMs = parseInterval(opts.interval);
|
|
2880
|
+
const code = await watch(getAppClientFromCommand(cmd), {
|
|
2881
|
+
intervalMs,
|
|
2882
|
+
untilDecision: opts.until === "decision",
|
|
2883
|
+
human: isHumanOutput(cmd)
|
|
2884
|
+
});
|
|
2885
|
+
if (code !== 0) process.exitCode = code;
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
1427
2888
|
// src/version.ts
|
|
1428
2889
|
/**
|
|
1429
2890
|
* The installed package's version, read from its own manifest: `src/` and the
|
|
@@ -1444,13 +2905,13 @@ const PUBLIC_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
1444
2905
|
"login",
|
|
1445
2906
|
"use",
|
|
1446
2907
|
"instances",
|
|
1447
|
-
"current",
|
|
1448
2908
|
"logout",
|
|
1449
|
-
"help"
|
|
2909
|
+
"help",
|
|
2910
|
+
"init",
|
|
2911
|
+
"guide"
|
|
1450
2912
|
]);
|
|
1451
2913
|
function isPublicCommand(actionCommand) {
|
|
1452
|
-
|
|
1453
|
-
return actionCommand.parent?.name() === "auth" && actionCommand.name() === "help";
|
|
2914
|
+
return PUBLIC_COMMANDS.has(actionCommand.name());
|
|
1454
2915
|
}
|
|
1455
2916
|
function createProgram() {
|
|
1456
2917
|
const program = new Command();
|
|
@@ -1458,23 +2919,26 @@ function createProgram() {
|
|
|
1458
2919
|
registerRootCommands(program);
|
|
1459
2920
|
registerRecordsCommand(program);
|
|
1460
2921
|
registerSchemaCommand(program);
|
|
1461
|
-
registerPullCommand(program);
|
|
1462
|
-
registerDiffCommand(program);
|
|
1463
2922
|
registerDocumentsCommand(program);
|
|
1464
|
-
|
|
2923
|
+
registerFilesCommand(program);
|
|
1465
2924
|
registerKeysCommand(program);
|
|
1466
2925
|
registerInvitationsCommand(program);
|
|
1467
|
-
registerAuthCommand(program);
|
|
1468
2926
|
registerBundlesCommand(program);
|
|
1469
2927
|
registerConnectorsCommand(program);
|
|
1470
2928
|
registerMcpCommand(program);
|
|
1471
2929
|
registerMeetingsCommand(program);
|
|
1472
|
-
registerMemoryCommand(program);
|
|
1473
|
-
registerSearchCommand(program);
|
|
1474
2930
|
registerSkillsCommand(program);
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
2931
|
+
registerUsageCommand(program);
|
|
2932
|
+
registerEnvironmentsCommand(program);
|
|
2933
|
+
registerSecretsCommand(program);
|
|
2934
|
+
registerMachinesCommand(program);
|
|
2935
|
+
registerLinksCommand(program);
|
|
2936
|
+
registerSessionsCommand(program);
|
|
2937
|
+
registerDecisionsCommand(program);
|
|
2938
|
+
registerStatusCommand(program);
|
|
2939
|
+
registerWatchCommand(program);
|
|
2940
|
+
registerInitCommand(program);
|
|
2941
|
+
registerGuideCommand(program);
|
|
1478
2942
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
1479
2943
|
const raw = program.opts();
|
|
1480
2944
|
const resolved = await resolveCliConfig({
|