octwin-cli 0.1.11 → 0.1.13

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/CHANGELOG.md +38 -0
  2. package/dist/index.js +230 -28
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucket
5
5
  **Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
6
6
  repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
7
7
 
8
+ ## [0.1.13] - 2026-07-22
9
+
10
+ ### Added
11
+ - **A headless media loop — produce + send.** Two paired additions close the coverage hole where any
12
+ media-collect flow (e.g. `activate-app`'s registration / ID uploads) stalled at the upload prompt
13
+ because the CLI could neither *make* nor *send* an image:
14
+ - `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]` — AI-generates an
15
+ image on the platform (needs a `media:generate`-scoped token), stores it as a public asset, and prints
16
+ its `MEDIA-` handle + serve URL. `--out` downloads the bytes to a file (WhatsApp renders only
17
+ `.png`/`.jpg`); `--json` emits `{ media_id, url, mime, width, height, bytes }`.
18
+ - `octwin chat --media <file|media-id>` — uploads a local file (or a media id from
19
+ `media generate --json`) as an image/document/audio inbound; any accompanying `"message"` rides as
20
+ its caption. The platform's media pipeline folds the upload into a running collect, so media flows
21
+ are now fully drivable headlessly.
22
+
23
+ ### Changed
24
+ - **The KB-drift nudge now names what changed.** With the platform serving per-entry content hashes, the
25
+ post-command nudge appends a `(N changed · M added · K removed)` summary instead of a bare hash pair.
26
+ - **`octwin platform-kb` prints a changelog on pull.** Instead of only a doc/catalog count, the pull now
27
+ diffs the fresh index against your last pull and lists exactly which docs/catalogs were added (`+`),
28
+ changed (`~`), or removed (`-`) — so a replaced schema shape is visible, not silent.
29
+
30
+ ## [0.1.12] - 2026-07-22
31
+
32
+ ### Fixed
33
+ - **The KB-drift nudge now fires after `chat` too.** `chat` — the command a debugging session runs most —
34
+ was missing from the networked-command list, so a chat-heavy session never noticed the platform's
35
+ capability reference had moved. The nudge also now shows the concrete drift
36
+ (`old-hash → new-hash`) so you can see it's real, not a heuristic.
37
+
38
+ ### Changed
39
+ - **Every networked command announces what it's doing before it does it.** `chat` prints
40
+ `→ Connecting to <tenant>/<project> as '<handle>' …` before opening the stream and
41
+ `… delivered — waiting for the reply (up to Ns)` after the send; `status` / `whoami` /
42
+ `platform-kb` / `records` / `logs` / `cases` each print a one-line `→ …` header naming the
43
+ action and target before the first network call — no more silent seconds followed by a result
44
+ (or a hang with no clue what was being attempted). JSON modes (`--json`) stay clean for piping.
45
+
8
46
  ## [0.1.11] - 2026-07-21
9
47
 
10
48
  ### Fixed
package/dist/index.js CHANGED
@@ -17,7 +17,8 @@
17
17
  * octwin records [entity] [id] # inspect the pack's XRM data (records:read token)
18
18
  * octwin cases [caseId] [--queues] # inspect casework (support tickets) — list / one case + timeline
19
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
+ * octwin chat "msg" [--as h] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn via the web channel (+ send media)
21
+ * octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (media:generate scope)
21
22
  * octwin platform-kb [pull] [--dir .] # pull the platform capability reference for the authoring skill
22
23
  * octwin test [--dir .] # = validate --remote (the full platform check)
23
24
  *
@@ -35,7 +36,7 @@
35
36
  * (pure-YAML enforcement + manifest/flow Zod) and installs it onto the project.
36
37
  */
37
38
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, cpSync } from 'node:fs';
38
- import { join, resolve, dirname } from 'node:path';
39
+ import { join, resolve, dirname, basename } from 'node:path';
39
40
  import { homedir } from 'node:os';
40
41
  import { fileURLToPath } from 'node:url';
41
42
  import { parse as parseYaml } from 'yaml';
@@ -112,6 +113,50 @@ function errDetail(json) {
112
113
  const msg = typeof json === 'string' ? json : (json.error ?? json.message ?? JSON.stringify(json));
113
114
  return msg ? ` — ${msg}` : '';
114
115
  }
