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