octwin-cli 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -2
  2. package/dist/index.js +418 -100
  3. package/package.json +1 -1
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
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. */
@@ -268,11 +301,11 @@ async function cmdValidate(flags) {
268
301
  // every flow (schema/expression/structure) — returning ALL errors at once.
269
302
  const { url, tenant, project, token } = resolveTarget(flags, packDir);
270
303
  console.log(`→ Validating against ${tenant}/${project} @ ${url} …`);
271
- const res = await fetch(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/validate`, {
304
+ const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/validate`, {
272
305
  method: 'POST',
273
306
  headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
274
307
  body: JSON.stringify({ files }),
275
- });
308
+ }, 'remote validate');
276
309
  const text = await res.text();
277
310
  let json;
278
311
  try {
@@ -334,7 +367,7 @@ function resolveTarget(flags, packDir) {
334
367
  async function cmdWhoami(flags) {
335
368
  const packDir = resolve(flags.dir ?? '.');
336
369
  const { url, tenant, token } = resolveTarget(flags, packDir);
337
- const res = await fetch(`${url}/api/admin/tenants/${tenant}/packs`, { headers: { authorization: `Bearer ${token}` } });
370
+ const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/packs`, { headers: { authorization: `Bearer ${token}` } }, 'token check');
338
371
  if (res.ok) {
339
372
  console.log(`✓ Token valid for tenant '${tenant}' at ${url} (${token.startsWith('oct_') ? 'deploy token' : 'session token'})`);
340
373
  return;
@@ -411,13 +444,13 @@ async function cmdDeploy(flags) {
411
444
  const endpoint = `${url}/api/admin/tenants/${tenant}/projects/${project}/packs/deploy`;
412
445
  const seed = flags.seed === true;
413
446
  console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${tenant}/${project}${seed ? ' — with demo seed' : ''} …`);
414
- const res = await fetch(endpoint, {
447
+ const res = await fetchOrDie(endpoint, {
415
448
  method: 'POST',
416
449
  // Ask for a progress stream; the platform falls back to plain JSON if it
417
450
  // (or an error before any progress) can't stream — handled below.
418
451
  headers: { 'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${token}` },
419
452
  body: JSON.stringify({ files, seed }),
420
- });
453
+ }, 'deploy');
421
454
  // Streaming path — live install + seed progress (image generation can take a
422
455
  // while, so `--seed` prints per-record / per-image lines as they happen).
423
456
  if (res.ok && (res.headers.get('content-type') ?? '').includes('text/event-stream') && res.body) {
@@ -455,9 +488,9 @@ async function cmdStatus(flags) {
455
488
  die('manifest.yaml must declare a string `id`');
456
489
  const id = doc.id;
457
490
  const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
458
- const res = await fetch(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/${id}/runtime`, {
491
+ const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/${id}/runtime`, {
459
492
  headers: { authorization: `Bearer ${token}` },
460
- });
493
+ }, 'status check');
461
494
  const text = await res.text();
462
495
  let json;
463
496
  try {
@@ -493,9 +526,9 @@ async function cmdStatus(flags) {
493
526
  async function cmdPlatformKb(flags) {
494
527
  const packDir = resolve(flags.dir ?? '.');
495
528
  const { url, tenant, token } = resolveTarget(flags, packDir);
496
- const res = await fetch(`${url}/api/admin/tenants/${tenant}/octwin-platform-kb`, {
529
+ const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/octwin-platform-kb`, {
497
530
  headers: { authorization: `Bearer ${token}` },
498
- });
531
+ }, 'platform-kb pull');
499
532
  const text = await res.text();
500
533
  if (!res.ok) {
501
534
  let j;
@@ -533,10 +566,12 @@ async function cmdPlatformKb(flags) {
533
566
  console.log(` ${mdCount} markdown docs + ${jsonCount} JSON catalogs (reference version ${bundle.version ?? '?'})`);
534
567
  console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
535
568
  }
536
- // ── records / logs / chat — headless inspect + test with the deploy token ────
537
- /** GET an admin endpoint with the deploy token; returns `{ status, json }`. */
569
+ // ── records / cases / logs / chat — headless inspect + test with the deploy token ────
570
+ /** GET an admin endpoint with the deploy token; returns `{ status, json }`.
571
+ * Dies (with the URL) on a network failure; auth failures return so the
572
+ * caller can add command-specific context on top of `authFailureHint`. */
538
573
  async function apiGet(endpoint, token) {
539
- const res = await fetch(endpoint, { headers: { authorization: `Bearer ${token}` } });
574
+ const res = await fetchOrDie(endpoint, { headers: { authorization: `Bearer ${token}` } }, 'request');
540
575
  const text = await res.text();
541
576
  let json;
542
577
  try {
@@ -578,8 +613,14 @@ async function cmdRecords(flags) {
578
613
  const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, token);
579
614
  if (status === 403)
580
615
  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})`);
616
+ if (status !== 200) {
617
+ // Always show the server's reason (it names the unknown entity). Cases are
618
+ // casework (worklist), not pack-declared XRM — point at the right command.
619
+ if (entity === 'case' || entity === 'cases') {
620
+ console.error(` '${entity}' is casework (worklist), not a pack-declared XRM entity — inspect tickets with: octwin cases`);
621
+ }
622
+ die(`could not read records (HTTP ${status})${errDetail(json)}${status === 401 ? ` — ${authFailureHint(status, url)}` : ''}`);
623
+ }
583
624
  const rows = (json?.records ?? []);
584
625
  console.log(`${entity}: ${json?.total ?? rows.length} record(s)`);
585
626
  if (rows.length === 0)
@@ -597,44 +638,191 @@ async function cmdRecords(flags) {
597
638
  die(`could not read record (HTTP ${status})`);
598
639
  console.log(JSON.stringify(json?.record ?? json, null, 2));
599
640
  }
600
- /** `octwin logs [conversationId]` — list conversations or show one's event timeline. */
641
+ /** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
642
+ * or show one's event timeline (full text + the renders each turn produced). */
601
643
  async function cmdLogs(flags) {
602
644
  const packDir = resolve(flags.dir ?? '.');
603
645
  const { url, tenant, project, token } = resolveTarget(flags, packDir);
604
646
  const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
605
647
  const convId = flags._[0];
648
+ const asJson = flags.json === true;
649
+ const asHandle = typeof flags.as === 'string' ? flags.as : undefined;
606
650
  if (!convId) {
607
- const { status, json } = await apiGet(`${base}/conversations?limit=20`, token);
651
+ const { status, json } = await apiGet(`${base}/conversations?limit=50`, token);
608
652
  if (status !== 200)
609
- die(`could not read conversations (HTTP ${status})`);
610
- const convs = (json?.conversations ?? []);
653
+ die(`could not read conversations (HTTP ${status})${errDetail(json)} — ${authFailureHint(status, url)}`);
654
+ let convs = (json?.conversations ?? []);
655
+ if (asHandle)
656
+ convs = convs.filter((c) => c.contact?.channel_contact_handle === asHandle);
611
657
  if (convs.length === 0) {
612
- console.log('No conversations yet — try `octwin chat "hi"` first.');
658
+ console.log(asHandle ? `No conversations for handle '${asHandle}'.` : 'No conversations yet — try `octwin chat "hi"` first.');
613
659
  return;
614
660
  }
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>');
661
+ if (asJson) {
662
+ console.log(JSON.stringify(convs, null, 2));
663
+ return;
664
+ }
665
+ console.log(`Recent conversations in ${tenant}/${project}${asHandle ? ` (handle: ${asHandle})` : ''}:`);
666
+ for (const c of convs) {
667
+ const handle = c.contact?.channel_contact_handle ?? '?';
668
+ const name = c.contact?.display_name && c.contact.display_name !== handle ? ` (${c.contact.display_name})` : '';
669
+ const when = c.last_event_at ?? c.created_at ?? '';
670
+ console.log(` ${c.id} ${handle}${name} [${c.status}] ${when}`);
671
+ }
672
+ console.log('\nView a timeline: octwin logs <conversationId> (add --json for full payloads)');
619
673
  return;
620
674
  }
621
675
  const { status, json } = await apiGet(`${base}/conversations/${encodeURIComponent(convId)}`, token);
622
676
  if (status === 404)
623
677
  die(`conversation '${convId}' not found`);
624
678
  if (status !== 200)
625
- die(`could not read conversation (HTTP ${status})`);
679
+ die(`could not read conversation (HTTP ${status})${errDetail(json)} — ${authFailureHint(status, url)}`);
626
680
  const events = (json?.events ?? []);
681
+ if (asJson) {
682
+ console.log(JSON.stringify(events, null, 2));
683
+ return;
684
+ }
627
685
  console.log(`Timeline for ${convId} (${events.length} events):`);
628
686
  for (const e of events) {
629
687
  const isErr = e.type === 'tool' && e.subtype === 'platform_error';
630
688
  const tag = isErr ? '⚠ ERROR' : `${e.type}${e.subtype ? `/${e.subtype}` : ''}`;
631
689
  const body = typeof e.content === 'string' ? e.content : JSON.stringify(e.content ?? '');
632
- console.log(` ${e.ts ?? ''} ${tag}: ${(body ?? '').slice(0, 240)}`);
690
+ console.log(` ${e.ts ?? ''} ${tag}: ${body ?? ''}`);
691
+ // The renders this turn actually sent (captured outbound payloads) — the
692
+ // other half of the story next to the structured tap lines.
693
+ const captured = Array.isArray(e.metadata?.raw) ? e.metadata.raw : [];
694
+ for (const p of captured) {
695
+ if (!p?.rendered)
696
+ continue;
697
+ console.log(` ↳ rendered ${p.rendered.type ?? '?'}`);
698
+ printHint(p.rendered, ' ');
699
+ }
700
+ }
701
+ console.log('\nVerbatim LLM-thread payloads: --json, or GET …/conversations/<id>/mastra-messages');
702
+ }
703
+ // ── chat: render printing + SSE frame plumbing ──────────────────────────────
704
+ /** Print a render hint's FULL interactive content — every row/button with its
705
+ * tap id, so a follow-up `octwin chat --tap "<id>"` can press it. */
706
+ function printHint(hint, indent = ' ') {
707
+ if (!hint || typeof hint !== 'object')
708
+ return;
709
+ const out = (s) => console.log(`${indent}${s}`);
710
+ if (hint.header)
711
+ out(`─ ${hint.header}`);
712
+ switch (hint.type) {
713
+ case 'buttons':
714
+ for (const b of hint.buttons ?? [])
715
+ out(`[btn] ${b.title} tap: ${b.id}`);
716
+ break;
717
+ case 'list':
718
+ for (const s of hint.sections ?? []) {
719
+ if (s.title)
720
+ out(`── ${s.title}`);
721
+ for (const r of s.rows ?? []) {
722
+ out(`[row] ${r.title}${r.description ? ` — ${r.description}` : ''}`);
723
+ out(` tap: ${r.id}`);
724
+ }
725
+ }
726
+ if (hint.button_text)
727
+ out(`(list button: ${hint.button_text})`);
728
+ break;
729
+ case 'carousel':
730
+ (hint.cards ?? []).forEach((c, i) => {
731
+ out(`[card ${i + 1}] ${c.body}`);
732
+ for (const b of c.buttons ?? []) {
733
+ if (b.type === 'url')
734
+ out(` [link] ${b.title} → ${b.url}`);
735
+ else
736
+ out(` [btn] ${b.title} tap: ${b.id}`);
737
+ }
738
+ });
739
+ break;
740
+ case 'cta_url':
741
+ out(`[link] ${hint.label} → ${hint.url}`);
742
+ break;
743
+ case 'flow':
744
+ out(`[flow form] ${hint.cta_text} (flow_id: ${hint.flow_id})`);
745
+ break;
746
+ default:
747
+ // text / commerce variants — the body line already carries the substance.
748
+ break;
633
749
  }
750
+ if (hint.footer)
751
+ out(`─ ${hint.footer}`);
634
752
  }
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. */
753
+ /** Incremental SSE reader: buffers chunks, yields parsed `{ id, ev }` frames,
754
+ * and supports racing a read against a settle-window timeout WITHOUT losing
755
+ * data (the pending read is kept and re-raced — never issued twice). */
756
+ class SseFrameReader {
757
+ reader;
758
+ buf = '';
759
+ decoder = new TextDecoder();
760
+ pending = null;
761
+ queue = [];
762
+ constructor(reader) {
763
+ this.reader = reader;
764
+ }
765
+ /** Release the underlying stream (the body is locked to our reader). */
766
+ async cancel() {
767
+ try {
768
+ await this.reader.cancel();
769
+ }
770
+ catch { /* already closed */ }
771
+ }
772
+ /** Next frame, or 'timeout' after `ms` of silence, or 'done' when the stream ends. */
773
+ async next(ms) {
774
+ for (;;) {
775
+ const queued = this.queue.shift();
776
+ if (queued)
777
+ return queued;
778
+ this.pending ??= this.reader.read();
779
+ const TIMEOUT = Symbol('timeout');
780
+ const winner = await Promise.race([
781
+ this.pending,
782
+ new Promise((res) => setTimeout(() => res(TIMEOUT), ms).unref?.()),
783
+ ]);
784
+ if (winner === TIMEOUT)
785
+ return 'timeout';
786
+ this.pending = null;
787
+ const { done, value } = winner;
788
+ if (done)
789
+ return 'done';
790
+ this.buf += this.decoder.decode(value, { stream: true });
791
+ let idx;
792
+ while ((idx = this.buf.indexOf('\n\n')) >= 0) {
793
+ const frame = this.buf.slice(0, idx);
794
+ this.buf = this.buf.slice(idx + 2);
795
+ const lines = frame.split('\n');
796
+ const dataLine = lines.find((l) => l.startsWith('data:'));
797
+ if (!dataLine)
798
+ continue; // heartbeat comments etc.
799
+ const idLine = lines.find((l) => l.startsWith('id:'));
800
+ const id = idLine ? parseInt(idLine.slice(3).trim(), 10) : null;
801
+ let ev;
802
+ try {
803
+ ev = JSON.parse(dataLine.slice(5).trim());
804
+ }
805
+ catch {
806
+ continue;
807
+ }
808
+ this.queue.push({ id: Number.isFinite(id) ? id : null, ev });
809
+ }
810
+ }
811
+ }
812
+ }
813
+ const REPLAY_SETTLE_MS = 400; // quiet gap that marks the end of the connect replay burst
814
+ const TURN_SETTLE_MS = 2_000; // quiet gap after a render = the turn finished sending
815
+ const REPLY_TIMEOUT_MS = 45_000; // hard cap waiting for the first render of the turn
816
+ /** `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json]` — drive one
817
+ * turn through the dev web channel and print everything it rendered.
818
+ *
819
+ * Multi-turn works: the platform keeps ONE open conversation per handle, so the
820
+ * same `--as` continues the same conversation. Two traps this command handles:
821
+ * (1) the platform dedups inbound on `local_id` for 5 minutes — so we mint a
822
+ * FRESH id per call (a constant id made consecutive calls silent no-ops);
823
+ * (2) a fresh SSE connect REPLAYS recent history (the ring buffer) — so we
824
+ * drain the replay first and only accept frames newer than it as the reply
825
+ * (naively printing the first render showed LAST turn's message again). */
638
826
  async function cmdChat(flags) {
639
827
  const packDir = resolve(flags.dir ?? '.');
640
828
  const cfg = readPackConfig(packDir);
@@ -646,84 +834,162 @@ async function cmdChat(flags) {
646
834
  if (!tenant)
647
835
  die('no tenant — set it in pack.json, --tenant, or PACK_TENANT');
648
836
  const from = flags.as ?? 'cli-tester';
837
+ const asJson = flags.json === true;
838
+ const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
649
839
  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);
840
+ if (!message && !tapId)
841
+ die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--json]');
842
+ // Fresh idempotency key per call (see the command doc above).
843
+ const localId = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
844
+ const evRes = await fetchOrDie(`${url}/api/web/events/${tenant}/${project}/${encodeURIComponent(from)}`, { headers: { accept: 'text/event-stream' } }, 'open chat stream');
845
+ if (!evRes.ok || !evRes.body)
665
846
  die(`could not open chat stream (HTTP ${evRes.status})`);
666
- return;
847
+ const frames = new SseFrameReader(evRes.body.getReader());
848
+ const cancel = () => frames.cancel();
849
+ // Phase 1 — drain the connect replay; remember the highest sequence id.
850
+ // Frames at or below it are history, not this turn's reply.
851
+ let boundary = 0;
852
+ for (;;) {
853
+ const f = await frames.next(REPLAY_SETTLE_MS);
854
+ if (f === 'timeout')
855
+ break;
856
+ if (f === 'done') {
857
+ await cancel();
858
+ die('chat stream closed before the message was sent');
859
+ }
860
+ if (f.id != null && f.id > boundary)
861
+ boundary = f.id;
667
862
  }
668
- console.log(`→ [${from}] ${message}`);
669
- await fetch(`${url}/api/web/inbound/${tenant}/${project}`, {
863
+ // Phase 2 — send the inbound (text, or an interactive tap).
864
+ console.log(`→ [${from}] ${tapId ? `(tap) ${tapId}` : message}`);
865
+ const body = tapId
866
+ ? { type: 'interactive', from, tap_id: tapId, ...(message ? { raw_title: message } : {}), local_id: localId }
867
+ : { type: 'text', from, text: message, local_id: localId };
868
+ const postRes = await fetchOrDie(`${url}/api/web/inbound/${tenant}/${project}`, {
670
869
  method: 'POST',
671
870
  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)
871
+ body: JSON.stringify(body),
872
+ }, 'send message');
873
+ if (!postRes.ok) {
874
+ await cancel();
875
+ die(`send rejected (HTTP ${postRes.status}): ${await postRes.text()}`);
876
+ }
877
+ // Phase 3 — collect THIS turn's renders (id > boundary). A turn can send
878
+ // several messages, so keep reading until a quiet gap after the last render.
879
+ const deadline = Date.now() + REPLY_TIMEOUT_MS;
880
+ let rendersSeen = 0;
881
+ for (;;) {
882
+ const remaining = deadline - Date.now();
883
+ if (remaining <= 0)
884
+ break;
885
+ const f = await frames.next(rendersSeen > 0 ? TURN_SETTLE_MS : Math.min(remaining, REPLY_TIMEOUT_MS));
886
+ if (f === 'timeout') {
887
+ if (rendersSeen > 0)
682
888
  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
- }
889
+ else
890
+ continue;
709
891
  }
892
+ if (f === 'done')
893
+ break;
894
+ if (f.id != null && f.id <= boundary)
895
+ continue; // late replay stragglers
896
+ if (asJson) {
897
+ console.log(JSON.stringify(f.ev));
898
+ if (f.ev?.kind === 'render')
899
+ rendersSeen++;
900
+ continue;
901
+ }
902
+ if (f.ev?.kind !== 'render')
903
+ continue; // status/typing noise
904
+ rendersSeen++;
905
+ console.log(`← ${f.ev.body ?? '(no text body)'}${f.ev.hint?.type && f.ev.hint.type !== 'text' ? ` (render: ${f.ev.hint.type})` : ''}`);
906
+ printHint(f.ev.hint);
710
907
  }
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})`);
908
+ await cancel();
909
+ if (rendersSeen === 0) {
910
+ 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.`);
911
+ process.exit(1);
716
912
  }
717
- finally {
718
- clearTimeout(timer);
719
- try {
720
- await reader.cancel();
913
+ console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
914
+ }
915
+ /** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
916
+ * the aggregate inbox, one case + its timeline, or the queue list. */
917
+ async function cmdCases(flags) {
918
+ const packDir = resolve(flags.dir ?? '.');
919
+ const { url, tenant, project, token } = resolveTarget(flags, packDir);
920
+ const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
921
+ const caseId = flags._[0];
922
+ const asJson = flags.json === true;
923
+ const caseFail = (what, status, json) => {
924
+ if (status === 403)
925
+ die(`forbidden — casework needs the 'cases' plan feature on this tenant, and a role whose grants reach the queue`);
926
+ die(`could not read ${what} (HTTP ${status})${errDetail(json)}${status === 401 ? ` — ${authFailureHint(status, url)}` : ''}`);
927
+ };
928
+ if (flags.queues === true) {
929
+ const { status, json } = await apiGet(`${base}/case-queues`, token);
930
+ if (status !== 200)
931
+ caseFail('case queues', status, json);
932
+ if (asJson) {
933
+ console.log(JSON.stringify(json, null, 2));
934
+ return;
721
935
  }
722
- catch { /* ignore */ }
936
+ const queues = (json?.queues ?? []);
937
+ console.log(`Case queues in ${tenant}/${project}:`);
938
+ for (const q of queues)
939
+ console.log(` ${q.key}${q.name ? ` (${q.name})` : ''} ${q.open_count} open`);
940
+ if (json?.unrouted_open_count)
941
+ console.log(` (unrouted: ${json.unrouted_open_count} open)`);
942
+ return;
723
943
  }
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);
944
+ if (!caseId) {
945
+ const limit = flags.limit ?? '50';
946
+ const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, token);
947
+ if (status !== 200)
948
+ caseFail('cases', status, json);
949
+ if (asJson) {
950
+ console.log(JSON.stringify(json, null, 2));
951
+ return;
952
+ }
953
+ const rows = (json?.cases ?? []);
954
+ console.log(`Cases in ${tenant}/${project}: ${json?.total ?? rows.length} total`);
955
+ if (rows.length === 0)
956
+ console.log(' (none)');
957
+ for (const c of rows) {
958
+ const sla = c.sla_due_at ? ` sla:${c.sla_due_at}` : '';
959
+ console.log(` #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}${c.queue_key ? ` q:${c.queue_key}` : ''}${sla} ${c.id}`);
960
+ }
961
+ console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
962
+ return;
963
+ }
964
+ const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, token);
965
+ if (status === 404)
966
+ die(`case '${caseId}' not found`);
967
+ if (status !== 200)
968
+ caseFail('case', status, json);
969
+ if (asJson) {
970
+ console.log(JSON.stringify(json, null, 2));
971
+ return;
972
+ }
973
+ const c = json?.case ?? {};
974
+ console.log(`Case #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}`);
975
+ console.log(` id: ${c.id} queue: ${c.queue_key ?? '(unrouted)'} assignee: ${c.assignee_principal ?? '(none)'}`);
976
+ if (json?.contact)
977
+ console.log(` contact: ${json.contact.display_name ?? json.contact.channel_contact_handle ?? json.contact.id}`);
978
+ if (c.conversation_id)
979
+ console.log(` conversation: ${c.conversation_id} (octwin logs ${c.conversation_id})`);
980
+ if (c.sla_due_at)
981
+ console.log(` sla due: ${c.sla_due_at}`);
982
+ if (c.fields && Object.keys(c.fields).length > 0)
983
+ console.log(` fields: ${JSON.stringify(c.fields)}`);
984
+ const events = (json?.events ?? []);
985
+ console.log(` Timeline (${events.length}):`);
986
+ for (const e of events) {
987
+ const payload = e.payload && Object.keys(e.payload).length > 0 ? ` ${JSON.stringify(e.payload)}` : '';
988
+ console.log(` ${e.ts ?? ''} ${e.kind}${e.actor ? ` (${e.actor})` : ''}${payload}`);
989
+ }
990
+ const dispositions = (json?.dispositions ?? []);
991
+ if (dispositions.length > 0) {
992
+ console.log(` Decisions: ${dispositions.map((d) => `${d.action}${d.next_status ? `→${d.next_status}` : ''}`).join(', ')}`);
727
993
  }
728
994
  }
729
995
  function help() {
@@ -737,18 +1003,67 @@ function help() {
737
1003
  octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
738
1004
  octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
739
1005
  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
1006
+ octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
1007
+ octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
1008
+ octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json] # drive a turn + print every render
742
1009
  octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
743
1010
  octwin test [--dir .] # = validate --remote (the full platform check)
744
1011
 
1012
+ Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
1013
+ \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
1014
+ button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
745
1015
  Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
746
1016
  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.`);
1017
+ Config (deploy): flags > pack.json > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login.
1018
+ Per-command usage: octwin <command> --help`);
748
1019
  }
