octwin-cli 0.1.13 → 0.1.15

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/index.js CHANGED
@@ -19,6 +19,11 @@
19
19
  * octwin logs [conversationId] [--as h] [--json] # list conversations / show one's timeline
20
20
  * octwin chat "msg" [--as h] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn via the web channel (+ send media)
21
21
  * octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (media:generate scope)
22
+ * octwin agents [packId::agentId] [--prompt] # effective model/memory + which layer won; --prompt = the resolved system prompt
23
+ * octwin orders [reference_id] # the orders a conversation produced — money breakdown + payment state (orders:read)
24
+ * octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion, any pipelined entity
25
+ * octwin catalog [--readiness] # commerce products + stock + the WhatsApp catalog binding (catalog:read)
26
+ * octwin scheduling [--slots <resourceRecordId>] # engine state / computed slots (scheduling:read)
22
27
  * octwin platform-kb [pull] [--dir .] # pull the platform capability reference for the authoring skill
23
28
  * octwin test [--dir .] # = validate --remote (the full platform check)
24
29
  *
@@ -99,12 +104,66 @@ function authFailureHint(status, url) {
99
104
  ? `the token was rejected — invalid / expired / revoked. If it JUST worked, this can be a one-off platform hiccup: retry once before re-logging in (octwin login --url ${url} --token oct_…)`
100
105
  : `the token is valid but not authorized here (missing scope, plan feature, or role)`;
101
106
  }
102
- /** Print the auth-scope hint below an HTTP-failure line when it's a 401/403 —
103
- * so every command explains a token problem, not just the inspect family
104
- * (author-feedback A7). No-op for other statuses. */
107
+ /**
108
+ * The scope (and plan feature, where the route is entitlement-gated) each command's
109
+ * endpoints require — a client-side mirror of the platform's scope registry
110
+ * (`src/platform/core/identity/scopes.ts` `SCOPE_REGISTRY`) narrowed to what the CLI
111
+ * calls. A 403 that names the missing scope is the difference between a two-minute
112
+ * fix (mint a wider token) and a support thread.
113
+ *
114
+ * `pack:deploy` and `media:generate` have access `special`: they match by DIRECT
115
+ * grant only, so even a `tenant:admin` preset token does NOT confer them
116
+ * (`scopeSatisfies` — the preset branches only reach `:read`/`:write`). That is the
117
+ * single most common "but my token is admin" confusion, hence the explicit note below.
118
+ */
119
+ const COMMAND_REQUIREMENTS = {
120
+ deploy: { scope: 'pack:deploy' },
121
+ validate: { scope: 'pack:deploy' },
122
+ status: { scope: 'pack:deploy' },
123
+ test: { scope: 'pack:deploy' },
124
+ 'platform-kb': { scope: 'pack:deploy' },
125
+ media: { scope: 'media:generate' },
126
+ records: { scope: 'records:read', feature: 'records' },
127
+ analytics: { scope: 'records:read', feature: 'records' },
128
+ cases: { scope: 'cases:read', feature: 'cases' },
129
+ logs: { scope: 'conversations:read' },
130
+ orders: { scope: 'orders:read', feature: 'orders' },
131
+ catalog: { scope: 'catalog:read', feature: 'catalog' },
132
+ scheduling: { scope: 'scheduling:read' },
133
+ agents: { scope: 'agents:read' },
134
+ };
135
+ /** The command currently running — set once in `main()` so any failure printer can
136
+ * name the scope that command needs without threading it through every call. */
137
+ let CURRENT_COMMAND;
138
+ /** `→ needs the \`orders:read\` scope …` — the requirement line for the running
139
+ * command, or '' when the command has no declared requirement. */
140
+ function scopeRequirementHint() {
141
+ const req = CURRENT_COMMAND ? COMMAND_REQUIREMENTS[CURRENT_COMMAND] : undefined;
142
+ if (!req)
143
+ return '';
144
+ const special = req.scope === 'pack:deploy' || req.scope === 'media:generate';
145
+ return `needs the \`${req.scope}\` scope`
146
+ + (special ? ' (granted DIRECTLY only — a `tenant:admin` token does not confer it)' : '')
147
+ + (req.feature ? `, and the \`${req.feature}\` plan feature on this workspace` : '');
148
+ }
149
+ /** Print the auth hints below an HTTP-failure line when it's a 401/403 — so every
150
+ * command explains a token problem, not just the inspect family (author-feedback A7):
151
+ * WHY it failed, then WHAT the command needs. No-op for other statuses. */
105
152
  function printAuthHint(status, url) {
106
- if (status === 401 || status === 403)
107
- console.error(` → ${authFailureHint(status, url)}`);
153
+ if (status !== 401 && status !== 403)
154
+ return;
155
+ console.error(` → ${authFailureHint(status, url)}`);
156
+ const req = scopeRequirementHint();
157
+ if (req)
158
+ console.error(` → ${req}`);
159
+ }
160
+ /** The same two hints folded into ONE line, for the `die(...)` call sites that
161
+ * report an auth failure inline instead of via `printAuthHint`. */
162
+ function authFailureDetail(status, url) {
163
+ if (status !== 401 && status !== 403)
164
+ return '';
165
+ const req = scopeRequirementHint();
166
+ return ` — ${authFailureHint(status, url)}${req ? `; ${req}` : ''}`;
108
167
  }
109
168
  /** Pretty-print a JSON error body (or raw text) for an HTTP failure line. */