116
+ // ── media helpers (produce/send loop) ───────────────────────────────────────
117
+ /** Extension → MIME for local files the `chat --media` sender uploads (mirrors
118
+ * the platform's accepted image/document/audio MIME sets). */
119
+ const MEDIA_MIME_BY_EXT = {
120
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
121
+ '.pdf': 'application/pdf', '.txt': 'text/plain',
122
+ '.doc': 'application/msword',
123
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
124
+ '.ogg': 'audio/ogg', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.webm': 'audio/webm', '.aac': 'audio/aac',
125
+ };
126
+ /** MIME → file extension (for naming a fetched-by-id media part). */
127
+ const EXT_BY_MEDIA_MIME = {
128
+ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf',
129
+ };
130
+ /** The inbound `type` the platform expects for a MIME (image/document/audio). */
131
+ function mediaKindOf(mime) {
132
+ if (mime.startsWith('image/'))
133
+ return 'image';
134
+ if (mime.startsWith('audio/'))
135
+ return 'audio';
136
+ return 'document';
137
+ }
138
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
139
+ /** Resolve a `--media` argument to an uploadable part: a local file path, OR a
140
+ * media UUID (as returned by `octwin media generate --json`) fetched from the
141
+ * public serve route. The cosmetic `MEDIA-…` handle is NOT fetchable (the serve
142
+ * route is UUID-keyed) — use the file written by `--out`, or the `media_id`. */
143
+ async function resolveMediaPart(url, arg) {
144
+ if (existsSync(arg)) {
145
+ const buffer = readFileSync(arg);
146
+ const ext = arg.slice(arg.lastIndexOf('.')).toLowerCase();
147
+ const mime = MEDIA_MIME_BY_EXT[ext] ?? 'application/octet-stream';
148
+ return { blob: new Blob([buffer], { type: mime }), kind: mediaKindOf(mime), filename: basename(arg) };
149
+ }
150
+ if (UUID_RE.test(arg)) {
151
+ const res = await fetchOrDie(`${url}/api/media/${arg}`, undefined, 'fetch media');
152
+ if (!res.ok)
153
+ die(`--media '${arg}' not found (HTTP ${res.status}) — pass a local file path, or a media id from 'octwin media generate --json'`);
154
+ const mime = res.headers.get('content-type') ?? 'application/octet-stream';
155
+ const buffer = Buffer.from(await res.arrayBuffer());
156
+ return { blob: new Blob([buffer], { type: mime }), kind: mediaKindOf(mime), filename: `${arg}.${EXT_BY_MEDIA_MIME[mime] ?? 'bin'}` };
157
+ }
158
+ die(`--media '${arg}' is neither an existing file nor a media id (UUID). The cosmetic MEDIA-… handle isn't fetchable — send the file written by 'octwin media generate --out', or pass the media_id from '--json'.`);
159
+ }
115
160
  // ── bundle collection ───────────────────────────────────────────────────────
116
161
  const SKIP_DIRS = new Set(['.git', 'node_modules', '.pack-bundles', 'dist', '.mastra']);
117
162
  /** Collect every text file under `packDir` into a `{ relPath: content }` map. */
@@ -243,18 +288,34 @@ async function notifyIfOutdated() {
243
288
  }
244
289
  catch { /* a version check must never break the CLI */ }
245
290
  }
