@miakapp/cli 4.0.0-alpha.0 → 4.0.0-alpha.10

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.
Files changed (63) hide show
  1. package/README.md +151 -35
  2. package/assets/agent-guide.md +232 -79
  3. package/assets/design/OFL-Manrope.txt +93 -0
  4. package/assets/design/components.css +139 -0
  5. package/assets/design/fonts.css +9 -0
  6. package/assets/design/gallery.html +667 -0
  7. package/assets/design/guide.md +77 -0
  8. package/assets/design/tokens.css +185 -0
  9. package/assets/starter/app/app/README.md +74 -0
  10. package/assets/starter/app/app/main.ts +124 -0
  11. package/assets/starter/app/app/miakapp.ts +156 -0
  12. package/assets/starter/app/app/theme.ts +8 -0
  13. package/assets/starter/app/tsconfig.json +23 -0
  14. package/bin/miakapp.js +5 -1
  15. package/dist/agent-pack.d.ts +11 -3
  16. package/dist/agent-pack.d.ts.map +1 -1
  17. package/dist/agent-pack.js +26 -10
  18. package/dist/agent-pack.js.map +1 -1
  19. package/dist/contexts.d.ts +59 -0
  20. package/dist/contexts.d.ts.map +1 -0
  21. package/dist/contexts.js +388 -0
  22. package/dist/contexts.js.map +1 -0
  23. package/dist/control-plane.d.ts +14 -0
  24. package/dist/control-plane.d.ts.map +1 -1
  25. package/dist/control-plane.js +33 -1
  26. package/dist/control-plane.js.map +1 -1
  27. package/dist/internal/names.d.ts +5 -0
  28. package/dist/internal/names.d.ts.map +1 -1
  29. package/dist/internal/names.js +6 -0
  30. package/dist/internal/names.js.map +1 -1
  31. package/dist/main.d.ts +22 -5
  32. package/dist/main.d.ts.map +1 -1
  33. package/dist/main.js +673 -60
  34. package/dist/main.js.map +1 -1
  35. package/dist/mcp.d.ts +7 -1
  36. package/dist/mcp.d.ts.map +1 -1
  37. package/dist/mcp.js +195 -26
  38. package/dist/mcp.js.map +1 -1
  39. package/dist/pairing.d.ts +27 -0
  40. package/dist/pairing.d.ts.map +1 -0
  41. package/dist/pairing.js +148 -0
  42. package/dist/pairing.js.map +1 -0
  43. package/dist/project.d.ts +3 -1
  44. package/dist/project.d.ts.map +1 -1
  45. package/dist/project.js +33 -6
  46. package/dist/project.js.map +1 -1
  47. package/dist/publication.d.ts +20 -5
  48. package/dist/publication.d.ts.map +1 -1
  49. package/dist/publication.js +58 -16
  50. package/dist/publication.js.map +1 -1
  51. package/dist/starter.d.ts +17 -0
  52. package/dist/starter.d.ts.map +1 -0
  53. package/dist/starter.js +72 -0
  54. package/dist/starter.js.map +1 -0
  55. package/dist/terminal.d.ts +10 -0
  56. package/dist/terminal.d.ts.map +1 -0
  57. package/dist/terminal.js +70 -0
  58. package/dist/terminal.js.map +1 -0
  59. package/dist/version.d.ts +15 -0
  60. package/dist/version.d.ts.map +1 -0
  61. package/dist/version.js +15 -0
  62. package/dist/version.js.map +1 -0
  63. package/package.json +6 -4
package/dist/main.js CHANGED
@@ -14,31 +14,47 @@
14
14
  * {@link EXIT_CODE}), so a wrapper decides without parsing prose;
15
15
  * - `--json`, which prints exactly one closed object on stdout.
16
16
  */
17
+ import { homedir, hostname } from 'node:os';
17
18
  import { installPack } from './agent-pack.js';
19
+ import { CLI_VERSION } from './version.js';
18
20
  import { prepareArtifact } from './artifact.js';
19
- import { exchangePublisherToken, fetchDiscovery } from './control-plane.js';
21
+ import { exchangePublisherToken, fetchDiscovery, homeKeyId, homeUrl } from './control-plane.js';
22
+ import { configDirectory, contextNameError, isContextName, mutateContexts, readContexts, readCredential, } from './contexts.js';
20
23
  import { discoverFlows, inventoryJson } from './discovery.js';
21
- import { CliError, EXIT_CODE, artifactError, authorizationError, projectError, usageError, } from './errors.js';
24
+ import { CliError, EXIT_CODE, artifactError, authorizationError, contractError, projectError, usageError, } from './errors.js';
22
25
  import { isDigest, isRelease, REQUIREMENT_KINDS } from './internal/names.js';
26
+ import { DEFAULT_ISSUER, PAIRING_PAGE, normalizeCode, pairingIssuer, redeemPairingCode, validateLabel, } from './pairing.js';
27
+ import { STARTERS, STARTER_FILES, isStarter, starterFiles, validateStarterArtifact, } from './starter.js';
23
28
  import { PROJECT_FILE, PROJECT_SCHEMA, findProjectFile, parseProject } from './project.js';
24
- import { activateRelease, publish, readRelease, readUpload, } from './publication.js';
25
- export const CLI_VERSION = '4.0.0-alpha.0';
29
+ import { activateRelease, publish, readPointer, readRelease, readUpload, } from './publication.js';
30
+ export { CLI_VERSION };
26
31
  /**
27
- * The Home Key is read from the environment only. A secret passed as an
28
- * argument would land in shell history, in a process listing and in most CI
29
- * logs, so no command accepts one.
32
+ * A Home Key comes from a paired context in `~/.miakapp` or, for CI and
33
+ * compatibility, from this variable. A secret passed as an argument would land
34
+ * in shell history, in a process listing and in most CI logs, so no command
35
+ * accepts one.
30
36
  */
31
37
  export const HOME_KEY_VARIABLE = 'MIAKAPP_HOME_KEY';
32
- const USAGE = `miakapp ${CLI_VERSION} — build, publish and roll back a Miakapp home component
38
+ /** Selects a stored context for one invocation, like `--context`. */
39
+ export const CONTEXT_VARIABLE = 'MIAKAPP_CONTEXT';
40
+ const USAGE = `miakapp ${CLI_VERSION} — build, publish and roll back a Miakapp home interface
33
41
 
34
42
  Usage
35
43
  miakapp <command> [options]
36
44
 
37
45
  Commands
