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.
- package/README.md +19 -2
- package/dist/index.js +418 -100
- 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` |
|
|
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
|
|
19
|
-
* octwin
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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}: ${
|
|
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
|
-
/**
|
|
636
|
-
*
|
|
637
|
-
*
|
|
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
|
-
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
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(
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
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 (!
|
|
725
|
-
|
|
726
|
-
|
|
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
|
|
741
|
-
octwin
|
|
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