110
169
  function errDetail(json) {
@@ -335,8 +394,8 @@ async function notifyIfKbStale(flags) {
335
394
  return;
336
395
  const ctrl = new AbortController();
337
396
  const timer = setTimeout(() => ctrl.abort(), 2_000);
338
- const res = await fetch(`${t.url}/api/admin/tenants/${t.tenant}/octwin-platform-kb?meta=1`, {
339
- headers: { authorization: `Bearer ${t.token}` }, signal: ctrl.signal,
397
+ const res = await fetch(`${t.url}/api/self/t/octwin-platform-kb?meta=1`, {
398
+ headers: authHeaders(t), signal: ctrl.signal,
340
399
  });
341
400
  clearTimeout(timer);
342
401
  if (!res.ok)
@@ -376,7 +435,12 @@ function commandTouchesPlatform(command, flags) {
376
435
  case 'records':
377
436
  case 'cases':
378
437
  case 'logs':
379
- case 'whoami': return true;
438
+ case 'whoami':
439
+ case 'agents':
440
+ case 'orders':
441
+ case 'analytics':
442
+ case 'catalog':
443
+ case 'scheduling': return true;
380
444
  default: return false;
381
445
  }
382
446
  }
@@ -402,10 +466,11 @@ function cmdInit(flags) {
402
466
  displayName: flags['display-name'] ?? undefined,
403
467
  });
404
468
  // Deploy config + repo hygiene + a README.
469
+ // The token carries its own tenant (and optional project pin), so pack.json
470
+ // needs only the platform URL. `tenant`/`project` may be added as optional
471
+ // overrides (they also seed `octwin chat`, which is tenant/project-pathed).
405
472
  writeFileSync(join(dir, 'pack.json'), JSON.stringify({
406
473
  platform_url: 'http://localhost:3000',
407
- tenant: 'your-tenant-slug',
408
- project: 'main',
409
474
  }, null, 2) + '\n', 'utf8');
410
475
  writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.pack-bundles/\n.octwin/\n', 'utf8');
411
476
  if (!existsSync(join(dir, 'README.md'))) {
@@ -417,7 +482,7 @@ function cmdInit(flags) {
417
482
  console.log(' git init && git add -A && git commit -m "init pack"');
418
483
  console.log(' # edit manifest.yaml / flows / prompts, then:');
419
484
  console.log(' octwin validate');
420
- console.log(' # set platform_url + tenant + project in pack.json, then:');
485
+ console.log(' # set platform_url in pack.json (tenant comes from your token), then:');
421
486
  console.log(' octwin login --url <platformUrl> --token <deploy-token>');
422
487
  console.log(' octwin deploy');
423
488
  }
@@ -443,11 +508,12 @@ async function cmdValidate(flags) {
443
508
  }
444
509
  // Remote: the SAME validation the deploy route runs — manifest `.strict()` +
445
510
  // every flow (schema/expression/structure) — returning ALL errors at once.
446
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
447
- console.log(`→ Validating against ${tenant}/${project} @ ${url} …`);
448
- const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/validate`, {
511
+ const t = resolveTarget(flags, packDir);
512
+ const { url } = t;
513
+ console.log(`→ Validating against ${targetLabel(t)} @ ${url} …`);
514
+ const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
449
515
  method: 'POST',
450
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
516
+ headers: { 'content-type': 'application/json', ...authHeaders(t) },
451
517
  body: JSON.stringify({ files }),
452
518
  }, 'remote validate');
453
519
  const text = await res.text();
@@ -467,6 +533,12 @@ async function cmdValidate(flags) {
467
533
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
468
534
  process.exit(1);
469
535
  }
536
+ const warnings = (json?.warnings ?? []);
537
+ if (warnings.length) {
538
+ console.log(` ⚠ ${warnings.length} warning${warnings.length === 1 ? '' : 's'} (won't block deploy):`);
539
+ for (const w of warnings)
540
+ console.log(` ⚠ [${w.where}] ${w.message}`);
541
+ }
470
542
  if (json?.ok) {
471
543
  console.log(`✓ ${id}@${version} passes the platform's FULL validation — deploy won't reject on schema.`);
472
544
  return;
@@ -486,55 +558,83 @@ async function cmdValidate(flags) {
486
558
  }
487
559
  process.exit(1);
488
560
  }
489
- function cmdLogin(flags) {
490
- const url = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
561
+ async function cmdLogin(flags) {
562
+ const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
563
+ const url = rawUrl.replace(/\/$/, '');
491
564
  const token = flags.token ?? process.env.PACK_TOKEN ?? die('missing --token');
492
565
  const creds = readCreds();
493
- creds[url.replace(/\/$/, '')] = token;
566
+ creds[url] = token;
494
567
  writeCreds(creds);
495
568
  console.log(`✓ Saved token for ${url}`);
569
+ // Best-effort: echo what the token reaches (workspace + project pin + scopes)
570
+ // so a fresh token self-identifies without a second `octwin whoami`. A network
571
+ // failure never fails the save — the token is stored regardless.
572
+ try {
573
+ const res = await fetch(`${url}/api/self/t/whoami`, { headers: { authorization: `Bearer ${token}` } });
574
+ if (res.ok) {
575
+ const j = await res.json();
576
+ const scopes = Array.isArray(j.scopes) && j.scopes.length ? ` · scopes: ${j.scopes.join(', ')}` : '';
577
+ console.log(` → workspace '${j.tenant_slug}'${j.project_slug ? `, pinned to project '${j.project_slug}'` : ''}${scopes}`);
578
+ }
579
+ else if (res.status === 401 || res.status === 403) {
580
+ console.log(` ⚠ token saved, but the platform rejected it (HTTP ${res.status}) — check it's a current oct_… token`);
581
+ }
582
+ }
583
+ catch { /* platform unreachable — the token is saved regardless */ }
496
584
  }
497
- /** Resolve platform url + tenant + project + token: flags > pack.json > env > saved login. */
585
+ /** Bearer auth + the optional self-surface overrides, as request headers. */
586
+ function authHeaders(t) {
587
+ const h = { authorization: `Bearer ${t.token}` };
588
+ if (t.tenant)
589
+ h['x-octwin-tenant'] = t.tenant;
590
+ if (t.project)
591
+ h['x-octwin-project'] = t.project;
592
+ return h;
593
+ }
594
+ /** Resolve platform url + token (+ optional tenant/project overrides):
595
+ * flags > pack.json > env > saved login. Tenant is derived from the token
596
+ * server-side, so only url + token are required. */
498
597
  function resolveTarget(flags, packDir) {
499
598
  const cfg = readPackConfig(packDir);
500
599
  const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
501
- const tenant = flags.tenant ?? process.env.PACK_TENANT ?? cfg.tenant ?? '';
502
- const project = flags.project ?? process.env.PACK_PROJECT ?? cfg.project ?? 'main';
600
+ const tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || undefined;
601
+ const project = flags.project || process.env.PACK_PROJECT || cfg.project || undefined;
503
602
  const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
504
603
  if (!url)
505
604
  die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
506
- if (!tenant)
507
- die('no tenant — set it in pack.json, --tenant, or PACK_TENANT');
508
605
  if (!token)
509
- die('no token — generate a deploy token in the console (Settings → API tokens), then `octwin login --url <url> --token oct_…` or pass --token');
510
- return { url, tenant, project, token };
606
+ die('no token — generate an API token in the console (Settings → API tokens), then `octwin login --url <url> --token oct_…` or pass --token');
607
+ return { url, token, tenant, project };
511
608
  }
512
- /** Non-fatal `resolveTarget`: returns null (never dies) when any of url/tenant/token
513
- * is missing. Used by the fail-silent KB-staleness observer, which must never
609
+ /** Non-fatal `resolveTarget`: returns null (never dies) when url or token is
610
+ * missing. Used by the fail-silent KB-staleness observer, which must never
514
611
  * interrupt a command over a config gap. */
515
612
  function resolveTargetOrNull(flags, packDir) {
516
613
  const cfg = readPackConfig(packDir);
517
614
  const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
518
- const tenant = flags.tenant ?? process.env.PACK_TENANT ?? cfg.tenant ?? '';
519
- const project = flags.project ?? process.env.PACK_PROJECT ?? cfg.project ?? 'main';
615
+ const tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || undefined;
616
+ const project = flags.project || process.env.PACK_PROJECT || cfg.project || undefined;
520
617
  const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
521
- if (!url || !tenant || !token)
618
+ if (!url || !token)
522
619
  return null;
523
- return { url, tenant, project, token };
620
+ return { url, token, tenant, project };
524
621
  }
525
622
  async function cmdWhoami(flags) {
526
623
  const packDir = resolve(flags.dir ?? '.');
527
- const { url, tenant, token } = resolveTarget(flags, packDir);
528
- console.log(`→ Checking the saved token against '${tenant}' @ ${url} …`);
529
- const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/packs`, { headers: { authorization: `Bearer ${token}` } }, 'token check');
624
+ const t = resolveTarget(flags, packDir);
625
+ console.log(`→ Checking the token against ${t.url} …`);
626
+ const res = await fetchOrDie(`${t.url}/api/self/t/whoami`, { headers: authHeaders(t) }, 'token check');
530
627
  if (res.ok) {
531
- console.log(`✓ Token valid for tenant '${tenant}' at ${url} (${token.startsWith('oct_') ? 'deploy token' : 'session token'})`);
628
+ const j = await res.json();
629
+ console.log(`✓ Token valid — workspace '${j.tenant_slug}'${j.project_slug ? `, pinned to project '${j.project_slug}'` : ''} (${j.kind === 'api_token' ? 'API token' : 'session'})`);
630
+ if (Array.isArray(j.scopes))
631
+ console.log(` scopes: ${j.scopes.length ? j.scopes.join(', ') : '(none)'}`);
532
632
  return;
533
633
  }
534
634
  const why = res.status === 401 ? 'invalid / expired / revoked token'
535
- : res.status === 403 ? 'token not authorized for this tenant'
635
+ : res.status === 403 ? 'token not authorized for this workspace'
536
636
  : await res.text();
537
- die(`token check failed for '${tenant}' (HTTP ${res.status}) — ${why}`);
637
+ die(`token check failed (HTTP ${res.status}) — ${why}`);
538
638
  }
539
639
  /**
540
640
  * Read the deploy SSE stream, printing each progress frame's message live, and
@@ -547,6 +647,11 @@ async function readDeployProgress(body) {
547
647
  const decoder = new TextDecoder();
548
648
  let buf = '';
549
649
  let terminal = null;
650
+ // Non-terminal frames with `status:'error'` are step failures the install
651
+ // SOFTENS to non-fatal (e.g. a demo-seed row) — the reconcile keeps going and
652
+ // still emits a `done`. We collect them so the deploy is NOT reported as a
653
+ // clean ✓ when a step actually failed (the false-✓ trap).
654
+ const stepErrors = [];
550
655
  for (;;) {
551
656
  const { done, value } = await reader.read();
552
657
  if (done)
@@ -570,14 +675,18 @@ async function readDeployProgress(body) {
570
675
  terminal = ev;
571
676
  continue;
572
677
  }
573
- if (ev.message)
574
- console.log(` ${ev.status === 'error' ? '⚠' : '·'} ${ev.message}`);
678
+ if (ev.message) {
679
+ const isErr = ev.status === 'error';
680
+ if (isErr)
681
+ stepErrors.push(ev.message);
682
+ console.log(` ${isErr ? '⚠' : '·'} ${ev.message}`);
683
+ }
575
684
  }
576
685
  }
577
- return terminal;
686
+ return { terminal, stepErrors };
578
687
  }
579
- function printDeploySuccess(id, version, tenant, project, r) {
580
- console.log(`✓ Deployed ${id}@${version} and installed onto ${tenant}/${project}`);
688
+ function printDeploySuccess(id, version, t, r) {
689
+ console.log(`✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
581
690
  if (r?.warning)
582
691
  console.log(` ⚠ ${r.warning}`);
583
692
  const s = r?.summary;
@@ -594,29 +703,39 @@ function printDeploySuccess(id, version, tenant, project, r) {
594
703
  if (parts.length)
595
704
  console.log(` Seeded: ${parts.join(', ')}`);
596
705
  }
597
- console.log(`\nChat with it on your tenant (web widget / console test page for ${tenant}/${project}).`);
706
+ console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
598
707
  }
599
708
  async function cmdDeploy(flags) {
600
709
  const packDir = resolve(flags.dir ?? '.');
601
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
710
+ const t = resolveTarget(flags, packDir);
711
+ const { url } = t;
602
712
  const { id, version, files } = localValidate(packDir);
603
- const endpoint = `${url}/api/admin/tenants/${tenant}/projects/${project}/packs/deploy`;
713
+ const endpoint = `${url}/api/self/p/packs/deploy`;
604
714
  const seed = flags.seed === true;
605
- console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${tenant}/${project}${seed ? ' — with demo seed' : ''} …`);
715
+ console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${seed ? ' — with demo seed' : ''} …`);
606
716
  const res = await fetchOrDie(endpoint, {
607
717
  method: 'POST',
608
718
  // Ask for a progress stream; the platform falls back to plain JSON if it
609
719
  // (or an error before any progress) can't stream — handled below.
610
- headers: { 'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${token}` },
720
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
611
721
  body: JSON.stringify({ files, seed }),
612
722
  }, 'deploy');
613
723
  // Streaming path — live install + seed progress (image generation can take a
614
724
  // while, so `--seed` prints per-record / per-image lines as they happen).
615
725
  if (res.ok && (res.headers.get('content-type') ?? '').includes('text/event-stream') && res.body) {
616
- const final = await readDeployProgress(res.body);
726
+ const { terminal: final, stepErrors } = await readDeployProgress(res.body);
617
727
  if (!final || final.stage === 'error')
618
728
  die(`deploy failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
619
- printDeploySuccess(id, version, tenant, project, final);
729
+ printDeploySuccess(id, version, t, final);
730
+ if (stepErrors.length) {
731
+ // The pack IS installed, but a step (e.g. the demo seed) failed — say so
732
+ // plainly and exit non-zero so CI / a `deploy && chat` chain doesn't treat
733
+ // an incomplete install as a clean success.
734
+ console.error(`\n⚠ Deployed with ${stepErrors.length} warning${stepErrors.length === 1 ? '' : 's'} — data may be incomplete:`);
735
+ for (const e of stepErrors)
736
+ console.error(` • ${e}`);
737
+ process.exit(1);
738
+ }
620
739
  return;
621
740
  }
622
741
  // Non-streaming path — plain JSON (older platform, or an error thrown before
@@ -635,11 +754,12 @@ async function cmdDeploy(flags) {
635
754
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
636
755
  process.exit(1);
637
756
  }
638
- printDeploySuccess(id, version, tenant, project, json);
757
+ printDeploySuccess(id, version, t, json);
639
758
  }
640
759
  async function cmdStatus(flags) {
641
760
  const packDir = resolve(flags.dir ?? '.');
642
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
761
+ const t = resolveTarget(flags, packDir);
762
+ const { url } = t;
643
763
  const manifestPath = join(packDir, 'manifest.yaml');
644
764
  if (!existsSync(manifestPath))
645
765
  die('no manifest.yaml in the pack directory (run from your pack dir or pass --dir)');
@@ -648,9 +768,9 @@ async function cmdStatus(flags) {
648
768
  die('manifest.yaml must declare a string `id`');
649
769
  const id = doc.id;
650
770
  const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
651
- console.log(`→ Checking ${id}@${localVersion} on ${tenant}/${project} @ ${url} …`);
652
- const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/${id}/runtime`, {
653
- headers: { authorization: `Bearer ${token}` },
771
+ console.log(`→ Checking ${id}@${localVersion} on ${targetLabel(t)} @ ${url} …`);
772
+ const res = await fetchOrDie(`${url}/api/self/p/packs/${id}/runtime`, {
773
+ headers: authHeaders(t),
654
774
  }, 'status check');
655
775
  const text = await res.text();
656
776
  let json;
@@ -662,13 +782,13 @@ async function cmdStatus(flags) {
662
782
  }
663
783
  if (!res.ok) {
664
784
  if (res.status === 404)
665
- die(`'${id}' is not installed on ${tenant}/${project} yet — run \`octwin deploy\` first`);
785
+ die(`'${id}' is not installed on ${targetLabel(t)} yet — run \`octwin deploy\` first`);
666
786
  console.error(`✗ status check failed (HTTP ${res.status})`);
667
787
  printAuthHint(res.status, url);
668
788
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
669
789
  process.exit(1);
670
790
  }
671
- console.log(`${id} on ${tenant}/${project} @ ${url}`);
791
+ console.log(`${id} on ${targetLabel(t)} @ ${url}`);
672
792
  console.log(` installed version : ${json.installed_version}`);
673
793
  console.log(` live on instance : registered=${json.registered} source=${json.source} loaded=${json.loaded_version ?? '(none)'}`);
674
794
  console.log(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
@@ -687,10 +807,11 @@ async function cmdStatus(flags) {
687
807
  }
688
808
  async function cmdPlatformKb(flags) {
689
809
  const packDir = resolve(flags.dir ?? '.');
690
- const { url, tenant, token } = resolveTarget(flags, packDir);
691
- console.log(`→ Pulling the platform capability reference from '${tenant}' @ ${url} …`);
692
- const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/octwin-platform-kb`, {
693
- headers: { authorization: `Bearer ${token}` },
810
+ const t = resolveTarget(flags, packDir);
811
+ const { url } = t;
812
+ console.log(`→ Pulling the platform capability reference from ${url} …`);
813
+ const res = await fetchOrDie(`${url}/api/self/t/octwin-platform-kb`, {
814
+ headers: authHeaders(t),
694
815
  }, 'platform-kb pull');
695
816
  const text = await res.text();
696
817
  if (!res.ok) {
@@ -755,8 +876,8 @@ async function cmdPlatformKb(flags) {
755
876
  /** GET an admin endpoint with the deploy token; returns `{ status, json }`.
756
877
  * Dies (with the URL) on a network failure; auth failures return so the
757
878
  * caller can add command-specific context on top of `authFailureHint`. */
758
- async function apiGet(endpoint, token) {
759
- const res = await fetchOrDie(endpoint, { headers: { authorization: `Bearer ${token}` } }, 'request');
879
+ async function apiGet(endpoint, t) {
880
+ const res = await fetchOrDie(endpoint, { headers: authHeaders(t) }, 'request');
760
881
  const text = await res.text();
761
882
  let json;
762
883
  try {
@@ -767,16 +888,22 @@ async function apiGet(endpoint, token) {
767
888
  }
768
889
  return { status: res.status, json };
769
890
  }
891
+ /** A progress-line label for the target workspace. The token names the tenant, so
892
+ * we surface at most the project (when pinned or overridden by `--project`). */
893
+ function targetLabel(t) {
894
+ return t.project ? `project '${t.project}'` : 'your workspace';
895
+ }
770
896
  /** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
771
897
  async function cmdRecords(flags) {
772
898
  const packDir = resolve(flags.dir ?? '.');
773
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
774
- const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
899
+ const t = resolveTarget(flags, packDir);
900
+ const { url } = t;
901
+ const base = `${url}/api/self/p`;
775
902
  const entity = flags._[0];
776
903
  const recordId = flags._[1];
777
- console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${tenant}/${project} …`);
904
+ console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${targetLabel(t)} …`);
778
905
  if (!entity) {
779
- const { status, json } = await apiGet(`${base}/xrm/entities`, token);
906
+ const { status, json } = await apiGet(`${base}/xrm/entities`, t);
780
907
  if (status !== 200)
781
908
  die(`could not read entities (HTTP ${status})`);
782
909
  if (json?.has_xrm === false) {
@@ -788,7 +915,7 @@ async function cmdRecords(flags) {
788
915
  console.log('No entities visible — mint a token with the `records:read` scope to inspect data.');
789
916
  return;
790
917
  }
791
- console.log(`Entities in ${tenant}/${project}:`);
918
+ console.log(`Entities in ${targetLabel(t)}:`);
792
919
  for (const e of ents)
793
920
  console.log(` ${e.entity} (${e.open_count ?? 0} records)`);
794
921
  console.log('\nList records: octwin records <entity>');
@@ -796,16 +923,14 @@ async function cmdRecords(flags) {
796
923
  }
797
924
  if (!recordId) {
798
925
  const limit = flags.limit ?? '50';
799
- const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, token);
800
- if (status === 403)
801
- die('forbidden — the paged record list needs the `records` plan feature on this tenant');
926
+ const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, t);
802
927
  if (status !== 200) {
803
928
  // Always show the server's reason (it names the unknown entity). Cases are
804
929
  // casework (worklist), not pack-declared XRM — point at the right command.
805
930
  if (entity === 'case' || entity === 'cases') {
806
931
  console.error(` '${entity}' is casework (worklist), not a pack-declared XRM entity — inspect tickets with: octwin cases`);
807
932
  }
808
- die(`could not read records (HTTP ${status})${errDetail(json)}${status === 401 ? ` — ${authFailureHint(status, url)}` : ''}`);
933
+ die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
809
934
  }
810
935
  const rows = (json?.records ?? []);
811
936
  console.log(`${entity}: ${json?.total ?? rows.length} record(s)`);
@@ -815,32 +940,29 @@ async function cmdRecords(flags) {
815
940
  console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'}${r.stage ? ` [${r.stage}]` : ''} ${r.id}`);
816
941
  return;
817
942
  }
818
- const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`, token);
819
- if (status === 403)
820
- die('forbidden — mint a token with the `records:read` scope');
943
+ const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`, t);
821
944
  if (status === 404)
822
945
  die(`record '${recordId}' not found`);
823
- if (status === 401)
824
- die(`could not read record — ${authFailureHint(401, url)}`);
825
946
  if (status !== 200)
826
- die(`could not read record (HTTP ${status})`);
947
+ die(`could not read record (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
827
948
  console.log(JSON.stringify(json?.record ?? json, null, 2));
828
949
  }
829
950
  /** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
830
951
  * or show one's event timeline (full text + the renders each turn produced). */
831
952
  async function cmdLogs(flags) {
832
953
  const packDir = resolve(flags.dir ?? '.');
833
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
834
- const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
954
+ const t = resolveTarget(flags, packDir);
955
+ const { url } = t;
956
+ const base = `${url}/api/self/p`;
835
957
  const convId = flags._[0];
836
958
  const asJson = flags.json === true;
837
959
  const asHandle = typeof flags.as === 'string' ? flags.as : undefined;
838
960
  if (!asJson)
839
- console.log(`→ Reading ${convId ? `conversation ${convId}` : 'recent conversations'} from ${tenant}/${project} …`);
961
+ console.log(`→ Reading ${convId ? `conversation ${convId}` : 'recent conversations'} from ${targetLabel(t)} …`);
840
962
  if (!convId) {
841
- const { status, json } = await apiGet(`${base}/conversations?limit=50`, token);
963
+ const { status, json } = await apiGet(`${base}/conversations?limit=50`, t);
842
964
  if (status !== 200)
843
- die(`could not read conversations (HTTP ${status})${errDetail(json)}${authFailureHint(status, url)}`);
965
+ die(`could not read conversations (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
844
966
  let convs = (json?.conversations ?? []);
845
967
  if (asHandle)
846
968
  convs = convs.filter((c) => c.contact?.channel_contact_handle === asHandle);
@@ -852,7 +974,7 @@ async function cmdLogs(flags) {
852
974
  console.log(JSON.stringify(convs, null, 2));
853
975
  return;
854
976
  }
855
- console.log(`Recent conversations in ${tenant}/${project}${asHandle ? ` (handle: ${asHandle})` : ''}:`);
977
+ console.log(`Recent conversations in ${targetLabel(t)}${asHandle ? ` (handle: ${asHandle})` : ''}:`);
856
978
  for (const c of convs) {
857
979
  const handle = c.contact?.channel_contact_handle ?? '?';
858
980
  const name = c.contact?.display_name && c.contact.display_name !== handle ? ` (${c.contact.display_name})` : '';
@@ -862,11 +984,11 @@ async function cmdLogs(flags) {
862
984
  console.log('\nView a timeline: octwin logs <conversationId> (add --json for full payloads)');
863
985
  return;
864
986
  }
865
- const { status, json } = await apiGet(`${base}/conversations/${encodeURIComponent(convId)}`, token);
987
+ const { status, json } = await apiGet(`${base}/conversations/${encodeURIComponent(convId)}`, t);
866
988
  if (status === 404)
867
989
  die(`conversation '${convId}' not found`);
868
990
  if (status !== 200)
869
- die(`could not read conversation (HTTP ${status})${errDetail(json)}${authFailureHint(status, url)}`);
991
+ die(`could not read conversation (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
870
992
  const events = (json?.events ?? []);
871
993
  if (asJson) {
872
994
  console.log(JSON.stringify(events, null, 2));
@@ -1017,12 +1139,33 @@ async function cmdChat(flags) {
1017
1139
  const packDir = resolve(flags.dir ?? '.');
1018
1140
  const cfg = readPackConfig(packDir);
1019
1141
  const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
1020
- const tenant = flags.tenant ?? process.env.PACK_TENANT ?? cfg.tenant ?? '';
1021
- const project = flags.project ?? process.env.PACK_PROJECT ?? cfg.project ?? 'main';
1022
1142
  if (!url)
1023
1143
  die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
1144
+ let tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || '';
1145
+ let project = flags.project || process.env.PACK_PROJECT || cfg.project || '';
1146
+ // The dev web channel is tenant/project-pathed (it simulates an end-user on a
1147
+ // specific project). When they aren't configured, derive them from the token —
1148
+ // its tenant + optional project pin — via the slug-free `/api/self/t/whoami`.
1149
+ if (!tenant || !project) {
1150
+ const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
1151
+ if (token) {
1152
+ try {
1153
+ const who = await fetch(`${url}/api/self/t/whoami`, {
1154
+ headers: { authorization: `Bearer ${token}`, ...(tenant ? { 'x-octwin-tenant': tenant } : {}) },
1155
+ });
1156
+ if (who.ok) {
1157
+ const j = await who.json();
1158
+ tenant = tenant || (j.tenant_slug ?? '');
1159
+ project = project || (j.project_slug ?? '');
1160
+ }
1161
+ }
1162
+ catch { /* fall through to the checks below */ }
1163
+ }
1164
+ }
1024
1165
  if (!tenant)
1025
- die('no tenant — set it in pack.json, --tenant, or PACK_TENANT');
1166
+ die('no tenant — set --tenant / PACK_TENANT / pack.json, or pass a --token to derive it');
1167
+ if (!project)
1168
+ project = 'main';
1026
1169
  const from = flags.as ?? 'cli-tester';
1027
1170
  const asJson = flags.json === true;
1028
1171
  const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
@@ -1136,7 +1279,8 @@ async function cmdMedia(flags) {
1136
1279
  if (sub !== 'generate')
1137
1280
  die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
1138
1281
  const packDir = resolve(flags.dir ?? '.');
1139
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
1282
+ const t = resolveTarget(flags, packDir);
1283
+ const { url } = t;
1140
1284
  const prompt = flags._[1];
1141
1285
  if (!prompt)
1142
1286
  die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
@@ -1144,10 +1288,10 @@ async function cmdMedia(flags) {
1144
1288
  const size = typeof flags.size === 'string' ? flags.size : undefined;
1145
1289
  const out = typeof flags.out === 'string' ? flags.out : undefined;
1146
1290
  if (!asJson)
1147
- console.log(`→ Generating an image on '${tenant}/${project}' @ ${url} …`);
1148
- const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/media/generate`, {
1291
+ console.log(`→ Generating an image on ${targetLabel(t)} @ ${url} …`);
1292
+ const res = await fetchOrDie(`${url}/api/self/p/media/generate`, {
1149
1293
  method: 'POST',
1150
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
1294
+ headers: { 'content-type': 'application/json', ...authHeaders(t) },
1151
1295
  body: JSON.stringify({ prompt, ...(size ? { size } : {}) }),
1152
1296
  }, 'media generate');
1153
1297
  const text = await res.text();
@@ -1191,19 +1335,22 @@ async function cmdMedia(flags) {
1191
1335
  * the aggregate inbox, one case + its timeline, or the queue list. */
1192
1336
  async function cmdCases(flags) {
1193
1337
  const packDir = resolve(flags.dir ?? '.');
1194
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
1195
- const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
1338
+ const t = resolveTarget(flags, packDir);
1339
+ const { url } = t;
1340
+ const base = `${url}/api/self/p`;
1196
1341
  const caseId = flags._[0];
1197
1342
  const asJson = flags.json === true;
1198
1343
  if (!asJson)
1199
- console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${tenant}/${project} …`);
1344
+ console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${targetLabel(t)} …`);
1200
1345
  const caseFail = (what, status, json) => {
1346
+ // A 403 here can also be an RBAC gap the scope hint can't see — a role whose
1347
+ // grants don't reach the queue passes the scope gate and still gets nothing.
1201
1348
  if (status === 403)
1202
- die(`forbidden — casework needs the 'cases' plan feature on this tenant, and a role whose grants reach the queue`);
1203
- die(`could not read ${what} (HTTP ${status})${errDetail(json)}${status === 401 ? ` — ${authFailureHint(status, url)}` : ''}`);
1349
+ console.error(' (a role whose grants reach the queue is also required)');
1350
+ die(`could not read ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1204
1351
  };
1205
1352
  if (flags.queues === true) {
1206
- const { status, json } = await apiGet(`${base}/case-queues`, token);
1353
+ const { status, json } = await apiGet(`${base}/case-queues`, t);
1207
1354
  if (status !== 200)
1208
1355
  caseFail('case queues', status, json);
1209
1356
  if (asJson) {
@@ -1211,7 +1358,7 @@ async function cmdCases(flags) {
1211
1358
  return;
1212
1359
  }
1213
1360
  const queues = (json?.queues ?? []);
1214
- console.log(`Case queues in ${tenant}/${project}:`);
1361
+ console.log(`Case queues in ${targetLabel(t)}:`);
1215
1362
  for (const q of queues)
1216
1363
  console.log(` ${q.key}${q.name ? ` (${q.name})` : ''} ${q.open_count} open`);
1217
1364
  if (json?.unrouted_open_count)
@@ -1220,7 +1367,7 @@ async function cmdCases(flags) {
1220
1367
  }
1221
1368
  if (!caseId) {
1222
1369
  const limit = flags.limit ?? '50';
1223
- const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, token);
1370
+ const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, t);
1224
1371
  if (status !== 200)
1225
1372
  caseFail('cases', status, json);
1226
1373
  if (asJson) {
@@ -1228,7 +1375,7 @@ async function cmdCases(flags) {
1228
1375
  return;
1229
1376
  }
1230
1377
  const rows = (json?.cases ?? []);
1231
- console.log(`Cases in ${tenant}/${project}: ${json?.total ?? rows.length} total`);
1378
+ console.log(`Cases in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
1232
1379
  if (rows.length === 0)
1233
1380
  console.log(' (none)');
1234
1381
  for (const c of rows) {
@@ -1238,7 +1385,7 @@ async function cmdCases(flags) {
1238
1385
  console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
1239
1386
  return;
1240
1387
  }
1241
- const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, token);
1388
+ const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, t);
1242
1389
  if (status === 404)
1243
1390
  die(`case '${caseId}' not found`);
1244
1391
  if (status !== 200)
@@ -1269,6 +1416,483 @@ async function cmdCases(flags) {
1269
1416
  console.log(` Decisions: ${dispositions.map((d) => `${d.action}${d.next_status ? `→${d.next_status}` : ''}`).join(', ')}`);
1270
1417
  }
1271
1418
  }
1419
+ // ── money formatting (orders / catalog) ─────────────────────────────────────
1420
+ /** Format a MAJOR-unit decimal amount as currency — the catalog's `price` shape.
1421
+ * Mirrors the console's `fmtAmount` (console/src/lib/money.ts) so an amount reads
1422
+ * the same in the terminal as on the page. */
1423
+ function fmtAmount(value, currency) {
1424
+ if (value == null)
1425
+ return '—';
1426
+ if (!currency)
1427
+ return value.toFixed(2);
1428
+ try {
1429
+ return new Intl.NumberFormat('en', { style: 'currency', currency }).format(value);
1430
+ }
1431
+ catch {
1432
+ return `${value.toFixed(2)} ${currency}`;
1433
+ }
1434
+ }
1435
+ /** Format MINOR units (the offset-100 `*_minor` ints every order field is projected
1436
+ * to on the wire — see `toOrderView`) as currency. `fmtAmount(minor / 100, …)`. */
1437
+ function fmtMinor(minor, currency) {
1438
+ if (minor == null)
1439
+ return '—';
1440
+ return fmtAmount(minor / 100, currency);
1441
+ }
1442
+ /** Human wording for a governed `source` — the layer that won the cascade
1443
+ * (per-project override > platform default > pack manifest default). The four
1444
+ * values mirror the platform's `GovernedSource` union exactly. */
1445
+ const GOVERNED_SOURCE_LABEL = {
1446
+ project: 'this project overrides it',
1447
+ platform: 'a PLATFORM default overrides the pack',
1448
+ pack: 'the pack manifest value is in force',
1449
+ unset: 'no value at any layer',
1450
+ };
1451
+ function showValue(v) {
1452
+ if (v === undefined || v === null)
1453
+ return '(unset)';
1454
+ return typeof v === 'string' ? v : JSON.stringify(v);
1455
+ }
1456
+ /** Print one governed setting as `key: <effective> [why]`, and — when the pack's
1457
+ * own declared value is NOT what runs — an explicit second line naming what the
1458
+ * pack asked for. That override is invisible from the pack source, which is the
1459
+ * whole reason this command exists. */
1460
+ function printGoverned(label, g) {
1461
+ if (!g)
1462
+ return;
1463
+ const source = typeof g.source === 'string' ? g.source : '';
1464
+ console.log(` ${label.padEnd(22)} ${showValue(g.effective)} [${GOVERNED_SOURCE_LABEL[source] ?? source ?? '?'}]`);
1465
+ // Warn only when a higher layer displaced the pack's declared value with a
1466
+ // DIFFERENT one. A platform default that happens to equal the pack's value has
1467
+ // changed nothing an author needs to know about.
1468
+ const displaced = (source === 'project' || source === 'platform')
1469
+ && g.pack_default !== undefined
1470
+ && JSON.stringify(g.pack_default) !== JSON.stringify(g.effective);
1471
+ if (displaced)
1472
+ console.log(` ⚠ your pack declares ${showValue(g.pack_default)} — it is NOT in force`);
1473
+ if (g.opted_out)
1474
+ console.log(' (this workspace is opted OUT of the platform default for this key)');
1475
+ }
1476
+ /** `octwin agents [agentRef] [--prompt] [--json]` — the agent roster with the
1477
+ * EFFECTIVE model/memory settings and which layer won, plus (`--prompt`) the exact
1478
+ * system prompt the LLM sees for this project. Needs an `agents:read` token. */
1479
+ async function cmdAgents(flags) {
1480
+ const packDir = resolve(flags.dir ?? '.');
1481
+ const t = resolveTarget(flags, packDir);
1482
+ const { url } = t;
1483
+ const base = `${url}/api/self/p/agents`;
1484
+ const ref = flags._[0];
1485
+ const asJson = flags.json === true;
1486
+ const wantPrompt = flags.prompt === true;
1487
+ if (wantPrompt && !ref)
1488
+ die('usage: octwin agents <packId::agentId> --prompt (name the agent — `octwin agents` lists them)');
1489
+ if (!asJson)
1490
+ console.log(`→ Reading ${ref ? `agent ${ref}` : 'the agent roster'} from ${targetLabel(t)} …`);
1491
+ // --prompt — the resolved system prompt (pack instructions + platform protocol +
1492
+ // the per-project overlay). No LLM call; pure resolution server-side.
1493
+ if (wantPrompt) {
1494
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(ref)}/preview-prompt`, t);
1495
+ if (status === 404)
1496
+ die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
1497
+ // 503 = the pack is installed but not warm on this instance yet (same trap
1498
+ // `octwin status` explains): the agent loads on the next inbound.
1499
+ if (status === 503)
1500
+ die(`'${ref}' is not registered with Mastra on the instance you hit yet — it loads on the next inbound (chat once, then retry)`);
1501
+ if (status !== 200)
1502
+ die(`could not resolve the prompt (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1503
+ if (asJson) {
1504
+ console.log(JSON.stringify(json, null, 2));
1505
+ return;
1506
+ }
1507
+ console.log(`Resolved system prompt for ${json?.agent?.pack_id}::${json?.agent?.pack_agent_id} (${json?.bytes ?? '?'} bytes`
1508
+ + `${json?.has_overlay ? ', includes this project\'s overlay' : ', no project overlay'}):\n`);
1509
+ console.log(json?.resolved_prompt ?? '(empty)');
1510
+ return;
1511
+ }
1512
+ if (!ref) {
1513
+ const { status, json } = await apiGet(base, t);
1514
+ if (status !== 200)
1515
+ die(`could not read agents (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1516
+ if (asJson) {
1517
+ console.log(JSON.stringify(json, null, 2));
1518
+ return;
1519
+ }
1520
+ const agents = (json?.agents ?? []);
1521
+ if (agents.length === 0) {
1522
+ console.log('No agents — is a pack installed on this project? (`octwin status`)');
1523
+ return;
1524
+ }
1525
+ console.log(`Agents in ${targetLabel(t)}:`);
1526
+ for (const a of agents) {
1527
+ console.log(` ${a.pack_id}::${a.pack_agent_id} "${a.display_name}"${a.enabled === false ? ' [DISABLED]' : ''}`);
1528
+ printGoverned('model', a.governed?.model);
1529
+ if (a.last_error_at)
1530
+ console.log(` last error: ${a.last_error_at} — ${a.last_error_reason ?? '(no reason)'}`);
1531
+ }
1532
+ console.log('\nOne agent + its full settings: octwin agents <packId::agentId>');
1533
+ console.log('The prompt the LLM sees: octwin agents <packId::agentId> --prompt');
1534
+ return;
1535
+ }
1536
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
1537
+ if (status === 404)
1538
+ die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
1539
+ if (status !== 200)
1540
+ die(`could not read agent (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1541
+ if (asJson) {
1542
+ console.log(JSON.stringify(json, null, 2));
1543
+ return;
1544
+ }
1545
+ console.log(`${json.pack_id}::${json.pack_agent_id} "${json.display_name}"${json.enabled === false ? ' [DISABLED]' : ''}`);
1546
+ console.log(' Governed settings (project override > platform default > pack manifest):');
1547
+ printGoverned('model', json.governed?.model);
1548
+ printGoverned('memory.last_messages', json.governed?.last_messages);
1549
+ printGoverned('working_memory', json.governed?.working_memory_enabled);
1550
+ console.log(` tools: ${(json.available_tools ?? []).join(', ') || '(none)'}`);
1551
+ console.log(` instructions overlay: ${json.instructions_overlay ? `${String(json.instructions_overlay).length} chars (project-specific)` : '(none)'}`);
1552
+ if (json.last_invoked_at)
1553
+ console.log(` last invoked: ${json.last_invoked_at}`);
1554
+ if (json.last_error_at)
1555
+ console.log(` last error: ${json.last_error_at} — ${json.last_error_reason ?? '(no reason)'}`);
1556
+ console.log(`\nThe prompt the LLM actually sees: octwin agents ${ref} --prompt`);
1557
+ }
1558
+ // ── orders: the commerce lifecycle a conversation produces ───────────────────
1559
+ /** Why a `pending` / `none` payment is usually CORRECT, not a bug. The forward
1560
+ * payment lifecycle is webhook-owned (`payment_status` is deliberately not
1561
+ * patchable — only `refund` is an operator move), and the default driver is the
1562
+ * gateway-less `ManualPaymentAdapter`, on which `payment_request` takes its `empty`
1563
+ * port and mutates nothing. Settling a payment headlessly has no path today — see
1564
+ * docs/BACKLOG.md → "Headless payment settle" (a `--settle` flag lands here once
1565
+ * the platform grows a manual-driver-only reconcile endpoint). */
1566
+ function printPaymentNote(paymentStatus) {
1567
+ if (paymentStatus !== 'pending' && paymentStatus !== 'none')
1568
+ return;
1569
+ console.log(` → payment_status '${paymentStatus}' is expected without a live gateway: the forward payment`);
1570
+ console.log(' lifecycle is WEBHOOK-owned (not patchable), and the default `manual` driver has no hosted');
1571
+ console.log(' checkout — so `payment_request` takes its `empty` port and the flow should offer');
1572
+ console.log(' pay-on-delivery. Refund is the one operator-driven move.');
1573
+ }
1574
+ /** `octwin orders [referenceId] [--status s] [--payment p] [--limit n] [--json]` —
1575
+ * the orders a conversation created: money breakdown, payment state, allowed
1576
+ * transitions. Needs an `orders:read` token + the `orders` plan feature. */
1577
+ async function cmdOrders(flags) {
1578
+ const packDir = resolve(flags.dir ?? '.');
1579
+ const t = resolveTarget(flags, packDir);
1580
+ const { url } = t;
1581
+ const base = `${url}/api/self/p/orders`;
1582
+ const referenceId = flags._[0];
1583
+ const asJson = flags.json === true;
1584
+ if (!asJson)
1585
+ console.log(`→ Reading ${referenceId ? `order ${referenceId}` : 'orders'} from ${targetLabel(t)} …`);
1586
+ if (!referenceId) {
1587
+ const q = new URLSearchParams({ limit: flags.limit ?? '50' });
1588
+ if (typeof flags.status === 'string')
1589
+ q.set('status', flags.status);
1590
+ if (typeof flags.payment === 'string')
1591
+ q.set('payment', flags.payment);
1592
+ const { status, json } = await apiGet(`${base}?${q.toString()}`, t);
1593
+ if (status !== 200)
1594
+ die(`could not read orders (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1595
+ if (asJson) {
1596
+ console.log(JSON.stringify(json, null, 2));
1597
+ return;
1598
+ }
1599
+ const rows = (json?.orders ?? []);
1600
+ console.log(`Orders in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
1601
+ if (rows.length === 0)
1602
+ console.log(' (none — drive a cart to `cart_submit` with `octwin chat`, or seed demo data)');
1603
+ for (const o of rows) {
1604
+ const who = o.contact?.channel_contact_handle ?? o.contact?.display_name ?? '—';
1605
+ console.log(` #${o.record_number} ${o.status}/${o.payment_status} ${fmtMinor(o.total_minor, o.currency)} ${who} ${o.reference_id}`);
1606
+ }
1607
+ console.log('\nOne order + its money breakdown: octwin orders <reference_id>');
1608
+ return;
1609
+ }
1610
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(referenceId)}`, t);
1611
+ if (status === 404)
1612
+ die(`order '${referenceId}' not found (pass the opaque reference_id, not the #number)`);
1613
+ if (status !== 200)
1614
+ die(`could not read order (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1615
+ if (asJson) {
1616
+ console.log(JSON.stringify(json, null, 2));
1617
+ return;
1618
+ }
1619
+ const o = json?.order ?? {};
1620
+ const cur = o.currency;
1621
+ console.log(`Order #${o.record_number} ${o.status} payment: ${o.payment_status}`);
1622
+ console.log(` reference: ${o.reference_id}${o.payment_ref ? ` payment_ref: ${o.payment_ref}` : ''}`);
1623
+ for (const it of (o.items ?? [])) {
1624
+ console.log(` ${it.quantity} × ${it.name ?? it.retailer_id} @ ${fmtMinor(it.amount_minor, it.currency || cur)}`);
1625
+ }
1626
+ console.log(` subtotal ${fmtMinor(o.subtotal_minor, cur)}`
1627
+ + ` tax ${fmtMinor(o.tax_minor, cur)}`
1628
+ + ` shipping ${fmtMinor(o.shipping_minor, cur)}`
1629
+ + ` discount ${fmtMinor(o.discount_minor, cur)}`);
1630
+ console.log(` TOTAL ${fmtMinor(o.total_minor, cur)}`);
1631
+ if (o.note)
1632
+ console.log(` note: ${o.note}`);
1633
+ const transitions = (json?.transitions ?? []);
1634
+ console.log(` allowed transitions: ${transitions.join(', ') || '(none — terminal)'}`);
1635
+ printPaymentNote(String(o.payment_status ?? ''));
1636
+ }
1637
+ // ── analytics: the generic per-entity funnel (any pipelined entity) ──────────
1638
+ const ANALYTICS_MODES = ['funnel', 'overview', 'milestones', 'trends', 'cost'];
1639
+ /** Both "unknown/non-pipelined entity" and "your role lacks the grant" answer
1640
+ * 200 + `has_data:false` (a deliberate empty state, never a 403) — so a bare
1641
+ * "no data" would hide the real cause. Name both. */
1642
+ function printNoAnalyticsData(entity) {
1643
+ console.log(`No analytics for '${entity}'. Either:`);
1644
+ console.log(` • '${entity}' isn't declared with a \`pipeline:\` (a funnel needs stages), or`);
1645
+ console.log(` • your token's role has no \`view\` grant on \`record.${entity}\`.`);
1646
+ console.log('Run `octwin analytics` for the entities that DO carry a pipeline.');
1647
+ }
1648
+ /** `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost]
1649
+ * [--stage <id>] [--json]` — stage conversion over ANY pipelined XRM entity
1650
+ * (orders, carts, cases, bookings, or a pack's own). Needs `records:read`. */
1651
+ async function cmdAnalytics(flags) {
1652
+ const packDir = resolve(flags.dir ?? '.');
1653
+ const t = resolveTarget(flags, packDir);
1654
+ const { url } = t;
1655
+ const base = `${url}/api/self/p/xrm/analytics`;
1656
+ const entity = flags._[0];
1657
+ const asJson = flags.json === true;
1658
+ const stage = typeof flags.stage === 'string' ? flags.stage : undefined;
1659
+ const mode = ANALYTICS_MODES.find(m => flags[m] === true) ?? 'funnel';
1660
+ if (stage && !entity)
1661
+ die('usage: octwin analytics <entity> --stage <stageId> (a stage belongs to an entity)');
1662
+ if (!entity) {
1663
+ if (!asJson)
1664
+ console.log(`→ Reading the analytics entity list from ${targetLabel(t)} …`);
1665
+ const { status, json } = await apiGet(`${base}/entities`, t);
1666
+ if (status !== 200)
1667
+ die(`could not read analytics entities (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1668
+ if (asJson) {
1669
+ console.log(JSON.stringify(json, null, 2));
1670
+ return;
1671
+ }
1672
+ const ents = (json?.entities ?? []);
1673
+ if (ents.length === 0) {
1674
+ console.log('No pipelined entities visible — a funnel needs an entity declared with a `pipeline:`,');
1675
+ console.log('and your role needs a `view` grant on it. (Journeys have their own analytics surface.)');
1676
+ return;
1677
+ }
1678
+ console.log(`Entities with a funnel in ${targetLabel(t)}:`);
1679
+ for (const e of ents) {
1680
+ console.log(` ${e.entity} (${e.stage_count} stage(s), ${e.milestone_count} milestone(s))`);
1681
+ }
1682
+ console.log('\nStage conversion: octwin analytics <entity> (add --overview / --milestones / --trends / --cost)');
1683
+ console.log('Who is at a stage: octwin analytics <entity> --stage <stageId>');
1684
+ return;
1685
+ }
1686
+ // Stage drill-down — records CURRENTLY at a stage. A live snapshot, deliberately
1687
+ // NOT range-filtered like the funnel's cumulative reached-≥ counts.
1688
+ if (stage) {
1689
+ if (!asJson)
1690
+ console.log(`→ Reading ${entity} records at stage '${stage}' from ${targetLabel(t)} …`);
1691
+ const limit = flags.limit ?? '50';
1692
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/stages/${encodeURIComponent(stage)}/records?limit=${limit}`, t);
1693
+ if (status === 404)
1694
+ die(`unknown stage '${stage}' for '${entity}'${errDetail(json)}`);
1695
+ if (status !== 200)
1696
+ die(`could not read stage records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1697
+ if (asJson) {
1698
+ console.log(JSON.stringify(json, null, 2));
1699
+ return;
1700
+ }
1701
+ if (json?.has_data === false) {
1702
+ printNoAnalyticsData(entity);
1703
+ return;
1704
+ }
1705
+ const rows = (json?.records ?? []);
1706
+ console.log(`${entity} at '${stage}' (live snapshot): ${json?.total ?? rows.length} record(s)`);
1707
+ for (const r of rows) {
1708
+ const who = r.channel_contact_handle ?? r.display_name ?? '—';
1709
+ console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'} ${who}${r.completed ? ' [completed]' : ''} ${r.record_id}`);
1710
+ }
1711
+ return;
1712
+ }
1713
+ if (!asJson)
1714
+ console.log(`→ Reading ${entity} ${mode} from ${targetLabel(t)} …`);
1715
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/${mode}`, t);
1716
+ if (status !== 200)
1717
+ die(`could not read ${mode} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1718
+ if (asJson) {
1719
+ console.log(JSON.stringify(json, null, 2));
1720
+ return;
1721
+ }
1722
+ if (json?.has_data === false) {
1723
+ printNoAnalyticsData(entity);
1724
+ return;
1725
+ }
1726
+ const range = json?.range ? ` (${json.range.from} → ${json.range.to})` : '';
1727
+ console.log(`${entity} — ${mode}${range}:`);
1728
+ switch (mode) {
1729
+ case 'funnel':
1730
+ for (const s of (json?.funnel ?? [])) {
1731
+ const conv = s.conversion_from_prev_pct == null ? '' : ` ${s.conversion_from_prev_pct}% of prev`;
1732
+ const lost = s.drop_off_from_prev ? ` (−${s.drop_off_from_prev})` : '';
1733
+ console.log(` ${String(s.rank).padStart(2)}. ${String(s.stage).padEnd(24)} ${String(s.reached).padStart(6)}${conv}${lost}`);
1734
+ }
1735
+ break;
1736
+ case 'overview': {
1737
+ const s = json?.summary ?? {};
1738
+ console.log(` entered ${s.entered} → converted ${s.converted}${s.conversion_pct == null ? '' : ` (${s.conversion_pct}%)`}`);
1739
+ if (s.biggest_dropoff)
1740
+ console.log(` biggest drop-off: ${s.biggest_dropoff.from} → ${s.biggest_dropoff.to} (lost ${s.biggest_dropoff.lost})`);
1741
+ if (s.top_milestone)
1742
+ console.log(` top milestone: ${s.top_milestone.milestone} (${s.top_milestone.completions})`);
1743
+ break;
1744
+ }
1745
+ case 'milestones':
1746
+ for (const m of (json?.milestones ?? [])) {
1747
+ console.log(` ${String(m.milestone).padEnd(28)} ${String(m.completions).padStart(6)} completion(s), ${m.unique_contacts} contact(s)`);
1748
+ }
1749
+ break;
1750
+ case 'trends':
1751
+ for (const b of (json?.buckets ?? [])) {
1752
+ console.log(` ${b.bucket} active ${b.active_contacts} milestones ${b.milestone_completions}`);
1753
+ }
1754
+ for (const c of (json?.cohorts ?? [])) {
1755
+ console.log(` cohort ${c.bucket} entered ${c.entered} converted ${c.converted}`);
1756
+ }
1757
+ break;
1758
+ case 'cost':
1759
+ if (json?.cost_partial)
1760
+ console.log(' ⚠ partial — some token-usage rows carry no cost');
1761
+ for (const r of (json?.by_milestone ?? [])) {
1762
+ console.log(` ${String(r.id).padEnd(28)} ${r.conversations} conv, ${r.total_tokens} tokens, $${r.cost_usd}`);
1763
+ }
1764
+ for (const d of (json?.drivers ?? [])) {
1765
+ console.log(` driver flow=${d.source_flow_id ?? '—'} agent=${d.source_agent_id ?? '—'} conversions ${d.conversions}`);
1766
+ }
1767
+ break;
1768
+ }
1769
+ }
1770
+ // ── catalog: the commerce products + their WhatsApp binding ──────────────────
1771
+ /** `octwin catalog [--readiness] [--json]` — the `product` records a commerce pack
1772
+ * sells, their stock, and the WhatsApp catalog binding. Needs `catalog:read` + the
1773
+ * `catalog` plan feature. */
1774
+ async function cmdCatalog(flags) {
1775
+ const packDir = resolve(flags.dir ?? '.');
1776
+ const t = resolveTarget(flags, packDir);
1777
+ const { url } = t;
1778
+ const base = `${url}/api/self/p/catalog`;
1779
+ const asJson = flags.json === true;
1780
+ // --readiness makes LIVE Meta Graph calls (and 409s with no bound access token),
1781
+ // so it's opt-in rather than part of the default read.
1782
+ if (flags.readiness === true) {
1783
+ if (!asJson)
1784
+ console.log(`→ Checking WhatsApp commerce readiness for ${targetLabel(t)} (live Meta Graph calls) …`);
1785
+ const { status, json } = await apiGet(`${base}/readiness`, t);
1786
+ if (status === 409)
1787
+ die(`no Meta access token on this project's WhatsApp channel — readiness needs one${errDetail(json)}`);
1788
+ if (status !== 200)
1789
+ die(`could not read readiness (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1790
+ if (asJson) {
1791
+ console.log(JSON.stringify(json, null, 2));
1792
+ return;
1793
+ }
1794
+ const r = json?.readiness ?? {};
1795
+ console.log(`WhatsApp commerce readiness: ${r.summary ?? '?'}`);
1796
+ for (const c of (r.checks ?? [])) {
1797
+ const mark = c.status === 'ok' ? '✓' : c.status === 'warn' ? '⚠' : c.status === 'skip' ? '·' : '✗';
1798
+ console.log(` ${mark} ${c.label}${c.detail ? ` — ${c.detail}` : ''}`);
1799
+ if (c.solution)
1800
+ console.log(` → ${c.solution}`);
1801
+ }
1802
+ return;
1803
+ }
1804
+ if (!asJson)
1805
+ console.log(`→ Reading the product catalog from ${targetLabel(t)} …`);
1806
+ const { status, json } = await apiGet(base, t);
1807
+ if (status !== 200)
1808
+ die(`could not read the catalog (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1809
+ if (asJson) {
1810
+ console.log(JSON.stringify(json, null, 2));
1811
+ return;
1812
+ }
1813
+ const products = (json?.products ?? []);
1814
+ console.log(`Products in ${targetLabel(t)}: ${products.length}`);
1815
+ if (products.length === 0)
1816
+ console.log(' (none — a commerce pack seeds `product` records, or add them in the console Catalog)');
1817
+ for (const p of products) {
1818
+ // `available: null` = the SKU isn't inventory-tracked (always sellable).
1819
+ const stock = p.available == null ? 'untracked' : `${p.available}`;
1820
+ console.log(` ${String(p.retailer_id).padEnd(20)} ${String(p.name ?? '').padEnd(28)} ${fmtAmount(p.price, p.currency)}`
1821
+ + ` avail=${p.availability} stock=${stock} sync=${p.sync_status ?? '—'}`);
1822
+ }
1823
+ // A binding row can exist with no catalog_id yet (a WABA is configured but no Meta
1824
+ // catalog picked) — that is "not bound" for selling purposes, so say so.
1825
+ const b = json?.binding;
1826
+ console.log(b?.catalog_id
1827
+ ? `\nWhatsApp catalog binding: catalog ${b.catalog_id} (waba ${b.waba_id ?? '—'}), sync=${b.sync_status ?? '—'}, last=${b.last_sync_at ?? 'never'}`
1828
+ : `\nWhatsApp catalog binding: no Meta catalog bound${b?.waba_id ? ` (waba ${b.waba_id} is configured — pick a catalog)` : ''}`
1829
+ + ' — the catalog works web-only (`--readiness` explains what Meta needs).');
1830
+ }
1831
+ // ── scheduling: the availability engine + a slot preview ─────────────────────
1832
+ /** `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]`
1833
+ * — the scheduling engine's state, or the computed slots for one bookable resource
1834
+ * (the verification the `--seed` availability fan-out was missing). `scheduling:read`. */
1835
+ async function cmdScheduling(flags) {
1836
+ const packDir = resolve(flags.dir ?? '.');
1837
+ const t = resolveTarget(flags, packDir);
1838
+ const { url } = t;
1839
+ const base = `${url}/api/self/p/scheduling`;
1840
+ const asJson = flags.json === true;
1841
+ const resourceId = typeof flags.slots === 'string' ? flags.slots : undefined;
1842
+ if (flags.slots === true)
1843
+ die('usage: octwin scheduling --slots <resourceRecordId> (the record id of a bookable resource)');
1844
+ if (resourceId) {
1845
+ const q = new URLSearchParams({ include_booked: '1' }); // full occupancy, as the operator preview does
1846
+ if (typeof flags.from === 'string')
1847
+ q.set('from', flags.from);
1848
+ if (typeof flags.days === 'string')
1849
+ q.set('days', flags.days); // server-clamped to 1–31
1850
+ if (!asJson)
1851
+ console.log(`→ Computing slots for resource ${resourceId} in ${targetLabel(t)} …`);
1852
+ const { status, json } = await apiGet(`${base}/resources/${encodeURIComponent(resourceId)}/slots?${q.toString()}`, t);
1853
+ if (status === 404)
1854
+ die(`resource '${resourceId}' not found (pass an XRM record id — \`octwin records <entity>\` lists them)`);
1855
+ if (status === 400)
1856
+ die(`that record isn't a bookable resource${errDetail(json)}`);
1857
+ if (status !== 200)
1858
+ die(`could not compute slots (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1859
+ if (asJson) {
1860
+ console.log(JSON.stringify(json, null, 2));
1861
+ return;
1862
+ }
1863
+ if (json?.has_scheduling === false) {
1864
+ console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
1865
+ return;
1866
+ }
1867
+ const slots = (json?.slots ?? []);
1868
+ console.log(`Slots for ${resourceId} (timezone ${json?.timezone ?? '?'}): ${slots.length}`);
1869
+ if (slots.length === 0)
1870
+ console.log(' (none — no availability rules cover this window; `octwin deploy --seed` seeds the demo rules)');
1871
+ for (const s of slots) {
1872
+ console.log(` ${s.slot_start} → ${s.slot_end} ${s.remaining}/${s.capacity} free`);
1873
+ }
1874
+ return;
1875
+ }
1876
+ if (!asJson)
1877
+ console.log(`→ Reading the scheduling engine state from ${targetLabel(t)} …`);
1878
+ const { status, json } = await apiGet(base, t);
1879
+ if (status !== 200)
1880
+ die(`could not read scheduling (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1881
+ if (asJson) {
1882
+ console.log(JSON.stringify(json, null, 2));
1883
+ return;
1884
+ }
1885
+ if (json?.has_scheduling === false) {
1886
+ console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
1887
+ return;
1888
+ }
1889
+ console.log(`Scheduling in ${targetLabel(t)} — bookings land as '${json?.booking_entity}'`);
1890
+ for (const rt of (json?.resource_types ?? [])) {
1891
+ console.log(` ${rt.entity} ${rt.resources} resource(s), ${rt.window_days}-day booking window`);
1892
+ }
1893
+ console.log(` upcoming slots: ${json?.upcoming_slots ?? 0} booked seats: ${json?.booked_seats ?? 0}`);
1894
+ console.log('\nSlots for one resource: octwin scheduling --slots <resourceRecordId> (ids: octwin records <entity>)');
1895
+ }
1272
1896
  function help() {
1273
1897
  console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
1274
1898
 
@@ -1284,6 +1908,11 @@ function help() {
1284
1908
  octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
1285
1909
  octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
1286
1910
  octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
1911
+ octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
1912
+ octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
1913
+ octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
1914
+ octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
1915
+ octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
1287
1916
  octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1288
1917
  octwin test [--dir .] # = validate --remote (the full platform check)
1289
1918
 
@@ -1304,7 +1933,8 @@ const COMMAND_HELP = {
1304
1933
  Offline structural check; --remote additionally runs the platform's FULL
1305
1934
  manifest + flow-DSL validation (all errors at once) — same check as deploy.`,
1306
1935
  login: `octwin login --url <platformUrl> --token oct_…
1307
- Save a deploy token (console → Settings → API tokens) for that platform url.`,
1936
+ Save a deploy token (console → Settings → API tokens) for that platform url,
1937
+ and echo the workspace + project pin + scopes the token reaches.`,
1308
1938
  whoami: `octwin whoami [--url <url>] [--tenant <slug>]
1309
1939
  Verify the resolved token authenticates against the tenant.`,
1310
1940
  deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
@@ -1335,6 +1965,35 @@ const COMMAND_HELP = {
1335
1965
  public asset, and print its MEDIA- handle + serve URL. --out downloads the
1336
1966
  bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
1337
1967
  width, height, bytes }. Pair with 'octwin chat --media' to drive media flows.`,
1968
+ agents: `octwin agents [packId::agentId] [--prompt] [--json]
1969
+ No args = the roster with each agent's EFFECTIVE model and which layer set it.
1970
+ With an agent = every governed setting (model / memory.last_messages /
1971
+ working_memory) plus the layer that won — an operator PLATFORM default can
1972
+ override what your manifest declares, and this is where you see that.
1973
+ --prompt = the exact system prompt the LLM sees for this project (pack
1974
+ instructions + platform protocol + any project overlay). Needs agents:read.
1975
+ The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.`,
1976
+ orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
1977
+ No args = the order list (#number, status/payment, total, contact). With a
1978
+ reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
1979
+ payment_ref, and the allowed status transitions. Needs orders:read + the
1980
+ \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
1981
+ so \`pending\` on a gateway-less workspace is expected, not a bug.`,
1982
+ analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
1983
+ No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
1984
+ With an entity = stage-by-stage conversion (default --funnel) over the last 30
1985
+ days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
1986
+ range-filtered). Needs records:read + a \`view\` grant on \`record.<entity>\`.`,
1987
+ catalog: `octwin catalog [--readiness] [--json]
1988
+ The commerce \`product\` records + price, availability, stock (null = not
1989
+ inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
1990
+ Graph checklist (LIVE Graph calls; needs a bound access token). Needs
1991
+ catalog:read + the \`catalog\` plan feature.`,
1992
+ scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
1993
+ No args = the engine state (bookable resource types, upcoming slots, booked
1994
+ seats). --slots <recordId> computes the slots for one bookable resource
1995
+ (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
1996
+ the availability rules a \`deploy --seed\` created. Needs scheduling:read.`,
1338
1997
  'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1339
1998
  Pull the platform capability reference (markdown + JSON catalogs) into
1340
1999
  .octwin/platform-kb/ for the octwin-pack authoring skill.`,
@@ -1344,6 +2003,7 @@ const COMMAND_HELP = {
1344
2003
  async function main() {
1345
2004
  const [command, ...rest] = process.argv.slice(2);
1346
2005
  const flags = parseFlags(rest);
2006
+ CURRENT_COMMAND = command; // so an auth failure can name the scope THIS command needs
1347
2007
  // Per-subcommand --help/-h — intercepted BEFORE the command runs, so help can
1348
2008
  // never hit the network or die on auth (author-feedback A8).
1349
2009
  if (command && command in COMMAND_HELP && (flags.help === true || flags._.includes('-h'))) {
@@ -1358,7 +2018,7 @@ async function main() {
1358
2018
  await cmdValidate(flags);
1359
2019
  break;
1360
2020
  case 'login':
1361
- cmdLogin(flags);
2021
+ await cmdLogin(flags);
1362
2022
  break;
1363
2023
  case 'whoami':
1364
2024
  await cmdWhoami(flags);
@@ -1384,6 +2044,21 @@ async function main() {
1384
2044
  case 'media':
1385
2045
  await cmdMedia(flags);
1386
2046
  break;
2047
+ case 'agents':
2048
+ await cmdAgents(flags);
2049
+ break;
2050
+ case 'orders':
2051
+ await cmdOrders(flags);
2052
+ break;
2053
+ case 'analytics':
2054
+ await cmdAnalytics(flags);
2055
+ break;
2056
+ case 'catalog':
2057
+ await cmdCatalog(flags);
2058
+ break;
2059
+ case 'scheduling':
2060
+ await cmdScheduling(flags);
2061
+ break;
1387
2062
  case 'platform-kb':
1388
2063
  await cmdPlatformKb(flags);
1389
2064
  break;