38
- init Write ${PROJECT_FILE} in the current directory
46
+ docs start Print the complete agent guide bundled with this CLI
47
+ docs design Print the design guide: tokens, themes, recipes, examples
48
+ pair Redeem a one-time pairing code into a stored context
49
+ context list List stored contexts (never prints a key)
50
+ context show [name] Show one context, the current one by default
51
+ context use <name> Make a context current
52
+ context remove <name> Delete a context and its stored key
53
+ init Write ${PROJECT_FILE} (add --starter app for a buildable app)
39
54
  agent-pack Install the guide and the MCP wiring into a repository
40
55
  discover Inventory an existing Node-RED installation offline
41
56
  check Validate the project and the artifact offline
57
+ status Read the live generation and release of the home
42
58
  publish Upload, finalize and activate the built artifact
43
59
  activate Activate an already finalized digest at a new generation
44
60
  rollback Alias of activate, for returning to a known-good digest
@@ -49,17 +65,28 @@ Commands
49
65
  version Print the CLI version
50
66
 
51
67
  Common options
68
+ --help, -h Print this guide without running the command
52
69
  --json Print one machine-readable object on stdout
53
70
  --project <dir> Start the ${PROJECT_FILE} search here (default: cwd)
71
+ --context <name> Use this stored context (status, publish, activate,
72
+ rollback, release, upload, init)
73
+
74
+ pair options
75
+ (code) Read from stdin: hidden prompt on a terminal, first
76
+ line of a pipe otherwise. Preferred: never echoed.
77
+ --code <code> Pass the code explicitly (lands in shell history)
78
+ --issuer <https url> Control plane to redeem at (default: ${DEFAULT_ISSUER})
79
+ --label <label> Key label shown to the owner (default: miakapp-cli@host)
80
+ --name <context> Context name (default: the paired home ID)
54
81
 
55
82
  publish options
56
- --expected-generation <n> Generation the pointer is expected to hold (required)
83
+ --expected-generation <n> Generation the pointer holds (default: read live)
57
84
  --generation <n> Generation to publish (default: expected + 1)
58
- --release <name> Override component.release from ${PROJECT_FILE}
85
+ --release <name> Override app.release or component.release from ${PROJECT_FILE}
59
86
 
60
87
  activate / rollback options
61
88
  --sha256 <digest> Finalized artifact digest (required)
62
- --expected-generation <n> Generation the pointer is expected to hold (required)
89
+ --expected-generation <n> Generation the pointer holds (default: read live)
63
90
  --generation <n> Generation to publish (default: expected + 1)
64
91
 
65
92
  discover options
@@ -74,33 +101,52 @@ agent-pack options
74
101
  --dir <path> Repository to install into (default: cwd)
75
102
 
76
103
  init options
77
- --home <homeId> Home ID to write into ${PROJECT_FILE} (required)
78
- --control-plane <https url> Control-plane issuer (required)
79
- --artifact <path> Built artifact path (default: dist/component.js)
104
+ --home <homeId> Home ID (default: from the selected context)
105
+ --control-plane <https url> Control-plane issuer (default: from the context)
106
+ --kind <app|component> Interface kind (default: app)
107
+ --artifact <path> Built artifact path (default: dist/app.js for app,
108
+ dist/component.js for component)
80
109
  --release <name> Initial release name (default: 0.1.0)
110
+ --starter app Also write a self-contained app to edit: app/main.ts,
111
+ the app/miakapp.ts bridge, package.json (build and
112
+ check scripts), tsconfig.json and app/README.md.
113
+ Needs Bun >= 1.2.23 to build. Refuses, writing
114
+ nothing, if any of these files already exists.
115
+ Without it, init writes ${PROJECT_FILE} only.
116
+
117
+ Credentials, highest precedence first
118
+ 1. --context <name> a context stored by miakapp pair
119
+ 2. ${CONTEXT_VARIABLE}=<name> the same, from the environment
120
+ 3. ${HOME_KEY_VARIABLE} a raw Home Key, for CI and compatibility
121
+ 4. the stored context whose home and issuer match ${PROJECT_FILE}
122
+ (the current context wins a tie)
123
+ A stored context whose home or issuer differs from ${PROJECT_FILE} is refused,
124
+ so a key can never publish into another home. Keys are never printed.
81
125
 
82
126
  Environment
83
- ${HOME_KEY_VARIABLE} Home Key with the components:publish scope. Required by
84
- publish, activate, rollback, release and upload. It is never
85
- accepted as an argument and never printed.
127
+ MIAKAPP_CONFIG_DIR Absolute directory replacing ~/.miakapp
86
128
 
87
129
  Exit codes
88
130
  0 success 1 usage 2 project 3 artifact
89
131
  4 authorization 5 contract 6 conflict 7 unknown outcome
