@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 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, MEETING_RESPONSES, RECORDING_OVERRIDES, RECORD_ACCESS_ROLES, RESOURCE_VISIBILITIES, ValidationError, canonicalStringify, diffSchemaState, formatByteSize, parseRecordAccessRole, parseSchemaState, parseSkillSource, schemaStateSourcePayload } from "@standards/schema";
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 { createHash } from "node:crypto";
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, stdout } from "node:process";
18
+ import { stdin } from "node:process";
19
19
  import { createInterface } from "node:readline/promises";
20
- import { homedir } from "node:os";
21
- import { isSafeSkillFilePath, skillFileText } from "@standards/constants";
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
- function escapeCsvValue(value) {
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
- const str = typeof value === "object" ? JSON.stringify(value) : String(value);
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 Error("No Standards instance configured. Run \"standards login\" or pass --api-key.");
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
- * Like `requireTenant`, `message` is passed whole rather than composed: each
146
- * command's exact wording is what its users (and its tests) read.
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 window's own rules (maximum span, ordering) belong to the server, which
165
- * owns the calendar; the CLI only rejects what it can judge on its own.
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 parseScope(scope) {
168
- if (SCOPES.includes(scope)) return scope;
169
- throw new ValidationError(`--scope must be one of: ${SCOPES.join(", ")}`, []);
170
- }
171
- function registerAiCommand(program) {
172
- const ai = program.command("ai").description("Inspect AI usage and credit state");
173
- ai.command("usage").description("Aggregate the AI journal over an inclusive UTC day window").requiredOption("--from <day>", "first day of the window (YYYY-MM-DD)").requiredOption("--to <day>", "last day of the window, inclusive (YYYY-MM-DD)").option("--scope <scope>", "me or workspace", "me").action(async (opts, cmd) => {
174
- const params = {
175
- from: parseDay("--from", opts.from),
176
- to: parseDay("--to", opts.to),
177
- scope: parseScope(opts.scope ?? "me")
178
- };
179
- formatOutput(await getAppClientFromCommand(cmd).ai.usage(params), getFormat(cmd));
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
- // src/commands/auth.ts
186
- function registerAuthCommand(program) {
187
- program.command("auth").description("Inspect CLI authentication").command("whoami").description("Show current configuration and validate API key").action(async (_opts, cmd) => {
188
- const root = getGlobalOptions(cmd);
189
- const client = getAppClientFromCommand(cmd);
190
- const write = (msg) => process.stdout.write(`${msg}\n`);
191
- write(styleText("bold", "Standards CLI Configuration"));
192
- write(` API URL: ${root.apiUrl}`);
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
- * Orders discovered bundles so each comes after the bundles it `uses`.
203
- * Bundles a `uses` names that were not discovered are already the host's concern.
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 orderByUses(discovered) {
206
- const byName = new Map(discovered.map((entry) => [entry.bundle.name, entry]));
207
- const ordered = [];
208
- const visited = /* @__PURE__ */ new Set();
209
- async function visit(entry) {
210
- if (visited.has(entry.bundle.name)) return;
211
- visited.add(entry.bundle.name);
212
- for (const used of (await entry.bundle.build()).uses) {
213
- const dependency = byName.get(used);
214
- if (dependency) await visit(dependency);
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 (`preview`, then `add` with the preview's fingerprint)
289
- * and update such a copy (`update-preview`, then `update` with its fingerprint).
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("preview").description("Plan an add: what is new, what collides, and the plan's fingerprint")).action((names, opts, cmd) => run(async () => {
312
- formatOutput(await getAppClientFromCommand(cmd).bundles.preview(addInput(names, opts)), getFormat(cmd));
313
- }));
314
- withAddOptions(bundles.command("add").description("Add previewed bundles as copies the workspace owns").requiredOption("--fingerprint <fingerprint>", "the fingerprint `bundles preview` returned").option("--decision <pieceId=choice>", "how to settle a collision: bind, skip or rename:<name> (repeatable)", collectDecision, {})).action((names, opts, cmd) => run(async () => {
315
- formatOutput(await getAppClientFromCommand(cmd).bundles.add({
316
- ...addInput(names, opts),
317
- decisions: opts.decision,
318
- fingerprint: opts.fingerprint
319
- }), getFormat(cmd));
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
- console.info(`${name} updated, ${applied} applied`);
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
- process.stdout.write(`${styleText("green", "✓")} Connector connection ${id} disconnected.\n`);
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
- // src/drift/shared-state.ts
412
- async function readSchemaState(client) {
413
- const state = parseSchemaState(await client.schemaState.get({}));
414
- if (createHash("sha256").update(canonicalStringify(schemaStateSourcePayload(state.entries))).digest("hex") !== state.sourceFingerprint) throw new ValidationError("Invalid schema state: sourceFingerprint does not match deployed source", []);
415
- return state;
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 prettyCanonical(value) {
418
- return JSON.stringify(JSON.parse(canonicalStringify(value)), null, 2);
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
- // src/commands/diff.ts
421
- async function runDiffCommand(client) {
422
- try {
423
- const diff = diffSchemaState(await readSchemaState(client));
424
- return {
425
- exitCode: diff.changes.length ? 1 : 0,
426
- diff
427
- };
428
- } catch (error) {
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
- exitCode: 2,
431
- errorMessage: messageOf(error)
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
- function renderDiff(diff) {
436
- return ["Deployed source vs shared runtime", ...diff.changes.length ? diff.changes.map((entry) => `${entry.change} ${JSON.stringify(entry.address)}`) : ["No differences."]].join("\n");
437
- }
438
- function registerDiffCommand(program) {
439
- program.command("diff").description("Compare deployed source vs shared runtime; excludes unbuilt local TypeScript (exit 0 equal, 1 differences, 2 error)").option("--json", "Emit canonical machine-readable differences").action(async (opts, cmd) => {
440
- let result;
441
- try {
442
- result = await runDiffCommand(getAppClientFromCommand(cmd));
443
- } catch (error) {
444
- result = {
445
- exitCode: 2,
446
- errorMessage: messageOf(error)
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
- if (result.exitCode === 2) console.error(styleText("red", `✗ ${result.errorMessage}`));
450
- else console.info(opts.json ? prettyCanonical(result.diff) : renderDiff(result.diff));
451
- process.exit(result.exitCode);
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/embeddings.ts
528
- function registerEmbeddingsCommand(program) {
529
- program.command("embeddings").description("Maintain record and email embeddings").command("drain").description("Embed every pending record and email of the tenant").action(async (_opts, cmd) => {
530
- const tenant = requireTenant(cmd, "An embedding drain names its tenant explicitly. Pass \"--tenant <id>\".");
531
- await getAppClientFromCommand(cmd).embeddingsAdmin.drain({ tenantId: tenant });
532
- process.stdout.write(`${styleText("green", "✓")} Embedding drain accepted for tenant ${tenant}.\n`);
533
- });
534
- }
535
- // src/commands/folders.ts
536
- function registerFoldersCommand(program) {
537
- program.command("folders").description("Manage record drive folders").command("reconcile-paths").description("Create a folder tree on a record drive from paths").argument("<object>", "object name (e.g. contacts)").argument("<recordId>", "record ID owning the drive").option("--path <path>", "folder path to create (repeatable)", (val, acc) => {
538
- acc.push(val);
539
- return acc;
540
- }, []).option("--paths-file <file>", "file containing one path per line").option("--mode <mode>", "reconcile mode: repair or dryRun", "repair").action(async (objectName, recordId, opts, cmd) => {
541
- const paths = [...opts.path];
542
- if (opts.pathsFile) {
543
- const filePaths = (await readFile(opts.pathsFile, "utf-8")).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
544
- paths.push(...filePaths);
545
- }
546
- if (paths.length === 0) throw new Error("No paths provided. Pass at least one --path or a non-empty --paths-file.");
547
- formatOutput(await getAppClientFromCommand(cmd).folders.reconcilePaths({
548
- recordId,
549
- objectName,
550
- paths,
551
- mode: opts.mode
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
- const match = (await client.permissions.listRoles({})).find((role) => role.name === name);
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
- process.stdout.write(`${styleText("green", "✓")} API key ${id} revoked.\n`);
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
- process.stdout.write(`${styleText("green", "✓")} MCP server ${id} ${trusted ? "trusted" : "untrusted"}.\n`);
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
- process.stdout.write(`${styleText("green", "✓")} MCP server ${id} disconnected.\n`);
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
- ...opts.parent ? { options: { parentId: opts.parent } } : {}
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 a record").argument("<object>", "object name").argument("<id>", "record ID").action(async (objectName, id, _opts, cmd) => {
865
- await getAppClientFromCommand(cmd).records.delete({
866
- objectName,
867
- id
868
- });
869
- process.stdout.write(`Record ${id} deleted.\n`);
870
- });
871
- records.command("deletion-count").description("Count what deleting records would take: broken links and descendants").argument("<object>", "object name").argument("<ids...>", "record IDs").action(async (objectName, ids, _opts, cmd) => {
872
- formatOutput(await getAppClientFromCommand(cmd).records.deletionCount({
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-impact").description("Show who would gain and lose access if a record moved").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) => {
878
- formatOutput(await getAppClientFromCommand(cmd).records.moveImpact({
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
- }), getFormat(cmd));
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
- async function promptForMissingValue(label) {
1092
- const rl = createInterface({
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
- output: stdout
1651
+ terminal: false
1095
1652
  });
1096
1653
  try {
1097
- return (await rl.question(`${label}: `)).trim();
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
- rl.close();
1670
+ process.off("SIGINT", cancel);
1671
+ enter.close();
1100
1672
  }
1101
1673
  }
1102
- function registerRootCommands(program) {
1103
- program.command("login").description("Connect the CLI to a Standards instance").option("--url <url>", "Standards API URL", getDefaultApiUrl()).option("--key <key>", "API key to store for this instance").option("--name <name>", "local instance name", "local").action(async (opts) => {
1104
- const apiKey = opts.key ?? await promptForMissingValue("API key");
1105
- if (!apiKey) throw new Error("API key is required");
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: opts.url,
1108
- apiKey
1109
- })).apiKeys.list({});
1110
- await upsertProfile({
1111
- name: opts.name,
1112
- apiUrl: opts.url,
1113
- apiKey
1114
- });
1115
- process.stdout.write(`${styleText("green", "✓")} Standards instance "${opts.name}" saved and selected.\n`);
1116
- process.stdout.write(` API URL: ${opts.url}\n`);
1117
- process.stdout.write(` API Key: ${apiKey.slice(0, 8)}...\n`);
1118
- process.stdout.write(` Use it with: standards --instance ${opts.name} <command>\n`);
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
- process.stdout.write(`${styleText("green", "✓")} Standards instance "${name}" selected.\n`);
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("current").description("Show the active Standards instance").action(async () => {
1128
- const profile = await getActiveProfile();
1129
- if (!profile) {
1130
- process.stdout.write(`No Standards instance selected. Run "standards login".\n`);
1131
- return;
1132
- }
1133
- process.stdout.write(`${profile.name} ${profile.apiUrl}\n`);
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 removeProfile(name);
1137
- process.stdout.write(`${styleText("green", "✓")} Standards instance removed.\n`);
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/search.ts
1154
- function registerSearchCommand(program) {
1155
- program.command("search").description("Maintain the Standards search index").command("reindex").description("Rebuild the tenant search index beside the live one, then switch").option("--yes", "reindex without confirmation").option("--emails", "hand the tenant's stored emails back to the email indexer instead (nothing is cleared)").option("--after-id <emailId>", "with --emails, resume after the last email id a failed run reported").option("--rebuild", "with --emails and --yes, rebuild the mail search index from the stored mail instead (lifts a quarantine)").action(async (opts, cmd) => {
1156
- if (opts.afterId !== void 0 && !opts.emails) throw new ValidationError("\"--after-id\" resumes an email reindex. Pass it with \"--emails\".", []);
1157
- if (opts.rebuild && !opts.emails) throw new ValidationError("\"--rebuild\" rebuilds the mail search index. Pass it with \"--emails\".", []);
1158
- if (opts.rebuild && opts.afterId !== void 0) throw new ValidationError("\"--after-id\" resumes a requeue; a mail rebuild reads every stored email. Pass one of them.", []);
1159
- if (opts.emails) {
1160
- const tenant = requireTenant(cmd, "An email reindex names its tenant explicitly. Pass \"--tenant <id>\" with the tenant you are authenticated in.");
1161
- if (opts.rebuild && !opts.yes) throw new ValidationError("A mail rebuild re-reads every stored email. Re-run with \"--yes\" to confirm.", []);
1162
- await getAppClientFromCommand(cmd).searchAdmin.emailReindex({
1163
- tenantId: tenant,
1164
- ...opts.afterId !== void 0 && { afterId: opts.afterId },
1165
- ...opts.rebuild && { rebuild: true }
1166
- });
1167
- process.stdout.write(opts.rebuild ? `${styleText("green", "✓")} Mail index rebuild accepted. Mail search keeps answering from the current index until the rebuilt one replaces it — follow the server logs for progress.\n` : `${styleText("green", "✓")} Email reindex accepted for tenant ${tenant}. It runs in the background — follow the server logs for progress.\n`);
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 (!opts.yes) throw new ValidationError("A rebuild re-reads every record of the tenant. Re-run with \"--yes\" to confirm.", []);
1171
- const tenant = requireTenant(cmd, "A rebuild names its tenant explicitly. Pass \"--tenant <id>\" with the tenant you are authenticated in.");
1172
- await getAppClientFromCommand(cmd).searchAdmin.fullReindex({ tenantId: tenant });
1173
- process.stdout.write(`${styleText("green", "✓")} Index rebuild accepted for tenant ${tenant}. Search keeps answering from the current index until the rebuilt one replaces it — follow the server logs for progress.\n`);
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
- /** `standards skills search`: our index, or skills.sh with `community`. */
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 preview`: what a source would install, the source checked here first. */
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, or skills.sh with --community").argument("<query>", "what the skill should do").option("--community", "search skills.sh instead of the official catalogue").option("--limit <n>", "max results to return (up to 50)").action(async (query, opts, cmd) => {
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("top").description("List skills.sh's most installed skills, or the last 24 hours with --trending").option("--trending", "rank by the last 24 hours instead of all time").option("--limit <n>", "max results to return (up to 50)").action(async (opts, cmd) => {
1412
- await printAnswer(cmd, () => runSkillsTop(getAppClientFromCommand(cmd), opts));
1413
- });
1414
- skills.command("preview").description("Show what a source would install, without installing it").argument("<source>", "owner/repo@skill or owner/repo@skill#ref").action(async (source, _opts, cmd) => {
1415
- await printAnswer(cmd, () => runSkillsPreview(getAppClientFromCommand(cmd), source));
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
- if (PUBLIC_COMMANDS.has(actionCommand.name())) return true;
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
- registerFoldersCommand(program);
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
- registerEmbeddingsCommand(program);
1476
- registerAiCommand(program);
1477
- registerQuotasCommand(program);
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({