246
- // ── platform-KB drift check (observe the pulled reference, TTY-only, fail-silent) ──
247
- /** The `content_hash` of a previously-pulled KB in `<packDir>/.octwin/platform-kb/`,
248
- * or null if nothing has been pulled yet. */
249
- function readLocalKbHash(packDir) {
291
+ /** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
292
+ * (content hash + per-entry index), or null if nothing has been pulled yet. */
293
+ function readLocalKb(packDir) {
250
294
  try {
251
295
  const idx = JSON.parse(readFileSync(join(packDir, '.octwin', 'platform-kb', 'index.json'), 'utf8'));
252
- return typeof idx.content_hash === 'string' ? idx.content_hash : null;
296
+ return {
297
+ content_hash: typeof idx.content_hash === 'string' ? idx.content_hash : null,
298
+ index: Array.isArray(idx.index) ? idx.index : [],
299
+ };
253
300
  }
254
301
  catch {
255
302
  return null;
256
303
  }
257
304
  }
305
+ /** Diff two KB indexes by key + per-entry `hash` → added / removed / changed keys. */
306
+ function diffKbIndex(prev, next) {
307
+ const prevMap = new Map(prev.map(e => [e.key, e.hash]));
308
+ const nextKeys = new Set(next.map(e => e.key));
309
+ const added = [], changed = [];
310
+ for (const e of next) {
311
+ if (!prevMap.has(e.key))
312
+ added.push(e.key);
313
+ else if (prevMap.get(e.key) !== e.hash)
314
+ changed.push(e.key);
315
+ }
316
+ const removed = prev.filter(e => !nextKeys.has(e.key)).map(e => e.key);
317
+ return { added, removed, changed };
318
+ }
258
319
  /** Nudge (to stderr) when the platform's capability KB has changed since the last
259
320
  * `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
260
321
  * of the CLI: run only after commands that already hit the platform, so this adds
@@ -266,8 +327,8 @@ async function notifyIfKbStale(flags) {
266
327
  return;
267
328
  try {
268
329
  const packDir = resolve(flags.dir ?? '.');
269
- const localHash = readLocalKbHash(packDir);
270
- if (!localHash)
330
+ const local = readLocalKb(packDir);
331
+ if (!local?.content_hash)
271
332
  return; // never pulled → the skill already says to pull
272
333
  const t = resolveTargetOrNull(flags, packDir);
273
334
  if (!t)
@@ -281,21 +342,37 @@ async function notifyIfKbStale(flags) {
281
342
  if (!res.ok)
282
343
  return;
283
344
  const meta = await res.json();
284
- if (meta.content_hash && meta.content_hash !== localHash) {
285
- console.error('\n⬆ the platform capability reference changed since you last pulled it.');
286
- console.error(' Refresh it: octwin platform-kb pull');
345
+ if (meta.content_hash && meta.content_hash !== local.content_hash) {
346
+ // Per-entry summary (now that the index carries per-entry hashes) — the
347
+ // exact list of what changed is one `octwin platform-kb` away.
348
+ let summary = '';
349
+ if (Array.isArray(meta.index) && local.index.length) {
350
+ const { added, removed, changed } = diffKbIndex(local.index, meta.index);
351
+ const parts = [
352
+ changed.length && `${changed.length} changed`,
353
+ added.length && `${added.length} added`,
354
+ removed.length && `${removed.length} removed`,
355
+ ].filter(Boolean);
356
+ if (parts.length)
357
+ summary = ` (${parts.join(' · ')})`;
358
+ }
359
+ console.error(`\n⬆ the platform capability reference changed since you last pulled it${summary}.`);
360
+ console.error(' Refresh it: octwin platform-kb');
287
361
  }
288
362
  }
289
363
  catch { /* a KB check must never break the CLI */ }
290
364
  }