90
132
  `;
91
- const GLOBAL_FLAGS = ['json'];
133
+ const GLOBAL_FLAGS = ['json', 'help'];
92
134
  const GLOBAL_OPTIONS = ['project'];
93
135
  /** Exported so the MCP surface can be proved to expose every option, and no other. */
94
136
  export const COMMAND_OPTIONS = {
95
- init: ['home', 'control-plane', 'artifact', 'release'],
137
+ docs: [],
138
+ pair: ['code', 'issuer', 'label', 'name'],
139
+ context: [],
140
+ init: ['home', 'control-plane', 'artifact', 'release', 'context', 'kind', 'starter'],
96
141
  'agent-pack': ['dir'],
97
142
  discover: ['flows'],
98
143
  check: [],
99
- publish: ['expected-generation', 'generation', 'release'],
100
- activate: ['sha256', 'expected-generation', 'generation'],
101
- rollback: ['sha256', 'expected-generation', 'generation'],
102
- release: [],
103
- upload: [],
144
+ status: ['context'],
145
+ publish: ['expected-generation', 'generation', 'release', 'context'],
146
+ activate: ['sha256', 'expected-generation', 'generation', 'context'],
147
+ rollback: ['sha256', 'expected-generation', 'generation', 'context'],
148
+ release: ['context'],
149
+ upload: ['context'],
104
150
  mcp: [],
105
151
  help: [],
106
152
  version: [],
@@ -132,6 +178,10 @@ export function parseArguments(argv) {
132
178
  const positional = [];
133
179
  for (let index = 1; index < argv.length; index += 1) {
134
180
  const argument = argv[index];
181
+ if (argument === '-h') {
182
+ flags.add('help');
183
+ continue;
184
+ }
135
185
  if (!argument.startsWith('--')) {
136
186
  positional.push(argument);
137
187
  continue;
@@ -163,7 +213,7 @@ export function parseArguments(argv) {
163
213
  options.set(name, value);
164
214
  index += 1;
165
215
  }
166
- return { command, options, flags, positional };
216
+ return { command: flags.has('help') ? 'help' : command, options, flags, positional };
167
217
  }
168
218
  function requiredOption(invocation, name) {
169
219
  const value = invocation.options.get(name);
@@ -181,17 +231,34 @@ function generationOption(invocation, name) {
181
231
  throw usageError(`--${name} is above the safe integer range`);
182
232
  return value;
183
233
  }
184
- /** `--generation` defaults to one above the expected generation, never higher. */
185
- function generationPair(invocation) {
186
- const expectedGeneration = generationOption(invocation, 'expected-generation');
234
+ /** Validated before any network use, so a typo never costs a request. */
235
+ function generationPlan(invocation) {
236
+ const expectedGeneration = invocation.options.has('expected-generation')
237
+ ? generationOption(invocation, 'expected-generation')
238
+ : undefined;
187
239
  const generation = invocation.options.has('generation')
188
240
  ? generationOption(invocation, 'generation')
189
- : expectedGeneration + 1;
190
- if (generation <= expectedGeneration) {
241
+ : undefined;
242
+ if (expectedGeneration !== undefined && generation !== undefined
243
+ && generation <= expectedGeneration) {
191
244
  throw usageError('--generation must be strictly above --expected-generation');
192
245
  }
193
246
  return { expectedGeneration, generation };
194
247
  }
248
+ /**
249
+ * Resolves the compare-and-set pair. Without `--expected-generation` the live
250
+ * generation is read first. That read takes no lock: the activation is still a
251
+ * compare-and-set, so a publication that lands in between fails with
252
+ * `conflict` rather than being overwritten.
253
+ */
254
+ async function resolveGenerations(target, plan) {
255
+ const expectedGeneration = plan.expectedGeneration ?? (await readPointer(target)).generation;
256
+ const generation = plan.generation ?? expectedGeneration + 1;
257
+ if (generation <= expectedGeneration) {
258
+ throw usageError(`--generation must be strictly above the live generation ${expectedGeneration}`);
259
+ }
260
+ return { expectedGeneration, generation };
261
+ }
195
262
  function digestOption(invocation, name) {
196
263
  const value = requiredOption(invocation, name);
197
264
  if (!isDigest(value)) {
@@ -199,13 +266,95 @@ function digestOption(invocation, name) {
199
266
  }
200
267
  return value;
201
268
  }
202
- function homeKey(host) {
203
- const value = host.env(HOME_KEY_VARIABLE);
204
- if (value === undefined || value === '') {
205
- throw authorizationError(`${HOME_KEY_VARIABLE} is not set`, `Export a Home Key holding components:publish as ${HOME_KEY_VARIABLE}. `
206
- + 'It is never accepted as a command-line argument.');
269
+ function nonEmpty(value) {
270
+ return value === undefined || value === '' ? undefined : value;
271
+ }
272
+ function storeDirectory(host) {
273
+ return configDirectory((name) => host.env(name), host.homeDirectory?.() ?? homedir());
274
+ }
275
+ /** The context named for this invocation, by flag first and then environment. */
276
+ function explicitContext(host, invocation) {
277
+ const name = invocation.options.get('context') ?? nonEmpty(host.env(CONTEXT_VARIABLE));
278
+ if (name !== undefined && !isContextName(name))
279
+ contextNameError(name);
280
+ return name;
281
+ }
282
+ /**
283
+ * The wrong-home guard. The project file says where a publication goes; a
284
+ * context says which home its key opens. When the two disagree the command
285
+ * stops, because the control plane would accept a valid key for home A
286
+ * publishing into home A, while the person meant home B.
287
+ */
288
+ function requireSameHome(context, project, origin) {
289
+ if (context.homeId === project.homeId && context.issuer === project.issuer)
290
+ return;
291
+ throw authorizationError(`${origin} is for home ${context.homeId} at ${context.issuer}, but ${PROJECT_FILE} targets `
292
+ + `home ${project.homeId} at ${project.issuer}`, 'Select the context paired with this home (miakapp context list), or pair it with '
293
+ + 'miakapp pair. Nothing was sent.');
294
+ }
295
+ /**
296
+ * Picks the Home Key for one invocation. Precedence, highest first:
297
+ *
298
+ * 1. `--context <name>`, then `MIAKAPP_CONTEXT`: an explicit stored context;
299
+ * 2. `MIAKAPP_HOME_KEY`: a raw key, for CI and compatibility;
300
+ * 3. the stored context paired with the project's home and issuer, the
301
+ * current context breaking a tie between several keys for that one home.
302
+ *
303
+ * A stored context is used only for the home it was paired with, and a raw
304
+ * key known to belong to another stored home is refused the same way.
305
+ */
306
+ async function resolveCredential(host, invocation, project) {
307
+ const directory = storeDirectory(host);
308
+ const explicit = explicitContext(host, invocation);
309
+ if (explicit !== undefined) {
310
+ const { context, homeKey } = await readCredential(directory, explicit);
311
+ requireSameHome(context, project, `Context ${context.name}`);
312
+ return { homeKey, source: 'context', context };
207
313
  }
208
- return value;
314
+ const environmentKey = nonEmpty(host.env(HOME_KEY_VARIABLE));
315
+ if (environmentKey !== undefined) {
316
+ const keyId = homeKeyId(environmentKey);
317
+ let known = [];
318
+ try {
319
+ known = (await readContexts(directory)).contexts.values();
320
+ }
321
+ catch {
322
+ // The guard is best effort here: a CI runner has no store, and a broken
323
+ // one must not block a key the caller supplied explicitly.
324
+ }
325
+ for (const context of known) {
326
+ if (context.keyId === keyId) {
327
+ requireSameHome(context, project, `${HOME_KEY_VARIABLE} (paired as context ${context.name})`);
328
+ }
329
+ }
330
+ return { homeKey: environmentKey, source: 'environment', context: undefined };
331
+ }
332
+ const state = await readContexts(directory);
333
+ const matching = [...state.contexts.values()]
334
+ .filter((context) => context.homeId === project.homeId && context.issuer === project.issuer)
335
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
336
+ const chosen = matching.find((context) => context.name === state.current) ?? matching[0];
337
+ if (chosen === undefined) {
338
+ const others = [...state.contexts.keys()];
339
+ throw authorizationError(`No credential for home ${project.homeId} at ${project.issuer}`, 'Pair this home: ask the owner to open '
340
+ + `${PAIRING_PAGE}, choose it, confirm access and send you the code, then run `
341
+ + `miakapp pair. ${HOME_KEY_VARIABLE} also works, for CI.`
342
+ + (others.length === 0 ? '' : ` Stored contexts are for other homes: ${others.join(', ')}.`));
343
+ }
344
+ const { homeKey } = await readCredential(directory, chosen.name);
345
+ return { homeKey, source: 'context', context: chosen };
346
+ }
347
+ function credentialJson(credential) {
348
+ return {
349
+ credential_source: credential.source,
350
+ context: credential.context?.name ?? null,
351
+ };
352
+ }
353
+ function credentialFields(credential) {
354
+ return [[
355
+ 'credential',
356
+ credential.context === undefined ? HOME_KEY_VARIABLE : `context ${credential.context.name}`,
357
+ ]];
209
358
  }
210
359
  async function nodeFileSystem() {
211
360
  const fs = await import('node:fs/promises');
@@ -257,30 +406,47 @@ async function loadArtifact(host, project) {
257
406
  }
258
407
  return prepareArtifact(await filesystem.read(project.artifactPath));
259
408
  }
260
- async function publicationTarget(host, project) {
261
- const key = homeKey(host);
409
+ async function publicationTarget(host, invocation, project) {
410
+ const credential = await resolveCredential(host, invocation, project);
262
411
  const options = host.fetch === undefined ? {} : { fetch: host.fetch };
263
412
  const discovery = await fetchDiscovery({ issuer: project.issuer, ...options });
264
- const token = await exchangePublisherToken(discovery, key, options);
265
- return { issuer: discovery.issuer, homeId: project.homeId, token: token.accessToken, ...options };
413
+ const token = await exchangePublisherToken(discovery, credential.homeKey, options);
414
+ return {
415
+ target: { issuer: discovery.issuer, homeId: project.homeId, token: token.accessToken, ...options },
416
+ credential,
417
+ homeUrl: homeUrl(discovery, project.homeId),
418
+ };
266
419
  }
267
420
  function requirementFields(requires) {
268
421
  return REQUIREMENT_KINDS.map((kind) => [`requires.${kind}`, requires[kind]]);
269
422
  }
270
- function pointerResult(summary, pointer) {
423
+ /**
424
+ * `home_url` is the link residents open — the trusted Miakapp shell for this
425
+ * home, from discovery — and the only link to hand a person. `url` stays the
426
+ * raw artifact the shell verifies and runs; it is printed as `artifact_url` so
427
+ * it is never mistaken for a page.
428
+ */
429
+ function pointerResult(summary, pointer, credential, expectedGeneration, residentUrl) {
271
430
  return {
272
431
  summary,
273
432
  fields: [
433
+ ...credentialFields(credential),
434
+ ...(expectedGeneration === undefined
435
+ ? []
436
+ : [['expected_generation', expectedGeneration]]),
274
437
  ['home', pointer.homeId],
438
+ ['home_url', residentUrl ?? 'not advertised by this control plane'],
439
+ ['abi', pointer.abi],
275
440
  ['generation', pointer.generation],
276
441
  ['release', pointer.release],
277
442
  ['sha256', pointer.sha256],
278
443
  ['size', pointer.size],
279
- ['url', pointer.url],
444
+ ['artifact_url', pointer.url],
280
445
  ...requirementFields(pointer.requires),
281
446
  ],
282
447
  json: {
283
448
  home_id: pointer.homeId,
449
+ home_url: residentUrl,
284
450
  generation: pointer.generation,
285
451
  release: pointer.release,
286
452
  abi: pointer.abi,
@@ -288,10 +454,29 @@ function pointerResult(summary, pointer) {
288
454
  sha256: pointer.sha256,
289
455
  size: pointer.size,
290
456
  requires: pointer.requires,
457
+ ...(expectedGeneration === undefined ? {} : { expected_generation: expectedGeneration }),
458
+ ...credentialJson(credential),
291
459
  },
292
460
  };
293
461
  }
294
462
  function projectTemplate(fields) {
463
+ if (fields.kind === 'app') {
464
+ return `schema: ${PROJECT_SCHEMA}
465
+ home: ${fields.home}
466
+ control_plane: ${fields.controlPlane}
467
+
468
+ # A whole-house application: one self-contained classic-script (IIFE) bundle
469
+ # that draws its own interface in Miakapp's isolated frame. Every path read and
470
+ # every function called must be declared; the coordinator still decides, per
471
+ # resident, what each one may see and do.
472
+ app:
473
+ artifact: ${fields.artifact}
474
+ release: ${fields.release}
475
+ requires:
476
+ state_read: []
477
+ call: []
478
+ `;
479
+ }
295
480
  return `schema: ${PROJECT_SCHEMA}