1020
+ /** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
1021
+ * network/auth work (a --help that 401s is worse than no help at all). */
1022
+ const COMMAND_HELP = {
1023
+ init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
1024
+ Scaffold a pure-YAML starter pack into <dir>.`,
1025
+ validate: `octwin validate [--dir .] [--remote]
1026
+ Offline structural check; --remote additionally runs the platform's FULL
1027
+ manifest + flow-DSL validation (all errors at once) — same check as deploy.`,
1028
+ login: `octwin login --url <platformUrl> --token oct_…
1029
+ Save a deploy token (console → Settings → API tokens) for that platform url.`,
1030
+ whoami: `octwin whoami [--url <url>] [--tenant <slug>]
1031
+ Verify the resolved token authenticates against the tenant.`,
1032
+ deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
1033
+ Upload the pack bundle, validate server-side, install onto the project.
1034
+ --seed additionally applies the pack's demo seed (streams progress).`,
1035
+ status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
1036
+ Show installed vs live version + the flow list for this pack.`,
1037
+ records: `octwin records [entity] [id] [--limit 50]
1038
+ Inspect the pack's XRM data. No args = list entities. Cases/tickets are
1039
+ casework, not XRM — use \`octwin cases\` for those.`,
1040
+ cases: `octwin cases [caseId] [--queues] [--limit 50] [--json]
1041
+ Inspect casework (support tickets): the inbox, one case + its timeline
1042
+ (+ applicable decisions), or --queues for queue keys + open counts.`,
1043
+ logs: `octwin logs [conversationId] [--as <handle>] [--json]
1044
+ No id = recent conversations (handle, status, last activity; --as filters).
1045
+ With id = the full event timeline including what each turn rendered.
1046
+ --json = raw events (verbatim payloads).`,
1047
+ chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json]
1048
+ Drive one turn through the dev web channel and print every render with its
1049
+ tap ids. Same --as handle = same conversation (multi-turn works).
1050
+ --tap presses a rendered button/list row instead of sending text.
1051
+ --json dumps the raw SSE envelopes for the turn.`,
1052
+ 'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1053
+ Pull the platform capability reference (markdown + JSON catalogs) into
1054
+ .octwin/platform-kb/ for the octwin-pack authoring skill.`,
1055
+ test: `octwin test [--dir .]
1056
+ Alias for \`octwin validate --remote\` — the full platform check.`,
1057
+ };
749
1058
  async function main() {
750
1059
  const [command, ...rest] = process.argv.slice(2);
751
1060
  const flags = parseFlags(rest);
1061
+ // Per-subcommand --help/-h — intercepted BEFORE the command runs, so help can
1062
+ // never hit the network or die on auth (author-feedback A8).
1063
+ if (command && command in COMMAND_HELP && (flags.help === true || flags._.includes('-h'))) {
1064
+ console.log(COMMAND_HELP[command]);
1065
+ return;
1066
+ }
752
1067
  switch (command) {
753
1068
  case 'init':
754
1069
  cmdInit(flags);
@@ -771,6 +1086,9 @@ async function main() {
771
1086
  case 'records':
772
1087
  await cmdRecords(flags);
773
1088
  break;
1089
+ case 'cases':
1090
+ await cmdCases(flags);
1091
+ break;
774
1092
  case 'logs':
775
1093
  await cmdLogs(flags);
776
1094
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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": {