291
- /** Which commands already made an authed platform call, so the trailing KB-drift
292
- * poll rides on existing network work (never on offline `validate` / `init`). */
365
+ /** Which commands already made a platform call, so the trailing KB-drift poll
366
+ * rides on existing network work (never on offline `validate` / `init`;
367
+ * `platform-kb` refreshes the reference itself, so it needs no nudge). */
293
368
  function commandTouchesPlatform(command, flags) {
294
369
  switch (command) {
295
370
  case 'validate': return flags.remote === true; // offline validate stays offline
296
371
  case 'deploy':
297
372
  case 'status':
298
373
  case 'test':
374
+ case 'chat':
375
+ case 'media':
299
376
  case 'records':
300
377
  case 'cases':
301
378
  case 'logs':
@@ -448,6 +525,7 @@ function resolveTargetOrNull(flags, packDir) {
448
525
  async function cmdWhoami(flags) {
449
526
  const packDir = resolve(flags.dir ?? '.');
450
527
  const { url, tenant, token } = resolveTarget(flags, packDir);
528
+ console.log(`→ Checking the saved token against '${tenant}' @ ${url} …`);
451
529
  const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/packs`, { headers: { authorization: `Bearer ${token}` } }, 'token check');
452
530
  if (res.ok) {
453
531
  console.log(`✓ Token valid for tenant '${tenant}' at ${url} (${token.startsWith('oct_') ? 'deploy token' : 'session token'})`);
@@ -570,6 +648,7 @@ async function cmdStatus(flags) {
570
648
  die('manifest.yaml must declare a string `id`');
571
649
  const id = doc.id;
572
650
  const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
651
+ console.log(`→ Checking ${id}@${localVersion} on ${tenant}/${project} @ ${url} …`);
573
652
  const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/${id}/runtime`, {
574
653
  headers: { authorization: `Bearer ${token}` },
575
654
  }, 'status check');
@@ -609,6 +688,7 @@ async function cmdStatus(flags) {
609
688
  async function cmdPlatformKb(flags) {
610
689
  const packDir = resolve(flags.dir ?? '.');
611
690
  const { url, tenant, token } = resolveTarget(flags, packDir);
691
+ console.log(`→ Pulling the platform capability reference from '${tenant}' @ ${url} …`);
612
692
  const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/octwin-platform-kb`, {
613
693
  headers: { authorization: `Bearer ${token}` },
614
694
  }, 'platform-kb pull');
@@ -626,6 +706,9 @@ async function cmdPlatformKb(flags) {
626
706
  process.exit(1);
627
707
  }
628
708
  const bundle = JSON.parse(text);
709
+ // Snapshot the prior pull's index BEFORE overwriting it, so we can show the
710
+ // author EXACTLY what changed (not just a count).
711
+ const prior = readLocalKb(packDir);
629
712
  // Write the reference into <packDir>/.octwin/platform-kb/ — markdown docs (the
630
713
  // skill reads these first) + JSON catalogs (precise field schemas). Gitignored.
631
714
  const outDir = join(packDir, '.octwin', 'platform-kb');
@@ -649,6 +732,23 @@ async function cmdPlatformKb(flags) {
649
732
  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');
650
733
  console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
651
734
  console.log(` ${mdCount} markdown docs + ${jsonCount} JSON catalogs (reference version ${bundle.version ?? '?'})`);
735
+ // Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
736
+ // moved (a schema shape being replaced shows as a `~ changed`), not just a count.
737
+ if (prior?.content_hash) {
738
+ if (prior.content_hash === bundle.content_hash) {
739
+ console.log(' (no content changes since your last pull)');
740
+ }
741
+ else {
742
+ const { added, removed, changed } = diffKbIndex(prior.index, bundle.index ?? []);
743
+ console.log(` Δ changes since your last pull (${prior.content_hash} → ${bundle.content_hash ?? '?'}):`);
744
+ for (const k of added)
745
+ console.log(` + ${k} (new)`);
746
+ for (const k of changed)
747
+ console.log(` ~ ${k} (changed)`);
748
+ for (const k of removed)
749
+ console.log(` - ${k} (removed)`);
750
+ }
751
+ }
652
752
  console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
653
753
  }
654
754
  // ── records / cases / logs / chat — headless inspect + test with the deploy token ────
@@ -674,6 +774,7 @@ async function cmdRecords(flags) {
674
774
  const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
675
775
  const entity = flags._[0];
676
776
  const recordId = flags._[1];
777
+ console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${tenant}/${project} …`);
677
778
  if (!entity) {
678
779
  const { status, json } = await apiGet(`${base}/xrm/entities`, token);
679
780
  if (status !== 200)
@@ -734,6 +835,8 @@ async function cmdLogs(flags) {
734
835
  const convId = flags._[0];
735
836
  const asJson = flags.json === true;
736
837
  const asHandle = typeof flags.as === 'string' ? flags.as : undefined;
838
+ if (!asJson)
839
+ console.log(`→ Reading ${convId ? `conversation ${convId}` : 'recent conversations'} from ${tenant}/${project} …`);
737
840
  if (!convId) {
738
841
  const { status, json } = await apiGet(`${base}/conversations?limit=50`, token);
739
842
  if (status !== 200)
@@ -923,11 +1026,14 @@ async function cmdChat(flags) {
923
1026
  const from = flags.as ?? 'cli-tester';
924
1027
  const asJson = flags.json === true;
925
1028
  const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
1029
+ const mediaArg = typeof flags.media === 'string' ? flags.media : undefined;
926
1030
  const message = flags._[0];
927
- if (!message && !tapId)
928
- die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--json]');
1031
+ if (!message && !tapId && !mediaArg)
1032
+ die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]');
929
1033
  // Fresh idempotency key per call (see the command doc above).
930
1034
  const localId = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1035
+ if (!asJson)
1036
+ console.log(`→ Connecting to ${tenant}/${project} as '${from}' …`);
931
1037
  const evRes = await fetchOrDie(`${url}/api/web/events/${tenant}/${project}/${encodeURIComponent(from)}`, { headers: { accept: 'text/event-stream' } }, 'open chat stream');
932
1038
  if (!evRes.ok || !evRes.body)
933
1039
  die(`could not open chat stream (HTTP ${evRes.status})`);
@@ -947,20 +1053,41 @@ async function cmdChat(flags) {
947
1053
  if (f.id != null && f.id > boundary)
948
1054
  boundary = f.id;
949
1055
  }
950
- // Phase 2 — send the inbound (text, or an interactive tap).
951
- console.log(`→ [${from}] ${tapId ? `(tap) ${tapId}` : message}`);
952
- const body = tapId
953
- ? { type: 'interactive', from, tap_id: tapId, ...(message ? { raw_title: message } : {}), local_id: localId }
954
- : { type: 'text', from, text: message, local_id: localId };
955
- const postRes = await fetchOrDie(`${url}/api/web/inbound/${tenant}/${project}`, {
956
- method: 'POST',
957
- headers: { 'content-type': 'application/json' },
958
- body: JSON.stringify(body),
959
- }, 'send message');
1056
+ // Phase 2 — send the inbound (media upload, an interactive tap, or text).
1057
+ const inbound = `${url}/api/web/inbound/${tenant}/${project}`;
1058
+ let postRes;
1059
+ if (mediaArg && !tapId) {
1060
+ // Multipart upload → the platform's media pipeline sets $inbound_media, which
1061
+ // a running collect folds into $state.<field> (docs/19 §8a). Any accompanying
1062
+ // message rides as the media caption.
1063
+ const { blob, kind, filename } = await resolveMediaPart(url, mediaArg);
1064
+ console.log(`→ [${from}] (media:${kind}) ${filename}${message ? ` — "${message}"` : ''}`);
1065
+ const form = new FormData();
1066
+ form.append('type', kind);
1067
+ form.append('from', from);
1068
+ form.append('local_id', localId);
1069
+ if (message)
1070
+ form.append('caption', message);
1071
+ form.append('file', blob, filename);
1072
+ postRes = await fetchOrDie(inbound, { method: 'POST', body: form }, 'send media'); // fetch sets the multipart boundary
1073
+ }
1074
+ else {
1075
+ console.log(`→ [${from}] ${tapId ? `(tap) ${tapId}` : message}`);
1076
+ const body = tapId
1077
+ ? { type: 'interactive', from, tap_id: tapId, ...(message ? { raw_title: message } : {}), local_id: localId }
1078
+ : { type: 'text', from, text: message, local_id: localId };
1079
+ postRes = await fetchOrDie(inbound, {
1080
+ method: 'POST',
1081
+ headers: { 'content-type': 'application/json' },
1082
+ body: JSON.stringify(body),
1083
+ }, 'send message');
1084
+ }
960
1085
  if (!postRes.ok) {
961
1086
  await cancel();
962
1087
  die(`send rejected (HTTP ${postRes.status}): ${await postRes.text()}`);
963
1088
  }
1089
+ if (!asJson)
1090
+ console.log(` … delivered — waiting for the reply (up to ${Math.round(REPLY_TIMEOUT_MS / 1000)}s)`);
964
1091
  // Phase 3 — collect THIS turn's renders (id > boundary). A turn can send
965
1092
  // several messages, so keep reading until a quiet gap after the last render.
966
1093
  const deadline = Date.now() + REPLY_TIMEOUT_MS;
@@ -999,6 +1126,67 @@ async function cmdChat(flags) {
999
1126
  }
1000
1127
  console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
1001
1128
  }
1129
+ /** `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]`
1130
+ * — AI-generate an image on the platform (needs a `media:generate`-scoped token),
1131
+ * store it as a public asset, and return its `MEDIA-` handle + serve URL. `--out`
1132
+ * downloads the bytes to a file (WhatsApp renders only `.png`/`.jpg`); the paired
1133
+ * `octwin chat --media <file|id>` feeds it into a running media-collect flow. */
1134
+ async function cmdMedia(flags) {
1135
+ const sub = flags._[0];
1136
+ if (sub !== 'generate')
1137
+ die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
1138
+ const packDir = resolve(flags.dir ?? '.');
1139
+ const { url, tenant, project, token } = resolveTarget(flags, packDir);
1140
+ const prompt = flags._[1];
1141
+ if (!prompt)
1142
+ die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
1143
+ const asJson = flags.json === true;
1144
+ const size = typeof flags.size === 'string' ? flags.size : undefined;
1145
+ const out = typeof flags.out === 'string' ? flags.out : undefined;
1146
+ if (!asJson)
1147
+ console.log(`→ Generating an image on '${tenant}/${project}' @ ${url} …`);
1148
+ const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/media/generate`, {
1149
+ method: 'POST',
1150
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
1151
+ body: JSON.stringify({ prompt, ...(size ? { size } : {}) }),
1152
+ }, 'media generate');
1153
+ const text = await res.text();
1154
+ if (!res.ok) {
1155
+ let j;
1156
+ try {
1157
+ j = JSON.parse(text);
1158
+ }
1159
+ catch {
1160
+ j = text;
1161
+ }
1162
+ console.error(`✗ media generate failed (HTTP ${res.status})${errDetail(j)}`);
1163
+ printAuthHint(res.status, url);
1164
+ process.exit(1);
1165
+ }
1166
+ const r = JSON.parse(text);
1167
+ const absUrl = /^https?:/i.test(r.url) ? r.url : `${url}${r.url}`;
1168
+ // --out → download the bytes so the paired `chat --media <file>` can send them.
1169
+ if (out) {
1170
+ const dl = await fetchOrDie(absUrl, undefined, 'download generated image');
1171
+ if (!dl.ok)
1172
+ die(`could not download the generated image (HTTP ${dl.status})`);
1173
+ writeFileSync(out, Buffer.from(await dl.arrayBuffer()));
1174
+ const ext = out.slice(out.lastIndexOf('.')).toLowerCase();
1175
+ if (ext !== '.png' && ext !== '.jpg' && ext !== '.jpeg') {
1176
+ console.error(` ⚠ '${out}' isn't a .png/.jpg — WhatsApp only renders those extensions.`);
1177
+ }
1178
+ }
1179
+ if (asJson) {
1180
+ console.log(JSON.stringify({ media_id: r.media_id, url: absUrl, mime: r.mime, width: r.width, height: r.height, bytes: r.bytes }));
1181
+ return;
1182
+ }
1183
+ console.log(`✓ Generated ${r.media_ref} (${r.width}×${r.height}, ${r.mime}, ${r.bytes} bytes)`);
1184
+ console.log(` id: ${r.media_id}`);
1185
+ console.log(` url: ${absUrl}`);
1186
+ if (out)
1187
+ console.log(` saved → ${out}`);
1188
+ console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
1189
+ }
1002
1190
  /** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
1003
1191
  * the aggregate inbox, one case + its timeline, or the queue list. */
1004
1192
  async function cmdCases(flags) {
@@ -1007,6 +1195,8 @@ async function cmdCases(flags) {
1007
1195
  const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
1008
1196
  const caseId = flags._[0];
1009
1197
  const asJson = flags.json === true;
1198
+ if (!asJson)
1199
+ console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${tenant}/${project} …`);
1010
1200
  const caseFail = (what, status, json) => {
1011
1201
  if (status === 403)
1012
1202
  die(`forbidden — casework needs the 'cases' plan feature on this tenant, and a role whose grants reach the queue`);
@@ -1092,7 +1282,8 @@ function help() {
1092
1282
  octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
1093
1283
  octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
1094
1284
  octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
1095
- octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json] # drive a turn + print every render
1285
+ octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
1286
+ octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
1096
1287
  octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1097
1288
  octwin test [--dir .] # = validate --remote (the full platform check)
1098
1289
 
@@ -1131,11 +1322,19 @@ const COMMAND_HELP = {
1131
1322
  No id = recent conversations (handle, status, last activity; --as filters).
1132
1323
  With id = the full event timeline including what each turn rendered.
1133
1324
  --json = raw events (verbatim payloads).`,
1134
- chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json]
1325
+ chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
1135
1326
  Drive one turn through the dev web channel and print every render with its
1136
1327
  tap ids. Same --as handle = same conversation (multi-turn works).
1137
1328
  --tap presses a rendered button/list row instead of sending text.
1329
+ --media uploads a local file (or a media id from 'media generate --json') as
1330
+ an image/document/audio inbound — any "message" rides as its caption; feeds a
1331
+ running media-collect flow (e.g. activate-app).
1138
1332
  --json dumps the raw SSE envelopes for the turn.`,
1333
+ media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
1334
+ AI-generate an image (needs a media:generate-scoped token), store it as a
1335
+ public asset, and print its MEDIA- handle + serve URL. --out downloads the
1336
+ bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
1337
+ width, height, bytes }. Pair with 'octwin chat --media' to drive media flows.`,
1139
1338
  'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1140
1339
  Pull the platform capability reference (markdown + JSON catalogs) into
1141
1340
  .octwin/platform-kb/ for the octwin-pack authoring skill.`,
@@ -1182,6 +1381,9 @@ async function main() {
1182
1381
  case 'chat':
1183
1382
  await cmdChat(flags);
1184
1383
  break;
1384
+ case 'media':
1385
+ await cmdMedia(flags);
1386
+ break;
1185
1387
  case 'platform-kb':
1186
1388
  await cmdPlatformKb(flags);
1187
1389
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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": {