296
481
  home: ${fields.home}
297
482
  control_plane: ${fields.controlPlane}
@@ -311,26 +496,127 @@ async function runInit(host, invocation) {
311
496
  const filesystem = await files(host);
312
497
  const root = invocation.options.get('project') ?? host.cwd();
313
498
  const path = `${root}/${PROJECT_FILE}`;
314
- if (await filesystem.exists(path)) {
315
- throw projectError(`${path} already exists`, 'The CLI never overwrites a project file; edit it or remove it first.');
316
- }
317
499
  const release = invocation.options.get('release') ?? '0.1.0';
318
500
  if (!isRelease(release)) {
319
501
  throw usageError('--release must be 1..64 UTF-8 bytes without control characters');
320
502
  }
503
+ const starter = invocation.options.get('starter');
504
+ if (starter !== undefined && !isStarter(starter)) {
505
+ throw usageError(`--starter must be ${STARTERS.join(' or ')}, received ${starter}`);
506
+ }
507
+ const kind = invocation.options.get('kind') ?? 'app';
508
+ if (kind !== 'app' && kind !== 'component') {
509
+ throw usageError('--kind must be app (whole-house application, default) or component');
510
+ }
511
+ if (starter !== undefined && kind !== 'app') {
512
+ throw usageError('--starter app writes a whole-house application; it cannot be combined with --kind component');
513
+ }
514
+ const artifact = invocation.options.get('artifact') ?? (kind === 'app' ? 'dist/app.js' : 'dist/component.js');
515
+ if (starter !== undefined)
516
+ validateStarterArtifact(artifact);
517
+ // Every collision is found before anything is written: a refused init leaves
518
+ // the directory exactly as it was, never half a project.
519
+ const planned = [PROJECT_FILE, ...(starter === undefined ? [] : STARTER_FILES)];
520
+ const collisions = [];
521
+ for (const relative of planned) {
522
+ if (await filesystem.exists(`${root}/${relative}`))
523
+ collisions.push(relative);
524
+ }
525
+ if (collisions.length > 0) {
526
+ throw projectError(`${collisions.map((relative) => `${root}/${relative}`).join(', ')} already `
527
+ + `${collisions.length === 1 ? 'exists' : 'exist'}; nothing was written`, 'The CLI never overwrites a file. Run init in a new directory, or move these files aside.');
528
+ }
529
+ const target = await initTarget(host, invocation);
321
530
  const source = projectTemplate({
322
- home: requiredOption(invocation, 'home'),
323
- controlPlane: requiredOption(invocation, 'control-plane'),
324
- artifact: invocation.options.get('artifact') ?? 'dist/component.js',
531
+ home: target.home,
532
+ controlPlane: target.controlPlane,
533
+ artifact,
325
534
  release,
535
+ kind,
326
536
  });
327
537
  // Parsed before it is written, so init can never emit a file check rejects.
328
538
  parseProject(root, source);
539
+ const written = [];
540
+ if (starter !== undefined) {
541
+ const starterSources = await starterFiles({ home: target.home, artifact });
542
+ try {
543
+ await filesystem.makeDirectory(`${root}/app`);
544
+ for (const [relative, bytes] of starterSources) {
545
+ await filesystem.write(`${root}/${relative}`, bytes);
546
+ written.push(relative);
547
+ }
548
+ }
549
+ catch (error) {
550
+ const reason = error instanceof Error ? error.message : 'unknown failure';
551
+ throw projectError(`The starter could not be written (${reason}); ${PROJECT_FILE} was not written`
552
+ + (written.length === 0 ? '' : ` and these files were: ${written.join(', ')}`), 'Remove the files listed, fix the directory, and run init again.');
553
+ }
554
+ }
555
+ // The manifest goes last: a directory holding it always holds the whole starter.
329
556
  await filesystem.write(path, new TextEncoder().encode(source));
557
+ written.push(PROJECT_FILE);
330
558
  return {
331
- summary: `Wrote ${path}`,
332
- fields: [['project', path]],
333
- json: { project: path, schema: PROJECT_SCHEMA },
559
+ summary: starter === undefined
560
+ ? `Wrote ${path}`
561
+ : `Wrote an app starter in ${root}: inventory the house, declare app.requires, then build`,
562
+ fields: [
563
+ ['project', path],
564
+ ['home', target.home],
565
+ ['control_plane', target.controlPlane],
566
+ ...(target.context === undefined ? [] : [['context', target.context]]),
567
+ ...(starter === undefined ? [] : [
568
+ ['starter', starter],
569
+ ['files', written],
570
+ ['next', 'read app/README.md; bun run build; bun run check'],
571
+ ]),
572
+ ],
573
+ json: {
574
+ project: path,
575
+ schema: PROJECT_SCHEMA,
576
+ kind,
577
+ home_id: target.home,
578
+ control_plane: target.controlPlane,
579
+ context: target.context ?? null,
580
+ artifact,
581
+ release,
582
+ starter: starter ?? null,
583
+ files: written,
584
+ },
585
+ };
586
+ }
587
+ /**
588
+ * `--home` and `--control-plane` default to the selected context — the one
589
+ * named by `--context` or `MIAKAPP_CONTEXT`, else the current one — so the step
590
+ * after `miakapp pair` needs no identifier typed by hand. An explicit value
591
+ * that contradicts an explicitly selected context is refused rather than
592
+ * silently preferred.
593
+ */
594
+ async function initTarget(host, invocation) {
595
+ const home = invocation.options.get('home');
596
+ const controlPlane = invocation.options.get('control-plane');
597
+ const explicit = explicitContext(host, invocation);
598
+ if (explicit === undefined && home !== undefined && controlPlane !== undefined) {
599
+ return { home, controlPlane, context: undefined };
600
+ }
601
+ const state = await readContexts(storeDirectory(host));
602
+ const name = explicit ?? state.current ?? undefined;
603
+ const context = name === undefined ? undefined : state.contexts.get(name);
604
+ if (explicit !== undefined && context === undefined) {
605
+ throw usageError(`No context named ${explicit}`, 'Run miakapp context list.');
606
+ }
607
+ if (context === undefined) {
608
+ throw usageError(`--${home === undefined ? 'home' : 'control-plane'} is required when no context is selected`, 'Pair the home first with miakapp pair, or pass both --home and --control-plane.');
609
+ }
610
+ if (explicit !== undefined
611
+ && ((home !== undefined && home !== context.homeId)
612
+ || (controlPlane !== undefined && controlPlane !== context.issuer))) {
613
+ throw usageError(`Context ${context.name} is for home ${context.homeId} at ${context.issuer}, which `
614
+ + 'contradicts --home or --control-plane');
615
+ }
616
+ return {
617
+ home: home ?? context.homeId,
618
+ controlPlane: controlPlane ?? context.issuer,
619
+ context: context.name,
334
620
  };
335
621
  }
