octwin-cli 0.1.7 → 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 +432 -103
  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. */
@@ -177,11 +210,22 @@ async function latestPublishedVersion() {
177
210
  return null;
178
211
  }
179
212
  }
213
+ /** True when running via `npx` — the CLI's own file lives in npx's cache dir, or npm
214
+ * ran it as `npm exec`. Under npx there is no persistent install to upgrade
215
+ * (`@latest` already resolves the newest), so an upgrade notice would be
216
+ * misleading — stay silent. The notice is for a GLOBAL install (`npm i -g`). */
217
+ function isNpx() {
218
+ try {
219
+ return fileURLToPath(import.meta.url).includes('_npx') || process.env.npm_command === 'exec';
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ }
180
225
  /** Print a one-line upgrade notice (to stderr) when a newer octwin-cli is published.
181
- * Skipped when not attached to a TTY (CI / piped) so it never adds noise to scripts.
182
- * Never throws. */
226
+ * Skipped when piped/CI (not a TTY) or run via npx (nothing to upgrade). Never throws. */
183
227
  async function notifyIfOutdated() {
184
- if (!process.stdout.isTTY)
228
+ if (!process.stdout.isTTY || isNpx())
185
229
  return;
186
230
  try {
187
231
  const latest = await latestPublishedVersion();
@@ -257,11 +301,11 @@ async function cmdValidate(flags) {
257
301
  // every flow (schema/expression/structure) — returning ALL errors at once.
258
302
  const { url, tenant, project, token } = resolveTarget(flags, packDir);
259
303
  console.log(`→ Validating against ${tenant}/${project} @ ${url} …`);
260
- 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`, {
261
305
  method: 'POST',
262
306
  headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
263
307
  body: JSON.stringify({ files }),
264
- });
308
+ }, 'remote validate');
265
309
  const text = await res.text();
266
310
  let json;
267
311
  try {
@@ -323,7 +367,7 @@ function resolveTarget(flags, packDir) {
323
367
  async function cmdWhoami(flags) {
324
368
  const packDir = resolve(flags.dir ?? '.');
325
369
  const { url, tenant, token } = resolveTarget(flags, packDir);
326
- 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');
327
371
  if (res.ok) {
328
372
  console.log(`✓ Token valid for tenant '${tenant}' at ${url} (${token.startsWith('oct_') ? 'deploy token' : 'session token'})`);
329
373
  return;
@@ -400,13 +444,13 @@ async function cmdDeploy(flags) {
400
444
  const endpoint = `${url}/api/admin/tenants/${tenant}/projects/${project}/packs/deploy`;
401
445
  const seed = flags.seed === true;
402
446
  console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${tenant}/${project}${seed ? ' — with demo seed' : ''} …`);
403
- const res = await fetch(endpoint, {
447
+ const res = await fetchOrDie(endpoint, {
404
448
  method: 'POST',
405
449
  // Ask for a progress stream; the platform falls back to plain JSON if it
406
450
  // (or an error before any progress) can't stream — handled below.
407
451
  headers: { 'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${token}` },
408
452
  body: JSON.stringify({ files, seed }),
409
- });
453
+ }, 'deploy');
410
454
  // Streaming path — live install + seed progress (image generation can take a
411
455
  // while, so `--seed` prints per-record / per-image lines as they happen).
412
456
  if (res.ok && (res.headers.get('content-type') ?? '').includes('text/event-stream') && res.body) {
@@ -444,9 +488,9 @@ async function cmdStatus(flags) {
444
488
  die('manifest.yaml must declare a string `id`');
445
489
  const id = doc.id;
446
490
  const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
447
- 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`, {
448
492
  headers: { authorization: `Bearer ${token}` },
449
- });
493
+ }, 'status check');
450
494
  const text = await res.text();
451
495
  let json;
452
496
  try {
@@ -482,9 +526,9 @@ async function cmdStatus(flags) {
482
526
  async function cmdPlatformKb(flags) {
483
527
  const packDir = resolve(flags.dir ?? '.');
484
528
  const { url, tenant, token } = resolveTarget(flags, packDir);
485
- 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`, {
486
530
  headers: { authorization: `Bearer ${token}` },
487
- });
531
+ }, 'platform-kb pull');
488
532
  const text = await res.text();
489
533
  if (!res.ok) {
490
534
  let j;
@@ -522,10 +566,12 @@ async function cmdPlatformKb(flags) {
522
566
  console.log(` ${mdCount} markdown docs + ${jsonCount} JSON catalogs (reference version ${bundle.version ?? '?'})`);
523
567
  console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
524
568
  }
525
- // ── records / logs / chat — headless inspect + test with the deploy token ────
526
- /** 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`. */
527
573
  async function apiGet(endpoint, token) {
528
- const res = await fetch(endpoint, { headers: { authorization: `Bearer ${token}` } });
574
+ const res = await fetchOrDie(endpoint, { headers: { authorization: `Bearer ${token}` } }, 'request');
529
575
  const text = await res.text();
530
576
  let json;
531
577
  try {
@@ -567,8 +613,14 @@ async function cmdRecords(flags) {
567
613
  const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, token);
568
614
  if (status === 403)
569
615
  die('forbidden — the paged record list needs the `records` plan feature on this tenant');
570
- if (status !== 200)
571
- 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
+ }
572
624
  const rows = (json?.records ?? []);
573
625
  console.log(`${entity}: ${json?.total ?? rows.length} record(s)`);
574
626
  if (rows.length === 0)
@@ -586,44 +638,191 @@ async function cmdRecords(flags) {
586
638
  die(`could not read record (HTTP ${status})`);
587
639
  console.log(JSON.stringify(json?.record ?? json, null, 2));
588
640
  }
589
- /** `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). */
590
643
  async function cmdLogs(flags) {
591
644
  const packDir = resolve(flags.dir ?? '.');
592
645
  const { url, tenant, project, token } = resolveTarget(flags, packDir);
593
646
  const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
594
647
  const convId = flags._[0];
648
+ const asJson = flags.json === true;
649
+ const asHandle = typeof flags.as === 'string' ? flags.as : undefined;
595
650
  if (!convId) {
596
- const { status, json } = await apiGet(`${base}/conversations?limit=20`, token);
651
+ const { status, json } = await apiGet(`${base}/conversations?limit=50`, token);
597
652
  if (status !== 200)
598
- die(`could not read conversations (HTTP ${status})`);
599
- 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);
600
657
  if (convs.length === 0) {
601
- 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.');
602
659
  return;
603
660
  }
604
- console.log(`Recent conversations in ${tenant}/${project}:`);
605
- for (const c of convs)
606
- console.log(` ${c.id} ${c.contact?.display_name ?? c.contact?.channel_contact_handle ?? '?'} [${c.status}]`);
607
- 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)');
608
673
  return;
609
674
  }
610
675
  const { status, json } = await apiGet(`${base}/conversations/${encodeURIComponent(convId)}`, token);
611
676
  if (status === 404)
612
677
  die(`conversation '${convId}' not found`);
613
678
  if (status !== 200)
614
- die(`could not read conversation (HTTP ${status})`);
679
+ die(`could not read conversation (HTTP ${status})${errDetail(json)} — ${authFailureHint(status, url)}`);
615
680
  const events = (json?.events ?? []);
681
+ if (asJson) {
682
+ console.log(JSON.stringify(events, null, 2));
683
+ return;
684
+ }
616
685
  console.log(`Timeline for ${convId} (${events.length} events):`);
617
686
  for (const e of events) {
618
687
  const isErr = e.type === 'tool' && e.subtype === 'platform_error';
619
688
  const tag = isErr ? '⚠ ERROR' : `${e.type}${e.subtype ? `/${e.subtype}` : ''}`;
620
689
  const body = typeof e.content === 'string' ? e.content : JSON.stringify(e.content ?? '');
621
- 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
+ }
622
700
  }
701
+ console.log('\nVerbatim LLM-thread payloads: --json, or GET …/conversations/<id>/mastra-messages');
623
702
  }
624
- /** `octwin chat "message" [--as <handle>]` drive ONE turn through the dev web
625
- * channel and print the agent's reply. The web channel is unauthenticated, so no
626
- * token is required here. */
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;
749
+ }
750
+ if (hint.footer)
751
+ out(`─ ${hint.footer}`);
752
+ }
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). */
627
826
  async function cmdChat(flags) {
628
827
  const packDir = resolve(flags.dir ?? '.');
629
828
  const cfg = readPackConfig(packDir);
@@ -635,84 +834,162 @@ async function cmdChat(flags) {
635
834
  if (!tenant)
636
835
  die('no tenant — set it in pack.json, --tenant, or PACK_TENANT');
637
836
  const from = flags.as ?? 'cli-tester';
837
+ const asJson = flags.json === true;
838
+ const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
638
839
  const message = flags._[0];
639
- if (!message)
640
- die('usage: octwin chat "your message" [--as <handle>]');
641
- const ctrl = new AbortController();
642
- const timer = setTimeout(() => ctrl.abort(), 30_000);
643
- let evRes;
644
- try {
645
- evRes = await fetch(`${url}/api/web/events/${tenant}/${project}/${encodeURIComponent(from)}`, { headers: { accept: 'text/event-stream' }, signal: ctrl.signal });
646
- }
647
- catch (err) {
648
- clearTimeout(timer);
649
- die(`could not open chat stream: ${err?.message ?? err}`);
650
- return;
651
- }
652
- if (!evRes.ok || !evRes.body) {
653
- 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)
654
846
  die(`could not open chat stream (HTTP ${evRes.status})`);
655
- 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;
656
862
  }
657
- console.log(`→ [${from}] ${message}`);
658
- 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}`, {
659
869
  method: 'POST',
660
870
  headers: { 'content-type': 'application/json' },
661
- body: JSON.stringify({ type: 'text', from, text: message, local_id: `cli-${from}` }),
662
- }).catch((err) => { clearTimeout(timer); die(`could not send message: ${err?.message ?? err}`); });
663
- const reader = evRes.body.getReader();
664
- const decoder = new TextDecoder();
665
- let buf = '';
666
- let gotReply = false;
667
- try {
668
- outer: for (;;) {
669
- const { done, value } = await reader.read();
670
- 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)
671
888
  break;
672
- buf += decoder.decode(value, { stream: true });
673
- let idx;
674
- while ((idx = buf.indexOf('\n\n')) >= 0) {
675
- const frame = buf.slice(0, idx);
676
- buf = buf.slice(idx + 2);
677
- const line = frame.split('\n').find((l) => l.startsWith('data:'));
678
- if (!line)
679
- continue;
680
- let ev;
681
- try {
682
- ev = JSON.parse(line.slice(5).trim());
683
- }
684
- catch {
685
- continue;
686
- }
687
- if (ev.kind === 'render') {
688
- console.log(`← ${ev.body ?? '(no text body)'}`);
689
- const hint = ev.hint;
690
- if (hint?.buttons?.length)
691
- console.log(` [buttons: ${hint.buttons.map((b) => b.title).join(' | ')}]`);
692
- if (hint?.sections?.length || hint?.rows?.length)
693
- console.log(' [a list picker was rendered]');
694
- gotReply = true;
695
- break outer;
696
- }
697
- }
889
+ else
890
+ continue;
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;
698
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);
699
907
  }
700
- catch (err) {
701
- if (ctrl.signal.aborted)
702
- console.error(' (timed out after 30s waiting for a reply)');
703
- else
704
- 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);
705
912
  }
706
- finally {
707
- clearTimeout(timer);
708
- try {
709
- 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;
710
935
  }
711
- 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;
712
943
  }
713
- if (!gotReply) {
714
- console.error(' no reply the pack may not be warm yet, or the turn produced no render.');
715
- 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(', ')}`);
716
993
  }
717
994
  }
718
995
  function help() {
@@ -726,18 +1003,67 @@ function help() {
726
1003
  octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
727
1004
  octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
728
1005
  octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
729
- octwin logs [conversationId] # list conversations / show one's event timeline
730
- 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
731
1009
  octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
732
1010
  octwin test [--dir .] # = validate --remote (the full platform check)
733
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).
734
1015
  Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
735
1016
  octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
736
- 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`);
737
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
+ };
738
1058
  async function main() {
739
1059
  const [command, ...rest] = process.argv.slice(2);
740
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
+ }
741
1067
  switch (command) {
742
1068
  case 'init':
743
1069
  cmdInit(flags);
@@ -760,6 +1086,9 @@ async function main() {
760
1086
  case 'records':
761
1087
  await cmdRecords(flags);
762
1088
  break;
1089
+ case 'cases':
1090
+ await cmdCases(flags);
1091
+ break;
763
1092
  case 'logs':
764
1093
  await cmdLogs(flags);
765
1094
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.1.7",
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": {