octwin-cli 0.1.8 → 0.1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,83 @@
1
+ # Changelog — octwin-cli
2
+
3
+ All notable changes to the `octwin` CLI (npm package [`octwin-cli`](https://www.npmjs.com/package/octwin-cli)).
4
+ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucketed into
5
+ **Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
6
+ repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
7
+
8
+ ## [0.1.10] - 2026-07-21
9
+
10
+ The capability reference is now **pulled, never bundled** — the authoring skill dropped its committed KB
11
+ snapshot, so `octwin platform-kb pull` is the single source of truth, and the CLI watches it for drift.
12
+
13
+ ### Added
14
+ - **KB-drift nudge** — after any command that already talks to the platform (`deploy` / `status` /
15
+ `test` / `validate --remote` / `records` / `cases` / `logs` / `whoami`), the CLI does one tiny
16
+ `octwin-platform-kb?meta=1` GET and prints a one-line "the platform capability reference changed —
17
+ `octwin platform-kb pull`" when the platform's `content_hash` no longer matches your last pull.
18
+ Fail-silent + TTY-only + no-op until you've pulled once — the sibling of the existing CLI-upgrade notice.
19
+
20
+ ### Changed
21
+ - **`octwin platform-kb pull`** now records the reference's `content_hash` in `.octwin/platform-kb/index.json`
22
+ (the fingerprint the drift check compares against).
23
+
24
+ The conversation-debugging release — driven by the second author-feedback round (xpeng-egypt),
25
+ which found the author→validate→deploy loop solid but multi-turn debugging impossible from the CLI.
26
+
27
+ ### Added
28
+ - **`octwin chat --tap "<tap-id>"`** — press a rendered button / list row (sends the web channel's
29
+ `type:'interactive'` inbound). Paired with full render printing, any tap-driven flow is now drivable headlessly.
30
+ - **Full render printing** — every reply prints its complete interactive content: list sections/rows,
31
+ buttons, and carousel cards, each with its **tap id**; header/footer/list-button text included.
32
+ - **`--json` on `chat` / `logs` / `cases`** — raw SSE envelopes (chat) or raw event/case payloads.
33
+ - **`octwin cases [caseId] [--queues]`** — inspect casework (support tickets): the inbox, one case +
34
+ timeline + applicable decisions, or queue keys with open counts. Casework packs previously had zero
35
+ data-inspection path (`records case` was a dead end).
36
+ - **Per-subcommand `--help`/`-h`** — intercepted before any network or auth work (previously
37
+ `octwin logs --help` made a network call and could die with a 401).
38
+ - Multi-message turns: `chat` now collects **all** of a turn's renders (quiet-gap detection) instead
39
+ of cutting at the first one.
40
+
41
+ ### Changed
42
+ - **`octwin logs`**: the conversation list shows contact handle + last activity and filters with
43
+ `--as <handle>`; the timeline prints **full** event text (no more 240-char truncation) plus the
44
+ renders each turn actually sent; footer points at `--json` / `mastra-messages` for verbatim payloads.
45
+ - **Network failures name the target URL** (`platform unreachable at <url>`), and 401s explain
46
+ invalid-token vs one-off transient instead of a bare status code.
47
+ - `octwin records` on an unknown entity now surfaces the server's error body, and for `case`/`cases`
48
+ points at `octwin cases`.
49
+
50
+ ### Fixed
51
+ - **Multi-turn chat actually works.** Two bugs made consecutive `octwin chat` calls look like "every
52
+ call starts fresh": (1) the idempotency `local_id` was a **constant** per handle, so the platform's
53
+ 5-minute inbound dedup silently dropped the second message — a fresh id is now minted per call;
54
+ (2) a fresh SSE connect **replays** recent history and the CLI printed the first render frame — i.e.
55
+ the *previous* turn's greeting — as the reply; the CLI now drains the replay and only accepts frames
56
+ newer than the connect boundary. The platform always kept one open conversation per `--as` handle —
57
+ the same handle genuinely continues the same conversation now.
58
+
59
+ ## [0.1.8] - 2026-07-21
60
+ - Global-first install guidance (`npm i -g octwin-cli`); upgrade notice suppressed under `npx`.
61
+
62
+ ## [0.1.7] - 2026-07-21
63
+ - `--version`/`-v` flag; daily fail-silent, TTY-only "newer version available" notice.
64
+
65
+ ## [0.1.6] - 2026-07-21
66
+ - `octwin init` scaffolds a home-hub menu (`home` list_picker + example `browse` tool) instead of a bare greeting.
67
+
68
+ ## [0.1.5] - 2026-07-20
69
+ - `octwin validate --remote` (the platform's FULL manifest + flow-DSL check, all errors at once);
70
+ headless debugging commands `octwin records` / `octwin logs` / `octwin chat`; `octwin test` became
71
+ an alias for `validate --remote`. First author-feedback round.
72
+
73
+ ## [0.1.4] - 2026-07-20
74
+ - Authoring reliability + DX fixes from the first end-to-end external-pack test.
75
+
76
+ ## [0.1.3] - 2026-07-20
77
+ - `octwin platform-kb pull` — pull the platform capability reference for the authoring skill.
78
+
79
+ ## [0.1.2] - 2026-07-20
80
+ - Proper npm README.
81
+
82
+ ## [0.1.1] - 2026-07-20
83
+ - Initial npm publish: `init` / `validate` / `login` / `whoami` / `deploy` / `status`, `oct_` deploy tokens.
package/README.md CHANGED
@@ -66,13 +66,30 @@ octwin status # "✓ live and current" once it's warm
66
66
  | `octwin whoami` | Verify the saved/passed token is valid for a tenant. `--url`, `--tenant`. |
67
67
  | `octwin deploy` | Upload + install the pack onto your tenant's project. `--seed` also runs the pack's demo seed. |
68
68
  | `octwin status` | Report what the platform has live for this pack — installed vs. loaded version, and its flows. |
69
+ | `octwin chat "msg"` | Drive a turn through the dev web channel and print **every render with its tap ids**. `--as <handle>` picks the test user; `--tap "<tap-id>"` presses a rendered button/list row; `--json` dumps the raw envelopes. |
70
+ | `octwin logs` | List recent conversations (handle, status, last activity; `--as` filters), or show one conversation's full event timeline — including what each turn rendered. `--json` for raw payloads. |
71
+ | `octwin records` | Inspect the pack's XRM data (needs a `records:read` token). No args = list entities. |
72
+ | `octwin cases` | Inspect casework (support tickets): the inbox, one case + its timeline and decisions, or `--queues` for queue keys + open counts. |
69
73
  | `octwin platform-kb pull` | Pull the platform's capability reference (built-ins, primitives, render intents, flow-DSL — as markdown + JSON) into `.octwin/platform-kb/`, for the **`octwin-pack`** Claude Code authoring plugin to consult. |
70
- | `octwin test` | Validate locally and print how to try the pack on your tenant. |
71
- | `octwin help` | Show usage. |
74
+ | `octwin test` | Alias for `octwin validate --remote` the platform's full manifest + flow-DSL check. |
75
+ | `octwin help` | Show usage. Every subcommand also answers `--help`. |
72
76
 
73
77
  Every command that talks to the platform accepts `--dir <path>` (the pack directory; defaults to
74
78
  the current directory) plus the target overrides `--url` / `--tenant` / `--project` / `--token`.
75
79
 
80
+ ### Debugging a live conversation
81
+
82
+ The platform keeps **one open conversation per `--as` handle**, so consecutive `octwin chat` calls
83
+ with the same handle **continue the same conversation** — agent memory, suspended flows, and all:
84
+
85
+ ```bash
86
+ octwin chat "hi" --as tester1 # turn 1 — prints the menu with each row's tap id
87
+ octwin chat --tap "t:invoke:my-flow:x=1" --as tester1 # turn 2 — press a rendered row
88
+ octwin chat "3 bedrooms" --as tester1 # turn 3 — free text into the running flow
89
+ octwin logs --as tester1 # find the conversation, then:
90
+ octwin logs <conversationId> # the full timeline (taps, renders, tool events)
91
+ ```
92
+
76
93
  ## Configuration
77
94
 
78
95
  The deploy target has four settings. Three live in a committed **`pack.json`** at the root of your
@@ -126,7 +143,8 @@ needs **no database of its own**.
126
143
  ## Links
127
144
 
128
145
  - **npm:** <https://www.npmjs.com/package/octwin-cli>
129
- - **Command help:** `octwin help`
146
+ - **Command help:** `octwin help` (each subcommand also answers `--help`)
147
+ - **Changelog:** [CHANGELOG.md](./CHANGELOG.md)
130
148
 
131
149
  ## License
132
150
 
package/dist/index.js CHANGED
@@ -15,11 +15,18 @@
15
15
  * octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
16
16
  * octwin status [--dir .] # did my deploy land? which version is live?
17
17
  * octwin records [entity] [id] # inspect the pack's XRM data (records:read token)
18
- * octwin logs [conversationId] # list conversations / show one's timeline
19
- * octwin chat "msg" [--as h] # drive one turn via the web channel + print the reply
18
+ * octwin cases [caseId] [--queues] # inspect casework (support tickets) — list / one case + timeline
19
+ * octwin logs [conversationId] [--as h] [--json] # list conversations / show one's timeline
20
+ * octwin chat "msg" [--as h] [--tap <tap-id>] [--json] # drive a turn via the web channel
20
21
  * octwin platform-kb [pull] [--dir .] # pull the platform capability reference for the authoring skill
21
22
  * octwin test [--dir .] # = validate --remote (the full platform check)
22
23
  *
24
+ * Multi-turn testing: the platform keeps ONE open conversation per `--as` handle,
25
+ * so consecutive `octwin chat --as <h>` calls CONTINUE the same conversation
26
+ * (same agent memory, resumable flows). Renders print every row/button with its
27
+ * tap id; replay a tap with `--tap "<tap-id>"`. `--help` on any subcommand
28
+ * prints its usage without touching the network.
29
+ *
23
30
  * Config resolution (deploy): flags > pack.json (in the pack dir) > env
24
31
  * (PACK_PLATFORM_URL / PACK_TENANT / PACK_PROJECT / PACK_TOKEN) > saved login.
25
32
  *
@@ -72,6 +79,32 @@ function die(msg) {
72
79
  console.error(`✗ ${msg}`);
73
80
  process.exit(1);
74
81
  }
82
+ // ── network helpers ─────────────────────────────────────────────────────────
83
+ /** `fetch` that dies with the TARGET URL on a network failure — a bare
84
+ * `✗ fetch failed` with no address helps nobody (author-feedback A9). */
85
+ async function fetchOrDie(url, init, what) {
86
+ try {
87
+ return await fetch(url, init);
88
+ }
89
+ catch (err) {
90
+ die(`${what} — platform unreachable at ${url} (${err?.message ?? err})`);
91
+ }
92
+ }
93
+ /** One consistent explanation for auth failures on admin reads. A 401 can also
94
+ * be a one-off transient (the platform treats an auth-backend hiccup as
95
+ * unauthenticated), so say so instead of sending the author on a re-login hunt. */
96
+ function authFailureHint(status, url) {
97
+ return status === 401
98
+ ? `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_…)`
99
+ : `the token is valid but not authorized here (missing scope, plan feature, or role)`;
100
+ }
101
+ /** Pretty-print a JSON error body (or raw text) for an HTTP failure line. */
102
+ function errDetail(json) {
103
+ if (json == null)
104
+ return '';
105
+ const msg = typeof json === 'string' ? json : (json.error ?? json.message ?? JSON.stringify(json));
106
+ return msg ? ` — ${msg}` : '';
107
+ }
75
108
  // ── bundle collection ───────────────────────────────────────────────────────
76
109
  const SKIP_DIRS = new Set(['.git', 'node_modules', '.pack-bundles', 'dist', '.mastra']);
77
110
  /** Collect every text file under `packDir` into a `{ relPath: content }` map. */
@@ -203,6 +236,66 @@ async function notifyIfOutdated() {
203
236
  }
204
237
  catch { /* a version check must never break the CLI */ }
205
238
  }
239
+ // ── platform-KB drift check (observe the pulled reference, TTY-only, fail-silent) ──
240
+ /** The `content_hash` of a previously-pulled KB in `<packDir>/.octwin/platform-kb/`,
241
+ * or null if nothing has been pulled yet. */
242
+ function readLocalKbHash(packDir) {
243
+ try {
244
+ const idx = JSON.parse(readFileSync(join(packDir, '.octwin', 'platform-kb', 'index.json'), 'utf8'));
245
+ return typeof idx.content_hash === 'string' ? idx.content_hash : null;
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ }
251
+ /** Nudge (to stderr) when the platform's capability KB has changed since the last
252
+ * `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
253
+ * of the CLI: run only after commands that already hit the platform, so this adds
254
+ * a single tiny `?meta=1` GET on top of work that was networked anyway. Never
255
+ * throws — observing must never break a command. No-op until the author has pulled
256
+ * once (nothing to compare) or if the target/token can't be resolved. */
257
+ async function notifyIfKbStale(flags) {
258
+ if (!process.stdout.isTTY)
259
+ return;
260
+ try {
261
+ const packDir = resolve(flags.dir ?? '.');
262
+ const localHash = readLocalKbHash(packDir);
263
+ if (!localHash)
264
+ return; // never pulled → the skill already says to pull
265
+ const t = resolveTargetOrNull(flags, packDir);
266
+ if (!t)
267
+ return;
268
+ const ctrl = new AbortController();
269
+ const timer = setTimeout(() => ctrl.abort(), 2_000);
270
+ const res = await fetch(`${t.url}/api/admin/tenants/${t.tenant}/octwin-platform-kb?meta=1`, {
271
+ headers: { authorization: `Bearer ${t.token}` }, signal: ctrl.signal,
272
+ });
273
+ clearTimeout(timer);
274
+ if (!res.ok)
275
+ return;
276
+ const meta = await res.json();
277
+ if (meta.content_hash && meta.content_hash !== localHash) {
278
+ console.error('\n⬆ the platform capability reference changed since you last pulled it.');
279
+ console.error(' Refresh it: octwin platform-kb pull');
280
+ }
281
+ }
282
+ catch { /* a KB check must never break the CLI */ }
283
+ }
284
+ /** Which commands already made an authed platform call, so the trailing KB-drift
285
+ * poll rides on existing network work (never on offline `validate` / `init`). */
286
+ function commandTouchesPlatform(command, flags) {
287
+ switch (command) {
288
+ case 'validate': return flags.remote === true; // offline validate stays offline
289
+ case 'deploy':
290
+ case 'status':
291
+ case 'test':
292
+ case 'records':
293
+ case 'cases':
294
+ case 'logs':
295
+ case 'whoami': return true;
296
+ default: return false;
297
+ }
298
+ }
206
299
  // ── commands ────────────────────────────────────────────────────────────────
207
300
  function cmdInit(flags) {
208
301
  const target = flags._[0] ?? die('usage: octwin init <dir> [--id my-pack]');
@@ -268,11 +361,11 @@ async function cmdValidate(flags) {
268
361
  // every flow (schema/expression/structure) — returning ALL errors at once.
269
362
  const { url, tenant, project, token } = resolveTarget(flags, packDir);
270
363
  console.log(`→ Validating against ${tenant}/${project} @ ${url} …`);
271
- const res = await fetch(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/validate`, {
364
+ const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/validate`, {
272
365
  method: 'POST',
273
366
  headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
274
367
  body: JSON.stringify({ files }),
275
- });
368
+ }, 'remote validate');
276
369
  const text = await res.text();
277
370
  let json;
278
371
  try {
@@ -331,10 +424,23 @@ function resolveTarget(flags, packDir) {
331
424
  die('no token — generate a deploy token in the console (Settings → API tokens), then `octwin login --url <url> --token oct_…` or pass --token');
332
425
  return { url, tenant, project, token };
333
426
  }
427
+ /** Non-fatal `resolveTarget`: returns null (never dies) when any of url/tenant/token
428
+ * is missing. Used by the fail-silent KB-staleness observer, which must never
429
+ * interrupt a command over a config gap. */
430
+ function resolveTargetOrNull(flags, packDir) {
431
+ const cfg = readPackConfig(packDir);
432
+ const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
433
+ const tenant = flags.tenant ?? process.env.PACK_TENANT ?? cfg.tenant ?? '';
434
+ const project = flags.project ?? process.env.PACK_PROJECT ?? cfg.project ?? 'main';
435
+ const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
436
+ if (!url || !tenant || !token)
437
+ return null;
438
+ return { url, tenant, project, token };
439
+ }
334
440
  async function cmdWhoami(flags) {
335
441
  const packDir = resolve(flags.dir ?? '.');
336
442
  const { url, tenant, token } = resolveTarget(flags, packDir);
337
- const res = await fetch(`${url}/api/admin/tenants/${tenant}/packs`, { headers: { authorization: `Bearer ${token}` } });
443
+ const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/packs`, { headers: { authorization: `Bearer ${token}` } }, 'token check');
338
444
  if (res.ok) {
339
445
  console.log(`✓ Token valid for tenant '${tenant}' at ${url} (${token.startsWith('oct_') ? 'deploy token' : 'session token'})`);
340
446
  return;
@@ -411,13 +517,13 @@ async function cmdDeploy(flags) {
411
517
  const endpoint = `${url}/api/admin/tenants/${tenant}/projects/${project}/packs/deploy`;
412
518
  const seed = flags.seed === true;
413
519
  console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${tenant}/${project}${seed ? ' — with demo seed' : ''} …`);
414
- const res = await fetch(endpoint, {
520
+ const res = await fetchOrDie(endpoint, {
415
521
  method: 'POST',
416
522
  // Ask for a progress stream; the platform falls back to plain JSON if it
417
523
  // (or an error before any progress) can't stream — handled below.
418
524
  headers: { 'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${token}` },
419
525
  body: JSON.stringify({ files, seed }),
420
- });
526
+ }, 'deploy');
421
527
  // Streaming path — live install + seed progress (image generation can take a
422
528
  // while, so `--seed` prints per-record / per-image lines as they happen).
423
529
  if (res.ok && (res.headers.get('content-type') ?? '').includes('text/event-stream') && res.body) {
@@ -455,9 +561,9 @@ async function cmdStatus(flags) {
455
561
  die('manifest.yaml must declare a string `id`');
456
562
  const id = doc.id;
457
563
  const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
458
- const res = await fetch(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/${id}/runtime`, {
564
+ const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/${id}/runtime`, {
459
565
  headers: { authorization: `Bearer ${token}` },
460
- });
566
+ }, 'status check');
461
567
  const text = await res.text();
462
568
  let json;
463
569
  try {
@@ -493,9 +599,9 @@ async function cmdStatus(flags) {
493
599
  async function cmdPlatformKb(flags) {
494
600
  const packDir = resolve(flags.dir ?? '.');
495
601
  const { url, tenant, token } = resolveTarget(flags, packDir);
496
- const res = await fetch(`${url}/api/admin/tenants/${tenant}/octwin-platform-kb`, {
602
+ const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/octwin-platform-kb`, {
497
603
  headers: { authorization: `Bearer ${token}` },
498
- });
604
+ }, 'platform-kb pull');
499
605
  const text = await res.text();
500
606
  if (!res.ok) {
501
607
  let j;
@@ -528,15 +634,19 @@ async function cmdPlatformKb(flags) {
528
634
  writeFileSync(join(outDir, `${key}.json`), JSON.stringify(val, null, 2) + '\n', 'utf8');
529
635
  jsonCount++;
530
636
  }
531
- writeFileSync(join(outDir, 'index.json'), JSON.stringify({ version: bundle.version, generated_at: bundle.generated_at, index: bundle.index }, null, 2) + '\n', 'utf8');
637
+ // Persist `content_hash` too the staleness observer (`notifyIfKbStale`) reads
638
+ // it back and compares against the platform's current hash to nudge a re-pull.
639
+ writeFileSync(join(outDir, 'index.json'), JSON.stringify({ version: bundle.version, content_hash: bundle.content_hash, generated_at: bundle.generated_at, index: bundle.index }, null, 2) + '\n', 'utf8');
532
640
  console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
533
641
  console.log(` ${mdCount} markdown docs + ${jsonCount} JSON catalogs (reference version ${bundle.version ?? '?'})`);
534
642
  console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
535
643
  }
536
- // ── records / logs / chat — headless inspect + test with the deploy token ────
537
- /** GET an admin endpoint with the deploy token; returns `{ status, json }`. */
644
+ // ── records / cases / logs / chat — headless inspect + test with the deploy token ────
645
+ /** GET an admin endpoint with the deploy token; returns `{ status, json }`.
646
+ * Dies (with the URL) on a network failure; auth failures return so the
647
+ * caller can add command-specific context on top of `authFailureHint`. */
538
648
  async function apiGet(endpoint, token) {
539
- const res = await fetch(endpoint, { headers: { authorization: `Bearer ${token}` } });
649
+ const res = await fetchOrDie(endpoint, { headers: { authorization: `Bearer ${token}` } }, 'request');
540
650
  const text = await res.text();
541
651
  let json;
542
652
  try {
@@ -578,8 +688,14 @@ async function cmdRecords(flags) {
578
688
  const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, token);
579
689
  if (status === 403)
580
690
  die('forbidden — the paged record list needs the `records` plan feature on this tenant');
581
- if (status !== 200)
582
- die(`could not read records (HTTP ${status})`);
691
+ if (status !== 200) {
692
+ // Always show the server's reason (it names the unknown entity). Cases are
693
+ // casework (worklist), not pack-declared XRM — point at the right command.
694
+ if (entity === 'case' || entity === 'cases') {
695
+ console.error(` '${entity}' is casework (worklist), not a pack-declared XRM entity — inspect tickets with: octwin cases`);
696
+ }
697
+ die(`could not read records (HTTP ${status})${errDetail(json)}${status === 401 ? ` — ${authFailureHint(status, url)}` : ''}`);
698
+ }
583
699
  const rows = (json?.records ?? []);
584
700
  console.log(`${entity}: ${json?.total ?? rows.length} record(s)`);
585
701
  if (rows.length === 0)
@@ -597,44 +713,191 @@ async function cmdRecords(flags) {
597
713
  die(`could not read record (HTTP ${status})`);
598
714
  console.log(JSON.stringify(json?.record ?? json, null, 2));
599
715
  }
600
- /** `octwin logs [conversationId]` — list conversations or show one's event timeline. */
716
+ /** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
717
+ * or show one's event timeline (full text + the renders each turn produced). */
601
718
  async function cmdLogs(flags) {
602
719
  const packDir = resolve(flags.dir ?? '.');
603
720
  const { url, tenant, project, token } = resolveTarget(flags, packDir);
604
721
  const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
605
722
  const convId = flags._[0];
723
+ const asJson = flags.json === true;
724
+ const asHandle = typeof flags.as === 'string' ? flags.as : undefined;
606
725
  if (!convId) {
607
- const { status, json } = await apiGet(`${base}/conversations?limit=20`, token);
726
+ const { status, json } = await apiGet(`${base}/conversations?limit=50`, token);
608
727
  if (status !== 200)
609
- die(`could not read conversations (HTTP ${status})`);
610
- const convs = (json?.conversations ?? []);
728
+ die(`could not read conversations (HTTP ${status})${errDetail(json)} — ${authFailureHint(status, url)}`);
729
+ let convs = (json?.conversations ?? []);
730
+ if (asHandle)
731
+ convs = convs.filter((c) => c.contact?.channel_contact_handle === asHandle);
611
732
  if (convs.length === 0) {
612
- console.log('No conversations yet — try `octwin chat "hi"` first.');
733
+ console.log(asHandle ? `No conversations for handle '${asHandle}'.` : 'No conversations yet — try `octwin chat "hi"` first.');
613
734
  return;
614
735
  }
615
- console.log(`Recent conversations in ${tenant}/${project}:`);
616
- for (const c of convs)
617
- console.log(` ${c.id} ${c.contact?.display_name ?? c.contact?.channel_contact_handle ?? '?'} [${c.status}]`);
618
- console.log('\nView a timeline: octwin logs <conversationId>');
736
+ if (asJson) {
737
+ console.log(JSON.stringify(convs, null, 2));
738
+ return;
739
+ }
740
+ console.log(`Recent conversations in ${tenant}/${project}${asHandle ? ` (handle: ${asHandle})` : ''}:`);
741
+ for (const c of convs) {
742
+ const handle = c.contact?.channel_contact_handle ?? '?';
743
+ const name = c.contact?.display_name && c.contact.display_name !== handle ? ` (${c.contact.display_name})` : '';
744
+ const when = c.last_event_at ?? c.created_at ?? '';
745
+ console.log(` ${c.id} ${handle}${name} [${c.status}] ${when}`);
746
+ }
747
+ console.log('\nView a timeline: octwin logs <conversationId> (add --json for full payloads)');
619
748
  return;
620
749
  }
621
750
  const { status, json } = await apiGet(`${base}/conversations/${encodeURIComponent(convId)}`, token);
622
751
  if (status === 404)
623
752
  die(`conversation '${convId}' not found`);
624
753
  if (status !== 200)
625
- die(`could not read conversation (HTTP ${status})`);
754
+ die(`could not read conversation (HTTP ${status})${errDetail(json)} — ${authFailureHint(status, url)}`);
626
755
  const events = (json?.events ?? []);
756
+ if (asJson) {
757
+ console.log(JSON.stringify(events, null, 2));
758
+ return;
759
+ }
627
760
  console.log(`Timeline for ${convId} (${events.length} events):`);
628
761
  for (const e of events) {
629
762
  const isErr = e.type === 'tool' && e.subtype === 'platform_error';
630
763
  const tag = isErr ? '⚠ ERROR' : `${e.type}${e.subtype ? `/${e.subtype}` : ''}`;
631
764
  const body = typeof e.content === 'string' ? e.content : JSON.stringify(e.content ?? '');
632
- console.log(` ${e.ts ?? ''} ${tag}: ${(body ?? '').slice(0, 240)}`);
765
+ console.log(` ${e.ts ?? ''} ${tag}: ${body ?? ''}`);
766
+ // The renders this turn actually sent (captured outbound payloads) — the
767
+ // other half of the story next to the structured tap lines.
768
+ const captured = Array.isArray(e.metadata?.raw) ? e.metadata.raw : [];
769
+ for (const p of captured) {
770
+ if (!p?.rendered)
771
+ continue;
772
+ console.log(` ↳ rendered ${p.rendered.type ?? '?'}`);
773
+ printHint(p.rendered, ' ');
774
+ }
775
+ }
776
+ console.log('\nVerbatim LLM-thread payloads: --json, or GET …/conversations/<id>/mastra-messages');
777
+ }
778
+ // ── chat: render printing + SSE frame plumbing ──────────────────────────────
779
+ /** Print a render hint's FULL interactive content — every row/button with its
780
+ * tap id, so a follow-up `octwin chat --tap "<id>"` can press it. */
781
+ function printHint(hint, indent = ' ') {
782
+ if (!hint || typeof hint !== 'object')
783
+ return;
784
+ const out = (s) => console.log(`${indent}${s}`);
785
+ if (hint.header)
786
+ out(`─ ${hint.header}`);
787
+ switch (hint.type) {
788
+ case 'buttons':
789
+ for (const b of hint.buttons ?? [])
790
+ out(`[btn] ${b.title} tap: ${b.id}`);
791
+ break;
792
+ case 'list':
793
+ for (const s of hint.sections ?? []) {
794
+ if (s.title)
795
+ out(`── ${s.title}`);
796
+ for (const r of s.rows ?? []) {
797
+ out(`[row] ${r.title}${r.description ? ` — ${r.description}` : ''}`);
798
+ out(` tap: ${r.id}`);
799
+ }
800
+ }
801
+ if (hint.button_text)
802
+ out(`(list button: ${hint.button_text})`);
803
+ break;
804
+ case 'carousel':
805
+ (hint.cards ?? []).forEach((c, i) => {
806
+ out(`[card ${i + 1}] ${c.body}`);
807
+ for (const b of c.buttons ?? []) {
808
+ if (b.type === 'url')
809
+ out(` [link] ${b.title} → ${b.url}`);
810
+ else
811
+ out(` [btn] ${b.title} tap: ${b.id}`);
812
+ }
813
+ });
814
+ break;
815
+ case 'cta_url':
816
+ out(`[link] ${hint.label} → ${hint.url}`);
817
+ break;
818
+ case 'flow':
819
+ out(`[flow form] ${hint.cta_text} (flow_id: ${hint.flow_id})`);
820
+ break;
821
+ default:
822
+ // text / commerce variants — the body line already carries the substance.
823
+ break;
824
+ }
825
+ if (hint.footer)
826
+ out(`─ ${hint.footer}`);
827
+ }
828
+ /** Incremental SSE reader: buffers chunks, yields parsed `{ id, ev }` frames,
829
+ * and supports racing a read against a settle-window timeout WITHOUT losing
830
+ * data (the pending read is kept and re-raced — never issued twice). */
831
+ class SseFrameReader {
832
+ reader;
833
+ buf = '';
834
+ decoder = new TextDecoder();
835
+ pending = null;
836
+ queue = [];
837
+ constructor(reader) {
838
+ this.reader = reader;
839
+ }
840
+ /** Release the underlying stream (the body is locked to our reader). */
841
+ async cancel() {
842
+ try {
843
+ await this.reader.cancel();
844
+ }
845
+ catch { /* already closed */ }
846
+ }
847
+ /** Next frame, or 'timeout' after `ms` of silence, or 'done' when the stream ends. */
848
+ async next(ms) {
849
+ for (;;) {
850
+ const queued = this.queue.shift();
851
+ if (queued)
852
+ return queued;
853
+ this.pending ??= this.reader.read();
854
+ const TIMEOUT = Symbol('timeout');
855
+ const winner = await Promise.race([
856
+ this.pending,
857
+ new Promise((res) => setTimeout(() => res(TIMEOUT), ms).unref?.()),
858
+ ]);
859
+ if (winner === TIMEOUT)
860
+ return 'timeout';
861
+ this.pending = null;
862
+ const { done, value } = winner;
863
+ if (done)
864
+ return 'done';
865
+ this.buf += this.decoder.decode(value, { stream: true });
866
+ let idx;
867
+ while ((idx = this.buf.indexOf('\n\n')) >= 0) {
868
+ const frame = this.buf.slice(0, idx);
869
+ this.buf = this.buf.slice(idx + 2);
870
+ const lines = frame.split('\n');
871
+ const dataLine = lines.find((l) => l.startsWith('data:'));
872
+ if (!dataLine)
873
+ continue; // heartbeat comments etc.
874
+ const idLine = lines.find((l) => l.startsWith('id:'));
875
+ const id = idLine ? parseInt(idLine.slice(3).trim(), 10) : null;
876
+ let ev;
877
+ try {
878
+ ev = JSON.parse(dataLine.slice(5).trim());
879
+ }
880
+ catch {
881
+ continue;
882
+ }
883
+ this.queue.push({ id: Number.isFinite(id) ? id : null, ev });
884
+ }
885
+ }
633
886
  }
634
887
  }
635
- /** `octwin chat "message" [--as <handle>]` drive ONE turn through the dev web
636
- * channel and print the agent's reply. The web channel is unauthenticated, so no
637
- * token is required here. */
888
+ const REPLAY_SETTLE_MS = 400; // quiet gap that marks the end of the connect replay burst
889
+ const TURN_SETTLE_MS = 2_000; // quiet gap after a render = the turn finished sending
890
+ const REPLY_TIMEOUT_MS = 45_000; // hard cap waiting for the first render of the turn
891
+ /** `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json]` — drive one
892
+ * turn through the dev web channel and print everything it rendered.
893
+ *
894
+ * Multi-turn works: the platform keeps ONE open conversation per handle, so the
895
+ * same `--as` continues the same conversation. Two traps this command handles:
896
+ * (1) the platform dedups inbound on `local_id` for 5 minutes — so we mint a
897
+ * FRESH id per call (a constant id made consecutive calls silent no-ops);
898
+ * (2) a fresh SSE connect REPLAYS recent history (the ring buffer) — so we
899
+ * drain the replay first and only accept frames newer than it as the reply
900
+ * (naively printing the first render showed LAST turn's message again). */
638
901
  async function cmdChat(flags) {
639
902
  const packDir = resolve(flags.dir ?? '.');
640
903
  const cfg = readPackConfig(packDir);
@@ -646,84 +909,162 @@ async function cmdChat(flags) {
646
909
  if (!tenant)
647
910
  die('no tenant — set it in pack.json, --tenant, or PACK_TENANT');
648
911
  const from = flags.as ?? 'cli-tester';
912
+ const asJson = flags.json === true;
913
+ const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
649
914
  const message = flags._[0];
650
- if (!message)
651
- die('usage: octwin chat "your message" [--as <handle>]');
652
- const ctrl = new AbortController();
653
- const timer = setTimeout(() => ctrl.abort(), 30_000);
654
- let evRes;
655
- try {
656
- evRes = await fetch(`${url}/api/web/events/${tenant}/${project}/${encodeURIComponent(from)}`, { headers: { accept: 'text/event-stream' }, signal: ctrl.signal });
657
- }
658
- catch (err) {
659
- clearTimeout(timer);
660
- die(`could not open chat stream: ${err?.message ?? err}`);
661
- return;
662
- }
663
- if (!evRes.ok || !evRes.body) {
664
- clearTimeout(timer);
915
+ if (!message && !tapId)
916
+ die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--json]');
917
+ // Fresh idempotency key per call (see the command doc above).
918
+ const localId = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
919
+ const evRes = await fetchOrDie(`${url}/api/web/events/${tenant}/${project}/${encodeURIComponent(from)}`, { headers: { accept: 'text/event-stream' } }, 'open chat stream');
920
+ if (!evRes.ok || !evRes.body)
665
921
  die(`could not open chat stream (HTTP ${evRes.status})`);
666
- return;
922
+ const frames = new SseFrameReader(evRes.body.getReader());
923
+ const cancel = () => frames.cancel();
924
+ // Phase 1 — drain the connect replay; remember the highest sequence id.
925
+ // Frames at or below it are history, not this turn's reply.
926
+ let boundary = 0;
927
+ for (;;) {
928
+ const f = await frames.next(REPLAY_SETTLE_MS);
929
+ if (f === 'timeout')
930
+ break;
931
+ if (f === 'done') {
932
+ await cancel();
933
+ die('chat stream closed before the message was sent');
934
+ }
935
+ if (f.id != null && f.id > boundary)
936
+ boundary = f.id;
667
937
  }
668
- console.log(`→ [${from}] ${message}`);
669
- await fetch(`${url}/api/web/inbound/${tenant}/${project}`, {
938
+ // Phase 2 — send the inbound (text, or an interactive tap).
939
+ console.log(`→ [${from}] ${tapId ? `(tap) ${tapId}` : message}`);
940
+ const body = tapId
941
+ ? { type: 'interactive', from, tap_id: tapId, ...(message ? { raw_title: message } : {}), local_id: localId }
942
+ : { type: 'text', from, text: message, local_id: localId };
943
+ const postRes = await fetchOrDie(`${url}/api/web/inbound/${tenant}/${project}`, {
670
944
  method: 'POST',
671
945
  headers: { 'content-type': 'application/json' },
672
- body: JSON.stringify({ type: 'text', from, text: message, local_id: `cli-${from}` }),
673
- }).catch((err) => { clearTimeout(timer); die(`could not send message: ${err?.message ?? err}`); });
674
- const reader = evRes.body.getReader();
675
- const decoder = new TextDecoder();
676
- let buf = '';
677
- let gotReply = false;
678
- try {
679
- outer: for (;;) {
680
- const { done, value } = await reader.read();
681
- if (done)
946
+ body: JSON.stringify(body),
947
+ }, 'send message');
948
+ if (!postRes.ok) {
949
+ await cancel();
950
+ die(`send rejected (HTTP ${postRes.status}): ${await postRes.text()}`);
951
+ }
952
+ // Phase 3 — collect THIS turn's renders (id > boundary). A turn can send
953
+ // several messages, so keep reading until a quiet gap after the last render.
954
+ const deadline = Date.now() + REPLY_TIMEOUT_MS;
955
+ let rendersSeen = 0;
956
+ for (;;) {
957
+ const remaining = deadline - Date.now();
958
+ if (remaining <= 0)
959
+ break;
960
+ const f = await frames.next(rendersSeen > 0 ? TURN_SETTLE_MS : Math.min(remaining, REPLY_TIMEOUT_MS));
961
+ if (f === 'timeout') {
962
+ if (rendersSeen > 0)
682
963
  break;
683
- buf += decoder.decode(value, { stream: true });
684
- let idx;
685
- while ((idx = buf.indexOf('\n\n')) >= 0) {
686
- const frame = buf.slice(0, idx);
687
- buf = buf.slice(idx + 2);
688
- const line = frame.split('\n').find((l) => l.startsWith('data:'));
689
- if (!line)
690
- continue;
691
- let ev;
692
- try {
693
- ev = JSON.parse(line.slice(5).trim());
694
- }
695
- catch {
696
- continue;
697
- }
698
- if (ev.kind === 'render') {
699
- console.log(`← ${ev.body ?? '(no text body)'}`);
700
- const hint = ev.hint;
701
- if (hint?.buttons?.length)
702
- console.log(` [buttons: ${hint.buttons.map((b) => b.title).join(' | ')}]`);
703
- if (hint?.sections?.length || hint?.rows?.length)
704
- console.log(' [a list picker was rendered]');
705
- gotReply = true;
706
- break outer;
707
- }
708
- }
964
+ else
965
+ continue;
709
966
  }
967
+ if (f === 'done')
968
+ break;
969
+ if (f.id != null && f.id <= boundary)
970
+ continue; // late replay stragglers
971
+ if (asJson) {
972
+ console.log(JSON.stringify(f.ev));
973
+ if (f.ev?.kind === 'render')
974
+ rendersSeen++;
975
+ continue;
976
+ }
977
+ if (f.ev?.kind !== 'render')
978
+ continue; // status/typing noise
979
+ rendersSeen++;
980
+ console.log(`← ${f.ev.body ?? '(no text body)'}${f.ev.hint?.type && f.ev.hint.type !== 'text' ? ` (render: ${f.ev.hint.type})` : ''}`);
981
+ printHint(f.ev.hint);
710
982
  }
711
- catch (err) {
712
- if (ctrl.signal.aborted)
713
- console.error(' (timed out after 30s waiting for a reply)');
714
- else
715
- console.error(` (stream error: ${err?.message ?? err})`);
983
+ await cancel();
984
+ if (rendersSeen === 0) {
985
+ console.error(` no reply after ${Math.round(REPLY_TIMEOUT_MS / 1000)}s the pack may not be warm yet, or the turn produced no render.`);
986
+ process.exit(1);
716
987
  }
717
- finally {
718
- clearTimeout(timer);
719
- try {
720
- await reader.cancel();
988
+ console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
989
+ }
990
+ /** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
991
+ * the aggregate inbox, one case + its timeline, or the queue list. */
992
+ async function cmdCases(flags) {
993
+ const packDir = resolve(flags.dir ?? '.');
994
+ const { url, tenant, project, token } = resolveTarget(flags, packDir);
995
+ const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
996
+ const caseId = flags._[0];
997
+ const asJson = flags.json === true;
998
+ const caseFail = (what, status, json) => {
999
+ if (status === 403)
1000
+ die(`forbidden — casework needs the 'cases' plan feature on this tenant, and a role whose grants reach the queue`);
1001
+ die(`could not read ${what} (HTTP ${status})${errDetail(json)}${status === 401 ? ` — ${authFailureHint(status, url)}` : ''}`);
1002
+ };
1003
+ if (flags.queues === true) {
1004
+ const { status, json } = await apiGet(`${base}/case-queues`, token);
1005
+ if (status !== 200)
1006
+ caseFail('case queues', status, json);
1007
+ if (asJson) {
1008
+ console.log(JSON.stringify(json, null, 2));
1009
+ return;
721
1010
  }
722
- catch { /* ignore */ }
1011
+ const queues = (json?.queues ?? []);
1012
+ console.log(`Case queues in ${tenant}/${project}:`);
1013
+ for (const q of queues)
1014
+ console.log(` ${q.key}${q.name ? ` (${q.name})` : ''} ${q.open_count} open`);
1015
+ if (json?.unrouted_open_count)
1016
+ console.log(` (unrouted: ${json.unrouted_open_count} open)`);
1017
+ return;
723
1018
  }
724
- if (!gotReply) {
725
- console.error(' no reply the pack may not be warm yet, or the turn produced no render.');
726
- process.exit(1);
1019
+ if (!caseId) {
1020
+ const limit = flags.limit ?? '50';
1021
+ const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, token);
1022
+ if (status !== 200)
1023
+ caseFail('cases', status, json);
1024
+ if (asJson) {
1025
+ console.log(JSON.stringify(json, null, 2));
1026
+ return;
1027
+ }
1028
+ const rows = (json?.cases ?? []);
1029
+ console.log(`Cases in ${tenant}/${project}: ${json?.total ?? rows.length} total`);
1030
+ if (rows.length === 0)
1031
+ console.log(' (none)');
1032
+ for (const c of rows) {
1033
+ const sla = c.sla_due_at ? ` sla:${c.sla_due_at}` : '';
1034
+ console.log(` #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}${c.queue_key ? ` q:${c.queue_key}` : ''}${sla} ${c.id}`);
1035
+ }
1036
+ console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
1037
+ return;
1038
+ }
1039
+ const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, token);
1040
+ if (status === 404)
1041
+ die(`case '${caseId}' not found`);
1042
+ if (status !== 200)
1043
+ caseFail('case', status, json);
1044
+ if (asJson) {
1045
+ console.log(JSON.stringify(json, null, 2));
1046
+ return;
1047
+ }
1048
+ const c = json?.case ?? {};
1049
+ console.log(`Case #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}`);
1050
+ console.log(` id: ${c.id} queue: ${c.queue_key ?? '(unrouted)'} assignee: ${c.assignee_principal ?? '(none)'}`);
1051
+ if (json?.contact)
1052
+ console.log(` contact: ${json.contact.display_name ?? json.contact.channel_contact_handle ?? json.contact.id}`);
1053
+ if (c.conversation_id)
1054
+ console.log(` conversation: ${c.conversation_id} (octwin logs ${c.conversation_id})`);
1055
+ if (c.sla_due_at)
1056
+ console.log(` sla due: ${c.sla_due_at}`);
1057
+ if (c.fields && Object.keys(c.fields).length > 0)
1058
+ console.log(` fields: ${JSON.stringify(c.fields)}`);
1059
+ const events = (json?.events ?? []);
1060
+ console.log(` Timeline (${events.length}):`);
1061
+ for (const e of events) {
1062
+ const payload = e.payload && Object.keys(e.payload).length > 0 ? ` ${JSON.stringify(e.payload)}` : '';
1063
+ console.log(` ${e.ts ?? ''} ${e.kind}${e.actor ? ` (${e.actor})` : ''}${payload}`);
1064
+ }
1065
+ const dispositions = (json?.dispositions ?? []);
1066
+ if (dispositions.length > 0) {
1067
+ console.log(` Decisions: ${dispositions.map((d) => `${d.action}${d.next_status ? `→${d.next_status}` : ''}`).join(', ')}`);
727
1068
  }
728
1069
  }
729
1070
  function help() {
@@ -737,18 +1078,67 @@ function help() {
737
1078
  octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
738
1079
  octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
739
1080
  octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
740
- octwin logs [conversationId] # list conversations / show one's event timeline
741
- octwin chat "message" [--as <handle>] # drive one turn through the web channel + print the reply
1081
+ octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
1082
+ octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
1083
+ octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json] # drive a turn + print every render
742
1084
  octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
743
1085
  octwin test [--dir .] # = validate --remote (the full platform check)
744
1086
 
1087
+ Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
1088
+ \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
1089
+ button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
745
1090
  Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
746
1091
  octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
747
- Config (deploy): flags > pack.json > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login.`);
1092
+ Config (deploy): flags > pack.json > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login.
1093
+ Per-command usage: octwin <command> --help`);
748
1094
  }
1095
+ /** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
1096
+ * network/auth work (a --help that 401s is worse than no help at all). */
1097
+ const COMMAND_HELP = {
1098
+ init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
1099
+ Scaffold a pure-YAML starter pack into <dir>.`,
1100
+ validate: `octwin validate [--dir .] [--remote]
1101
+ Offline structural check; --remote additionally runs the platform's FULL
1102
+ manifest + flow-DSL validation (all errors at once) — same check as deploy.`,
1103
+ login: `octwin login --url <platformUrl> --token oct_…
1104
+ Save a deploy token (console → Settings → API tokens) for that platform url.`,
1105
+ whoami: `octwin whoami [--url <url>] [--tenant <slug>]
1106
+ Verify the resolved token authenticates against the tenant.`,
1107
+ deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
1108
+ Upload the pack bundle, validate server-side, install onto the project.
1109
+ --seed additionally applies the pack's demo seed (streams progress).`,
1110
+ status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
1111
+ Show installed vs live version + the flow list for this pack.`,
1112
+ records: `octwin records [entity] [id] [--limit 50]
1113
+ Inspect the pack's XRM data. No args = list entities. Cases/tickets are
1114
+ casework, not XRM — use \`octwin cases\` for those.`,
1115
+ cases: `octwin cases [caseId] [--queues] [--limit 50] [--json]
1116
+ Inspect casework (support tickets): the inbox, one case + its timeline
1117
+ (+ applicable decisions), or --queues for queue keys + open counts.`,
1118
+ logs: `octwin logs [conversationId] [--as <handle>] [--json]
1119
+ No id = recent conversations (handle, status, last activity; --as filters).
1120
+ With id = the full event timeline including what each turn rendered.
1121
+ --json = raw events (verbatim payloads).`,
1122
+ chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json]
1123
+ Drive one turn through the dev web channel and print every render with its
1124
+ tap ids. Same --as handle = same conversation (multi-turn works).
1125
+ --tap presses a rendered button/list row instead of sending text.
1126
+ --json dumps the raw SSE envelopes for the turn.`,
1127
+ 'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1128
+ Pull the platform capability reference (markdown + JSON catalogs) into
1129
+ .octwin/platform-kb/ for the octwin-pack authoring skill.`,
1130
+ test: `octwin test [--dir .]
1131
+ Alias for \`octwin validate --remote\` — the full platform check.`,
1132
+ };
749
1133
  async function main() {
750
1134
  const [command, ...rest] = process.argv.slice(2);
751
1135
  const flags = parseFlags(rest);
1136
+ // Per-subcommand --help/-h — intercepted BEFORE the command runs, so help can
1137
+ // never hit the network or die on auth (author-feedback A8).
1138
+ if (command && command in COMMAND_HELP && (flags.help === true || flags._.includes('-h'))) {
1139
+ console.log(COMMAND_HELP[command]);
1140
+ return;
1141
+ }
752
1142
  switch (command) {
753
1143
  case 'init':
754
1144
  cmdInit(flags);
@@ -771,6 +1161,9 @@ async function main() {
771
1161
  case 'records':
772
1162
  await cmdRecords(flags);
773
1163
  break;
1164
+ case 'cases':
1165
+ await cmdCases(flags);
1166
+ break;
774
1167
  case 'logs':
775
1168
  await cmdLogs(flags);
776
1169
  break;
@@ -796,6 +1189,11 @@ async function main() {
796
1189
  break;
797
1190
  default: die(`unknown command '${command}' — run \`octwin help\``);
798
1191
  }
799
- await notifyIfOutdated(); // trailing, fail-silent, TTY-only "newer version available" notice
1192
+ // Trailing, fail-silent, TTY-only notices. KB-drift only after commands that
1193
+ // already hit the platform (so it's one extra tiny GET, never a new call on
1194
+ // offline paths); CLI-upgrade always.
1195
+ if (commandTouchesPlatform(command, flags))
1196
+ await notifyIfKbStale(flags);
1197
+ await notifyIfOutdated();
800
1198
  }
801
1199
  main().catch((err) => die(err?.message ?? String(err)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "dist",
11
11
  "templates",
12
12
  "README.md",
13
+ "CHANGELOG.md",
13
14
  "LICENSE"
14
15
  ],
15
16
  "engines": {