336
622
  /**
@@ -347,6 +633,43 @@ export async function guideAssetPath() {
347
633
  const { fileURLToPath } = await import('node:url');
348
634
  return fileURLToPath(new URL('../assets/agent-guide.md', import.meta.url));
349
635
  }
636
+ /** Directory of the design files (`tokens.css`, `gallery.html`…), resolved like the guide. */
637
+ export async function designAssetDirectory() {
638
+ const { fileURLToPath } = await import('node:url');
639
+ return fileURLToPath(new URL('../assets/design/', import.meta.url));
640
+ }
641
+ /**
642
+ * Gives an agent its complete starting contract without requiring a repository,
643
+ * a network request or an MCP client. The onboarding page can therefore hand a
644
+ * person one stable command and the installed CLI remains the source of truth.
645
+ */
646
+ async function runDocs(host, invocation) {
647
+ const topic = invocation.positional.length === 1 ? invocation.positional[0] : undefined;
648
+ if (topic !== 'start' && topic !== 'design') {
649
+ throw usageError('docs requires the topic start or design', 'Run miakapp docs start to print the complete agent guide, miakapp docs design for the design guide.');
650
+ }
651
+ const directory = topic === 'design' ? await designAssetDirectory() : undefined;
652
+ const path = directory === undefined ? await guideAssetPath() : `${directory}guide.md`;
653
+ let guide;
654
+ try {
655
+ const filesystem = await files(host);
656
+ guide = new TextDecoder('utf-8', { fatal: true }).decode(await filesystem.read(path));
657
+ }
658
+ catch {
659
+ throw projectError(`The packaged guide is missing or unreadable at ${path}`, 'Reinstall @miakapp/cli: the guide is bundled with the package and opens no network connection.');
660
+ }
661
+ if (!guide.endsWith('\n'))
662
+ guide = `${guide}\n`;
663
+ if (directory === undefined) {
664
+ return { summary: 'Miakapp agent guide', fields: [], json: { topic, guide }, text: guide };
665
+ }
666
+ return {
667
+ summary: 'Miakapp design guide',
668
+ fields: [],
669
+ json: { topic, guide, directory },
670
+ text: `${guide}\nDesign directory: ${directory}\n`,
671
+ };
672
+ }
350
673
  /**
351
674
  * Installs the agent pack. Like `discover`, it loads no project file: the
352
675
  * repository it prepares is usually one that has no V4 project yet.
@@ -455,6 +778,7 @@ async function runCheck(host, invocation) {
455
778
  fields: [
456
779
  ['home', project.homeId],
457
780
  ['control_plane', project.issuer],
781
+ ['abi', project.abi],
458
782
  ['release', project.release],
459
783
  ['artifact', project.artifactPath],
460
784
  ['sha256', artifact.sha256],
@@ -465,6 +789,7 @@ async function runCheck(host, invocation) {
465
789
  json: {
466
790
  home_id: project.homeId,
467
791
  control_plane: project.issuer,
792
+ abi: project.abi,
468
793
  release: project.release,
469
794
  artifact: project.artifactPath,
470
795
  sha256: artifact.sha256,
@@ -475,35 +800,38 @@ async function runCheck(host, invocation) {
475
800
  };
476
801
  }
477
802
  async function runPublish(host, invocation) {
478
- const { expectedGeneration, generation } = generationPair(invocation);
803
+ const plan = generationPlan(invocation);
479
804
  const project = await loadProject(host, invocation);
480
805
  const release = invocation.options.get('release') ?? project.release;
481
806
  if (!isRelease(release)) {
482
807
  throw usageError('--release must be 1..64 UTF-8 bytes without control characters');
483
808
  }
484
809
  const artifact = await loadArtifact(host, project);
485
- const target = await publicationTarget(host, project);
810
+ const { target, credential, homeUrl: residentUrl } = await publicationTarget(host, invocation, project);
811
+ const { expectedGeneration, generation } = await resolveGenerations(target, plan);
486
812
  const { pointer } = await publish(target, artifact, {
487
813
  release,
814
+ abi: project.abi,
488
815
  requires: project.requires,
489
816
  expectedGeneration,
490
817
  generation,
491
818
  });
492
- return pointerResult(`Published ${release} as generation ${pointer.generation}`, pointer);
819
+ return pointerResult(`Published ${release} as generation ${pointer.generation}`, pointer, credential, expectedGeneration, residentUrl);
493
820
  }
494
821
  async function runActivate(host, invocation) {
495
822
  const sha256 = digestOption(invocation, 'sha256');
496
- const { expectedGeneration, generation } = generationPair(invocation);
823
+ const plan = generationPlan(invocation);
497
824
  const project = await loadProject(host, invocation);
498
- const target = await publicationTarget(host, project);
825
+ const { target, credential, homeUrl: residentUrl } = await publicationTarget(host, invocation, project);
499
826
  // Activation is checked against a readable finalized record first, so a typo
500
827
  // in a digest fails as an artifact error instead of spending a CAS attempt.
501
828
  const existing = await readRelease(target, sha256);
502
829
  if (existing === undefined) {
503
830
  throw artifactError(`No finalized release for ${sha256}`, 'Activate only a digest this home has already published.');
504
831
  }
832
+ const { expectedGeneration, generation } = await resolveGenerations(target, plan);
505
833
  const pointer = await activateRelease(target, { sha256, expectedGeneration, generation });
506
- return pointerResult(`Activated ${existing.release} as generation ${pointer.generation}`, pointer);
834
+ return pointerResult(`Activated ${existing.release} as generation ${pointer.generation}`, pointer, credential, expectedGeneration, residentUrl);
507
835
  }
508
836
  async function runRelease(host, invocation) {
509
837
  const sha256 = invocation.positional[0];
@@ -513,7 +841,7 @@ async function runRelease(host, invocation) {
513
841
  throw usageError('The release digest must be 43 base64url characters');
514
842
  }
515
843
  const project = await loadProject(host, invocation);
516
- const target = await publicationTarget(host, project);
844
+ const { target } = await publicationTarget(host, invocation, project);
517
845
  const record = await readRelease(target, sha256);
518
846
  if (record === undefined) {
519
847
  throw artifactError(`No finalized release for ${sha256}`);
@@ -542,7 +870,7 @@ async function runUpload(host, invocation) {
542
870
  if (uploadId === undefined)
543
871
  throw usageError('upload requires one uploadId argument');
544
872
  const project = await loadProject(host, invocation);
545
- const target = await publicationTarget(host, project);
873
+ const { target } = await publicationTarget(host, invocation, project);
546
874
  const state = await readUpload(target, uploadId);
547
875
  return {
548
876
  summary: `Upload ${state.uploadId} is ${state.status}`,
@@ -566,6 +894,281 @@ async function runUpload(host, invocation) {
566
894
  },
567
895
  };
568
896
  }
897
+ async function runStatus(host, invocation) {
898
+ const project = await loadProject(host, invocation);
899
+ const { target, credential, homeUrl: residentUrl } = await publicationTarget(host, invocation, project);
900
+ const { generation, pointer } = await readPointer(target);
901
+ if (pointer === null) {
902
+ return {
903
+ summary: `${project.homeId} has never activated an interface (generation 0)`,
904
+ fields: [
905
+ ['home', project.homeId],
906
+ ['home_url', residentUrl ?? 'not advertised by this control plane'],
907
+ ['generation', 0],
908
+ ...credentialFields(credential),
909
+ ],
910
+ json: {
911
+ home_id: project.homeId,
912
+ home_url: residentUrl,
913
+ generation,
914
+ active: false,
915
+ ...credentialJson(credential),
916
+ },
917
+ };
918
+ }
919
+ const result = pointerResult(`${project.homeId} runs ${pointer.release} at generation ${generation}`, pointer, credential, undefined, residentUrl);
920
+ return { ...result, json: { ...result.json, active: true } };
921
+ }
922
+ function defaultLabel(host) {
923
+ const machine = (host.hostname?.() ?? hostname()).replace(/[^A-Za-z0-9._-]/g, '-');
924
+ return `miakapp-cli@${machine === '' ? 'unknown' : machine}`.slice(0, 64);
925
+ }
926
+ /** A free context name: the base itself, else base-2, base-3, ... */
927
+ function freeContextName(taken, base) {
928
+ if (!taken.has(base))
929
+ return base;
930
+ for (let suffix = 2;; suffix += 1) {
931
+ const candidate = `${base.slice(0, 62 - String(suffix).length)}-${suffix}`;
932
+ if (!taken.has(candidate))
933
+ return candidate;
934
+ }
935
+ }
936
+ async function pairingCode(host, invocation) {
937
+ const explicit = invocation.options.get('code');
938
+ if (explicit !== undefined)
939
+ return explicit;
940
+ if (host.readSecret === undefined) {
941
+ throw usageError('pair needs the one-time code', 'Pipe it on stdin (printf %s "$CODE" | miakapp pair) or run miakapp pair in a terminal to '
942
+ + 'be prompted without echo.');
943
+ }
944
+ return await host.readSecret('Pairing code (hidden): ');
945
+ }
946
+ /**
947
+ * Trades a one-time pairing code for a fresh Home Key and stores it as a new
948
+ * context. Every existing context is kept. The new context becomes current.
949
+ *
950
+ * Everything that can fail without spending the code — the issuer, the label,
951
+ * the name, the readability of the store and the issuer's discovery document —
952
+ * is checked before the code is sent, because a code works exactly once.
953
+ */
954
+ async function runPair(host, invocation) {
955
+ const issuer = pairingIssuer(invocation.options.get('issuer') ?? DEFAULT_ISSUER);
956
+ const label = validateLabel(invocation.options.get('label') ?? defaultLabel(host));
957
+ const requestedName = invocation.options.get('name');
958
+ if (requestedName !== undefined && !isContextName(requestedName))
959
+ contextNameError(requestedName);
960
+ const directory = storeDirectory(host);
961
+ const before = await readContexts(directory);
962
+ if (requestedName !== undefined && before.contexts.has(requestedName)) {
963
+ throw usageError(`A context named ${requestedName} already exists`, `Choose another --name, or remove it first with miakapp context remove ${requestedName}. `
964
+ + 'The code was not used.');
965
+ }
966
+ const code = normalizeCode(await pairingCode(host, invocation));
967
+ const options = host.fetch === undefined ? {} : { fetch: host.fetch };
968
+ let discovery;
969
+ try {
970
+ discovery = await fetchDiscovery({ issuer, ...options });
971
+ }
972
+ catch (error) {
973
+ const reason = error instanceof Error ? error.message : 'unknown failure';
974
+ throw contractError(`${issuer} did not prove it is a Miakapp control plane: ${reason}`, 'The code was not sent and is still valid until it expires. Check --issuer and the '
975
+ + 'network, then run pair again with the same code.');
976
+ }
977
+ const paired = await redeemPairingCode({ issuer, code, label, ...options });
978
+ const createdAt = new Date().toISOString();
979
+ let stored;
980
+ try {
981
+ stored = await mutateContexts(directory, (state) => {
982
+ const previous = [...state.contexts.values()]
983
+ .filter((context) => context.homeId === paired.homeId && context.issuer === paired.issuer);
984
+ const name = freeContextName(state.contexts, requestedName ?? paired.homeId);
985
+ state.contexts.set(name, {
986
+ name,
987
+ issuer: paired.issuer,
988
+ homeId: paired.homeId,
989
+ keyId: paired.keyId,
990
+ label,
991
+ createdAt,
992
+ });
993
+ state.keys.set(name, paired.homeKey);
994
+ state.current = name;
995
+ return { name, previous };
996
+ });
997
+ }
998
+ catch (error) {
999
+ const reason = error instanceof Error ? error.message : 'unknown failure';
1000
+ throw authorizationError(`Home Key ${paired.keyId} for ${paired.homeId} was issued but could not be stored in `
1001
+ + `${directory}: ${reason}`, `Ask the owner to revoke the key labelled ${JSON.stringify(label)}, fix the directory, then pair again.`);
1002
+ }
1003
+ // The key is stored; proving it can obtain a publication token tells the
1004
+ // agent now, rather than at its first publish, whether the grant is usable.
1005
+ let publishAccess = 'verified';
1006
+ let publishAccessDetail;
1007
+ try {
1008
+ await exchangePublisherToken(discovery, paired.homeKey, options);
1009
+ }
1010
+ catch (error) {
1011
+ publishAccess = 'refused';
1012
+ publishAccessDetail = error instanceof Error ? error.message : 'unknown failure';
1013
+ }
1014
+ const previous = stored.previous.map((context) => context.name);
1015
+ return {
1016
+ summary: `Paired ${paired.homeId} as context ${stored.name}`
1017
+ + (publishAccess === 'verified' ? '' : ' (the key could not obtain a publication token)'),
1018
+ fields: [
1019
+ ['context', stored.name],
1020
+ ['home', paired.homeId],
1021
+ ['issuer', paired.issuer],
1022
+ ['key_id', paired.keyId],
1023
+ ['label', label],
1024
+ ['current', 'yes'],
1025
+ ['publish_access', publishAccessDetail === undefined ? publishAccess : `${publishAccess}: ${publishAccessDetail}`],
1026
+ ['config', directory],
1027
+ ...(previous.length === 0 ? [] : [['earlier_contexts_for_this_home', previous]]),
1028
+ ],
1029
+ json: {
1030
+ context: stored.name,
1031
+ home_id: paired.homeId,
1032
+ issuer: paired.issuer,
1033
+ key_id: paired.keyId,
1034
+ label,
1035
+ current: true,
1036
+ publish_access: publishAccess,
1037
+ ...(publishAccessDetail === undefined ? {} : { publish_access_detail: publishAccessDetail }),
1038
+ config_directory: directory,
1039
+ earlier_contexts_for_this_home: previous,
1040
+ },
1041
+ };
1042
+ }
1043
+ function contextJson(context, current, credential) {
1044
+ return {
1045
+ name: context.name,
1046
+ home_id: context.homeId,
1047
+ issuer: context.issuer,
1048
+ key_id: context.keyId,
1049
+ label: context.label,
1050
+ created_at: context.createdAt,
1051
+ current: context.name === current,
1052
+ credential: credential ?? 'missing',
1053
+ };
1054
+ }
1055
+ /**
1056
+ * `context list | show [name] | use <name> | remove <name>`.
1057
+ *
1058
+ * Nothing here prints a Home Key: `credential` reports only whether the stored
1059
+ * key is present and is the key the context was paired with.
1060
+ */
1061
+ async function runContext(host, invocation) {
1062
+ const [action, name, extra] = invocation.positional;
1063
+ const directory = storeDirectory(host);
1064
+ if (extra !== undefined)
1065
+ throw usageError('context takes at most one name');
1066
+ const named = (verb) => {
1067
+ if (name === undefined)
1068
+ throw usageError(`context ${verb} requires a context name`);
1069
+ if (!isContextName(name))
1070
+ contextNameError(name);
1071
+ return name;
1072
+ };
1073
+ switch (action) {
1074
+ case 'list': {
1075
+ if (name !== undefined)
1076
+ throw usageError('context list takes no name');
1077
+ const state = await readContexts(directory);
1078
+ const contexts = [...state.contexts.values()];
1079
+ return {
1080
+ summary: contexts.length === 0
1081
+ ? `No context in ${directory}. Run miakapp pair to add one.`
1082
+ : `${counted(contexts.length, 'context', 'contexts')} in ${directory}`,
1083
+ fields: contexts.map((context) => [
1084
+ `${context.name === state.current ? '* ' : ''}${context.name}`,
1085
+ `home ${context.homeId} at ${context.issuer}, key ${context.keyId} `
1086
+ + `(${state.credentials.get(context.name) ?? 'missing'})`,
1087
+ ]),
1088
+ json: {
1089
+ config_directory: directory,
1090
+ current_context: state.current,
1091
+ contexts: contexts.map((context) => contextJson(context, state.current, state.credentials.get(context.name))),
1092
+ },
1093
+ };
1094
+ }
1095
+ case 'show': {
1096
+ const state = await readContexts(directory);
1097
+ const selected = name ?? state.current ?? undefined;
1098
+ if (selected === undefined) {
1099
+ throw usageError('No current context', 'Name one: miakapp context show <name>, or pair with miakapp pair.');
1100
+ }
1101
+ if (!isContextName(selected))
1102
+ contextNameError(selected);
1103
+ const context = state.contexts.get(selected);
1104
+ if (context === undefined) {
1105
+ throw usageError(`No context named ${selected}`, 'Run miakapp context list.');
1106
+ }
1107
+ const json = contextJson(context, state.current, state.credentials.get(context.name));
1108
+ return {
1109
+ summary: `Context ${context.name}${json['current'] === true ? ' (current)' : ''}`,
1110
+ fields: [
1111
+ ['home', context.homeId],
1112
+ ['issuer', context.issuer],
1113
+ ['key_id', context.keyId],
1114
+ ['label', context.label],
1115
+ ['created_at', context.createdAt],
1116
+ ['credential', `${String(json['credential'])} (never printed)`],
1117
+ ['config', directory],
1118
+ ],
1119
+ json: { ...json, config_directory: directory },
1120
+ };
1121
+ }
1122
+ case 'use': {
1123
+ const selected = named('use');
1124
+ const context = await mutateContexts(directory, (state) => {
1125
+ const found = state.contexts.get(selected);
1126
+ if (found === undefined) {
1127
+ throw usageError(`No context named ${selected}`, 'Run miakapp context list.');
1128
+ }
1129
+ state.current = selected;
1130
+ return found;
1131
+ });
1132
+ return {
1133
+ summary: `Current context is now ${selected}`,
1134
+ fields: [['home', context.homeId], ['issuer', context.issuer]],
1135
+ json: { current_context: selected, home_id: context.homeId, issuer: context.issuer },
1136
+ };
1137
+ }
1138
+ case 'remove': {
1139
+ const selected = named('remove');
1140
+ const result = await mutateContexts(directory, (state) => {
1141
+ const found = state.contexts.get(selected);
1142
+ if (found === undefined) {
1143
+ throw usageError(`No context named ${selected}`, 'Run miakapp context list.');
1144
+ }
1145
+ state.contexts.delete(selected);
1146
+ state.keys.delete(selected);
1147
+ const wasCurrent = state.current === selected;
1148
+ if (wasCurrent)
1149
+ state.current = null;
1150
+ return { context: found, wasCurrent };
1151
+ });
1152
+ return {
1153
+ summary: `Removed context ${selected} and its stored key`,
1154
+ fields: [
1155
+ ['home', result.context.homeId],
1156
+ ['key_id', result.context.keyId],
1157
+ ['still_valid_on_server', 'yes: ask the owner to revoke it if it is no longer needed'],
1158
+ ],
1159
+ json: {
1160
+ removed: selected,
1161
+ home_id: result.context.homeId,
1162
+ key_id: result.context.keyId,
1163
+ was_current: result.wasCurrent,
1164
+ revoked: false,
1165
+ },
1166
+ };
1167
+ }
1168
+ default:
1169
+ throw usageError(action === undefined ? 'context requires an action' : `Unknown context action: ${action}`, 'Use context list, context show [name], context use <name> or context remove <name>.');
1170
+ }
1171
+ }
569
1172
  /**
570
1173
  * Runs one parsed invocation.
571
1174
  *
@@ -574,6 +1177,14 @@ async function runUpload(host, invocation) {
574
1177
  */
575
1178
  export async function dispatch(host, invocation) {
576
1179
  switch (invocation.command) {
1180
+ case 'docs':
1181
+ return await runDocs(host, invocation);
1182
+ case 'pair':
1183
+ return await runPair(host, invocation);
1184
+ case 'context':
1185
+ return await runContext(host, invocation);
1186
+ case 'status':
1187
+ return await runStatus(host, invocation);
577
1188
  case 'init':
578
1189
  return await runInit(host, invocation);
579
1190
  case 'agent-pack':
@@ -596,6 +1207,8 @@ export async function dispatch(host, invocation) {
596
1207
  }
597
1208
  }
598
1209
  function renderText(result) {
1210
+ if (result.text !== undefined)
1211
+ return result.text;
599
1212
  const lines = [result.summary];
600
1213
  for (const [key, value] of result.fields) {
601
1214
  lines.push(` ${key}: ${Array.isArray(value) ? `[${value.join(', ')}]` : String(value)}`);