octwin-cli 0.1.16 → 0.1.20

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/dist/index.js CHANGED
@@ -13,11 +13,13 @@
13
13
  * octwin login --url <platformUrl> --token oct_…
14
14
  * octwin whoami [--url <url>] [--tenant <slug>]
15
15
  * octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
16
+ * octwin pull <packId> [--dir <out>] [--version v] [--force] # write a DEPLOYED pack's source back to disk
16
17
  * octwin status [--dir .] # did my deploy land? which version is live?
17
18
  * octwin records [entity] [id] # inspect the pack's XRM data (records:read token)
18
19
  * octwin cases [caseId] [--queues] # inspect casework (support tickets) — list / one case + timeline
19
20
  * octwin logs [conversationId] [--as h] [--json] # list conversations / show one's timeline
20
21
  * octwin chat "msg" [--as h] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn via the web channel (+ send media)
22
+ * octwin chat --script <file> [--as h] # drive a WHOLE conversation, one turn per line (the reliable way to test a flow)
21
23
  * octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (media:generate scope)
22
24
  * octwin agents [packId::agentId] [--prompt] # effective model/memory + which layer won; --prompt = the resolved system prompt
23
25
  * octwin orders [reference_id] # the orders a conversation produced — money breakdown + payment state (orders:read)
@@ -33,20 +35,32 @@
33
35
  * tap id; replay a tap with `--tap "<tap-id>"`. `--help` on any subcommand
34
36
  * prints its usage without touching the network.
35
37
  *
36
- * Config resolution (deploy): flags > pack.json (in the pack dir) > env
37
- * (PACK_PLATFORM_URL / PACK_TENANT / PACK_PROJECT / PACK_TOKEN) > saved login.
38
+ * For a MULTI-STEP flow use `--script`, not chained invocations. A turn ends on
39
+ * a quiet gap, which can arrive before the server-side agent loop finishes — so
40
+ * `chat A && chat B` races it and B can land mid-turn (the agent then fills
41
+ * required fields with placeholder text, or starts a second workflow run).
42
+ * `--script` runs the turns in one process, over one connection, in order.
38
43
  *
39
- * The bundle uploaded is every file under the pack dir EXCEPT pack.json, dot
40
- * files/dirs, node_modules, and build output. The platform re-validates it
44
+ * Config resolution (deploy): flags > env (PACK_PLATFORM_URL / PACK_TENANT /
45
+ * PACK_PROJECT / PACK_TOKEN) > saved login (`octwin login` sets both the url and
46
+ * the token; the token carries its own tenant). Nothing is read from the pack dir.
47
+ *
48
+ * The bundle uploaded is every file under the pack dir that `classifyPackPath`
49
+ * calls content — excluding dot files/dirs, `node_modules/`, build output,
50
+ * `__snapshots__/`, `*.ts`/`*.tsx` and `*.example`. That is the SAME rule the
51
+ * operator's GitHub repo import applies. The platform re-validates it
41
52
  * (pure-YAML enforcement + manifest/flow Zod) and installs it onto the project.
42
53
  */
43
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, cpSync } from 'node:fs';
54
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, rmSync } from 'node:fs';
44
55
  import { join, resolve, dirname, basename } from 'node:path';
45
56
  import { homedir } from 'node:os';
46
57
  import { fileURLToPath } from 'node:url';
47
58
  import { parse as parseYaml } from 'yaml';
48
59
  import { applyRenames } from './lib/rename.js';
49
60
  import { validatePackBundle } from './lib/validate.js';
61
+ import { loadAllowedRenderKeys, findRenderKeyViolations, describeRenderFinding } from './lib/render-check.js';
62
+ import { loadPrimitiveArgSpecs, findArgViolations, describeArgFinding } from './lib/args-check.js';
63
+ import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
50
64
  // The in-package starter template ships alongside `dist/` and `src/` (both one
51
65
  // level under the package root), so `../templates/starter` resolves for the
52
66
  // built CLI and `tsx` dev alike.
@@ -217,12 +231,6 @@ async function resolveMediaPart(url, arg) {
217
231
  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'.`);
218
232
  }
219
233
  // ── bundle collection ───────────────────────────────────────────────────────
220
- const SKIP_DIRS = new Set(['.git', 'node_modules', '.pack-bundles', 'dist', '.mastra']);
221
- /**
222
- * Binary extensions the platform accepts as artifact BLOBS (mirrors
223
- * `ALLOWED_BINARY_EXT` server-side). Deliberately no `svg` — script-capable.
224
- */
225
- const BINARY_EXT = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf']);
226
234
  /** Per-blob / total ceilings, mirroring the server so oversize fails LOCALLY. */
227
235
  const MAX_BLOB_BYTES = 2 * 1024 * 1024;
228
236
  const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
@@ -234,6 +242,11 @@ const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
234
242
  * MANGLED any committed image — the bytes went through a lossy UTF-8 decode and
235
243
  * arrived corrupt. Binary files now split off into `blobs`, transported as base64
236
244
  * (transport only; they land in `bytea` server-side).
245
+ *
246
+ * WHAT is content and WHICH half it lands in are not decided here — `classifyPackPath`
247
+ * decides, the same rule the operator's GitHub repo import uses. This walk only
248
+ * supplies the bytes. (It had its own answer once, and the two disagreed: a `*.test.ts`
249
+ * or `xrm.yaml.example` beside a flow imported fine and failed to deploy.)
237
250
  */
238
251
  function collectBundleFiles(packDir) {
239
252
  const files = {};
@@ -244,17 +257,14 @@ function collectBundleFiles(packDir) {
244
257
  const full = join(dir, name);
245
258
  const rel = prefix ? `${prefix}/${name}` : name;
246
259
  if (statSync(full).isDirectory()) {
247
- if (SKIP_DIRS.has(name) || name.startsWith('.'))
248
- continue;
249
- walk(full, rel);
260
+ if (!isSkippedDir(name))
261
+ walk(full, rel); // prune before descending
250
262
  continue;
251
263
  }
252
- if (name === 'pack.json')
253
- continue; // deploy config, not part of the pack
254
- if (name.startsWith('.'))
255
- continue; // .gitignore etc. — not pack content
256
- const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
257
- if (BINARY_EXT.has(ext)) {
264
+ const kind = classifyPackPath(rel);
265
+ if (kind === 'skip')
266
+ continue;
267
+ if (kind === 'blob') {
258
268
  const buf = readFileSync(full);
259
269
  if (buf.byteLength > MAX_BLOB_BYTES) {
260
270
  die(`'${rel}' is ${(buf.byteLength / 1024 / 1024).toFixed(1)} MB — the per-file limit is ${MAX_BLOB_BYTES / 1024 / 1024} MB`);
@@ -282,17 +292,18 @@ function readManifestIdVersion(files) {
282
292
  }
283
293
  return { id: doc.id, version: doc.version };
284
294
  }
285
- function readPackConfig(packDir) {
286
- const p = join(packDir, 'pack.json');
287
- if (!existsSync(p))
288
- return {};
289
- try {
290
- return JSON.parse(readFileSync(p, 'utf8'));
291
- }
292
- catch {
293
- return {};
294
- }
295
- }
295
+ // ── config (env + saved login) ──────────────────────────────────────────────
296
+ /**
297
+ * The saved login IS the deploy target. `~/.octwin/credentials.json` is a flat
298
+ * map of platform url → token, plus ONE reserved key holding the url the last
299
+ * `octwin login` pointed at. It cannot collide with a token entry: every url key
300
+ * contains `://`, and `default_url` does not.
301
+ *
302
+ * A pack directory therefore holds pack content and nothing else — the deploy
303
+ * target is a property of the machine, not of the pack. (The retired `pack.json`
304
+ * was a second home for a fact `octwin login` already stated.)
305
+ */
306
+ const DEFAULT_URL_KEY = 'default_url';
296
307
  function credsPath() { return join(homedir(), '.octwin', 'credentials.json'); }
297
308
  function readCreds() {
298
309
  try {
@@ -306,6 +317,8 @@ function writeCreds(map) {
306
317
  mkdirSync(join(homedir(), '.octwin'), { recursive: true });
307
318
  writeFileSync(credsPath(), JSON.stringify(map, null, 2), 'utf8');
308
319
  }
320
+ /** The platform url of the last `octwin login` — the default target. */
321
+ function savedDefaultUrl() { return readCreds()[DEFAULT_URL_KEY] ?? ''; }
309
322
  // ── update check (daily, fail-silent, TTY-only) ──────────────────────────────
310
323
  function updateCachePath() { return join(homedir(), '.octwin', 'update-check.json'); }
311
324
  /** True when semver `a` is strictly greater than `b` (simple x.y.z compare). */
@@ -420,7 +433,7 @@ async function notifyIfKbStale(flags) {
420
433
  const local = readLocalKb(packDir);
421
434
  if (!local?.content_hash)
422
435
  return; // never pulled → the skill already says to pull
423
- const t = resolveTargetOrNull(flags, packDir);
436
+ const t = resolveTargetOrNull(flags);
424
437
  if (!t)
425
438
  return;
426
439
  const ctrl = new AbortController();
@@ -463,6 +476,7 @@ function commandTouchesPlatform(command, flags) {
463
476
  case 'test':
464
477
  case 'chat':
465
478
  case 'media':
479
+ case 'pull':
466
480
  case 'records':
467
481
  case 'cases':
468
482
  case 'logs':
@@ -496,13 +510,9 @@ function cmdInit(flags) {
496
510
  description: flags.description ?? undefined,
497
511
  displayName: flags['display-name'] ?? undefined,
498
512
  });
499
- // Deploy config + repo hygiene + a README.
500
- // The token carries its own tenant (and optional project pin), so pack.json
501
- // needs only the platform URL. `tenant`/`project` may be added as optional
502
- // overrides (they also seed `octwin chat`, which is tenant/project-pathed).
503
- writeFileSync(join(dir, 'pack.json'), JSON.stringify({
504
- platform_url: 'http://localhost:3000',
505
- }, null, 2) + '\n', 'utf8');
513
+ // Repo hygiene + a README. Nothing else: the scaffold writes pack CONTENT only.
514
+ // The deploy target is the saved login (`octwin login --url … --token …`), and
515
+ // the token carries its own tenant + optional project pin.
506
516
  writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.pack-bundles/\n.octwin/\n', 'utf8');
507
517
  if (!existsSync(join(dir, 'README.md'))) {
508
518
  writeFileSync(join(dir, 'README.md'), `# ${id}\n\nA pure-YAML pack for the Octwin platform.\n\n- \`flows/tools/home.flow.yaml\` — the menu hub (your front door); add a row + tool per journey\n- Edit \`manifest.yaml\`, \`prompts/identity.md\`\n- \`octwin validate --remote\` — full platform check before deploy\n- \`octwin deploy\` — deploy + install onto your tenant\n`, 'utf8');
@@ -513,7 +523,7 @@ function cmdInit(flags) {
513
523
  console.log(' git init && git add -A && git commit -m "init pack"');
514
524
  console.log(' # edit manifest.yaml / flows / prompts, then:');
515
525
  console.log(' octwin validate');
516
- console.log(' # set platform_url in pack.json (tenant comes from your token), then:');
526
+ console.log(' # point the CLI at your platform (the token carries the tenant):');
517
527
  console.log(' octwin login --url <platformUrl> --token <deploy-token>');
518
528
  console.log(' octwin deploy');
519
529
  }
@@ -532,6 +542,55 @@ async function cmdValidate(flags) {
532
542
  const packDir = resolve(flags.dir ?? '.');
533
543
  const { id, version, files, blobs } = localValidate(packDir); // offline structural gate first (fast, no server/token)
534
544
  console.log(`✓ ${id}@${version} passes the offline structural check (${Object.keys(files).length} files, ${Object.keys(blobs).length} image(s))`);
545
+ // Render-intent fields, checked against the pulled KB. Skipped (silently) when
546
+ // the author hasn't pulled the reference yet — Step 0.5 of the skill says to.
547
+ const allowedRenderKeys = loadAllowedRenderKeys(packDir);
548
+ if (allowedRenderKeys) {
549
+ const findings = Object.entries(files)
550
+ .filter(([p]) => /\.ya?ml$/i.test(p))
551
+ .flatMap(([p, body]) => {
552
+ let doc;
553
+ try {
554
+ doc = parseYaml(body);
555
+ }
556
+ catch {
557
+ return [];
558
+ } // a YAML syntax error is the structural gate's to report
559
+ return findRenderKeyViolations(doc, p, allowedRenderKeys);
560
+ });
561
+ if (findings.length) {
562
+ console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
563
+ for (const f of findings)
564
+ console.error(` ✗ ${describeRenderFinding(f)}`);
565
+ die('fix these before deploying — the platform rejects them at load, and before that they rendered as nothing');
566
+ }
567
+ console.log('✓ render intents use only fields the platform renders');
568
+ }
569
+ // Primitive `args:` keys, same source and same degrade-to-no-op contract.
570
+ // Cannot see inside a `use:` template body (expansion is the platform's job);
571
+ // `--remote` covers that.
572
+ const argSpecs = loadPrimitiveArgSpecs(packDir);
573
+ if (argSpecs) {
574
+ const findings = Object.entries(files)
575
+ .filter(([p]) => /\.ya?ml$/i.test(p))
576
+ .flatMap(([p, body]) => {
577
+ let doc;
578
+ try {
579
+ doc = parseYaml(body);
580
+ }
581
+ catch {
582
+ return [];
583
+ }
584
+ return findArgViolations(doc, p, argSpecs);
585
+ });
586
+ if (findings.length) {
587
+ console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
588
+ for (const f of findings)
589
+ console.error(` ✗ ${describeArgFinding(f)}`);
590
+ die('fix these before deploying — an undeclared argument is dropped with no error at runtime');
591
+ }
592
+ console.log('✓ primitive arguments match their declared inputs');
593
+ }
535
594
  if (flags.remote !== true) {
536
595
  console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
537
596
  console.log(' (all errors at once) before you deploy.');
@@ -539,7 +598,7 @@ async function cmdValidate(flags) {
539
598
  }
540
599
  // Remote: the SAME validation the deploy route runs — manifest `.strict()` +
541
600
  // every flow (schema/expression/structure) — returning ALL errors at once.
542
- const t = resolveTarget(flags, packDir);
601
+ const t = resolveTarget(flags);
543
602
  const { url } = t;
544
603
  console.log(`→ Validating against ${targetLabel(t)} @ ${url} …`);
545
604
  const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
@@ -595,8 +654,9 @@ async function cmdLogin(flags) {
595
654
  const token = flags.token ?? process.env.PACK_TOKEN ?? die('missing --token');
596
655
  const creds = readCreds();
597
656
  creds[url] = token;
657
+ creds[DEFAULT_URL_KEY] = url; // login sets the default deploy target
598
658
  writeCreds(creds);
599
- console.log(`✓ Saved token for ${url}`);
659
+ console.log(`✓ Saved token for ${url} — now the default target`);
600
660
  // Best-effort: echo what the token reaches (workspace + project pin + scopes)
601
661
  // so a fresh token self-identifies without a second `octwin whoami`. A network
602
662
  // failure never fails the save — the token is stored regardless.
@@ -623,36 +683,35 @@ function authHeaders(t) {
623
683
  return h;
624
684
  }
625
685
  /** Resolve platform url + token (+ optional tenant/project overrides):
626
- * flags > pack.json > env > saved login. Tenant is derived from the token
627
- * server-side, so only url + token are required. */
628
- function resolveTarget(flags, packDir) {
629
- const cfg = readPackConfig(packDir);
630
- const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
631
- const tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || undefined;
632
- const project = flags.project || process.env.PACK_PROJECT || cfg.project || undefined;
633
- const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
634
- if (!url)
635
- die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
636
- if (!token)
686
+ * flags > env > saved login. Tenant is derived from the token server-side, so
687
+ * only url + token are required — and `octwin login` supplies both. */
688
+ function resolveTarget(flags) {
689
+ const t = readTarget(flags);
690
+ if (!t.url)
691
+ die('no platform url run `octwin login --url <url> --token oct_…`, or pass --url / PACK_PLATFORM_URL');
692
+ if (!t.token)
637
693
  die('no token — generate an API token in the console (Settings → API tokens), then `octwin login --url <url> --token oct_…` or pass --token');
638
- return { url, token, tenant, project };
694
+ return t;
639
695
  }
640
696
  /** Non-fatal `resolveTarget`: returns null (never dies) when url or token is
641
697
  * missing. Used by the fail-silent KB-staleness observer, which must never
642
698
  * interrupt a command over a config gap. */
643
- function resolveTargetOrNull(flags, packDir) {
644
- const cfg = readPackConfig(packDir);
645
- const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
646
- const tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || undefined;
647
- const project = flags.project || process.env.PACK_PROJECT || cfg.project || undefined;
648
- const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
649
- if (!url || !token)
650
- return null;
651
- return { url, token, tenant, project };
699
+ function resolveTargetOrNull(flags) {
700
+ const t = readTarget(flags);
701
+ return t.url && t.token ? t : null;
702
+ }
703
+ /** The raw resolution both wrappers share — may return empty url/token. */
704
+ function readTarget(flags) {
705
+ const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? savedDefaultUrl()).replace(/\/$/, '');
706
+ return {
707
+ url,
708
+ token: flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '',
709
+ tenant: flags.tenant || process.env.PACK_TENANT || undefined,
710
+ project: flags.project || process.env.PACK_PROJECT || undefined,
711
+ };
652
712
  }
653
713
  async function cmdWhoami(flags) {
654
- const packDir = resolve(flags.dir ?? '.');
655
- const t = resolveTarget(flags, packDir);
714
+ const t = resolveTarget(flags);
656
715
  console.log(`→ Checking the token against ${t.url} …`);
657
716
  const res = await fetchOrDie(`${t.url}/api/self/t/whoami`, { headers: authHeaders(t) }, 'token check');
658
717
  if (res.ok) {
@@ -667,6 +726,73 @@ async function cmdWhoami(flags) {
667
726
  : await res.text();
668
727
  die(`token check failed (HTTP ${res.status}) — ${why}`);
669
728
  }
729
+ /**
730
+ * `octwin pull <packId> [--dir <out>] [--version v] [--force]` — write a
731
+ * DEPLOYED pack's source back to disk.
732
+ *
733
+ * The inverse of `deploy`, and the reason it exists: a pack pushed with
734
+ * `octwin deploy` lives on the platform as an artifact the runtime serves but
735
+ * nothing hands back, so its only source copy is the machine that pushed it.
736
+ * Lose that machine — or inherit a pack someone else deployed — and the source is
737
+ * gone while the bot keeps running. `pull` closes the loop: fetch, fix, redeploy.
738
+ *
739
+ * You may pull a pack YOUR tenant owns (an operator token may pull any). A pack
740
+ * you merely installed from the marketplace is not yours to read.
741
+ *
742
+ * Writes into `<out>/` and refuses a non-empty directory unless `--force`, so it
743
+ * can't quietly clobber local edits. The pulled directory is immediately
744
+ * `octwin deploy`-able back to where it came from — the target is the saved
745
+ * login, so nothing machine-specific needs to land in the pack dir.
746
+ */
747
+ async function cmdPull(flags) {
748
+ const packId = flags._[0] ?? '';
749
+ if (!packId)
750
+ die('usage: octwin pull <packId> [--dir <out>] [--version <v>] [--force]');
751
+ const outDir = resolve(flags.dir ?? packId);
752
+ const t = resolveTarget(flags);
753
+ const { url } = t;
754
+ const qs = typeof flags.version === 'string' ? `?version=${encodeURIComponent(flags.version)}` : '';
755
+ console.log(`→ Pulling ${packId}${qs ? `@${flags.version}` : ''} from ${targetLabel(t)} @ ${url} …`);
756
+ const res = await fetchOrDie(`${url}/api/self/p/packs/${encodeURIComponent(packId)}/source${qs}`, { headers: authHeaders(t) }, 'pull');
757
+ const text = await res.text();
758
+ let json;
759
+ try {
760
+ json = JSON.parse(text);
761
+ }
762
+ catch {
763
+ json = text;
764
+ }
765
+ if (!res.ok) {
766
+ console.error(`✗ pull failed (HTTP ${res.status})`);
767
+ printAuthHint(res.status, url);
768
+ if (res.status === 403) {
769
+ console.error(' → a pack is pullable by the tenant that OWNS it (deployed it), or by an operator.');
770
+ }
771
+ console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
772
+ process.exit(1);
773
+ }
774
+ const files = json.files ?? {};
775
+ const blobs = json.blobs ?? {};
776
+ const total = Object.keys(files).length + Object.keys(blobs).length;
777
+ if (total === 0)
778
+ die(`${packId}@${json.version} has no files — nothing to write`);
779
+ if (existsSync(outDir) && readdirSync(outDir).length > 0 && flags.force !== true) {
780
+ die(`${outDir} is not empty — pass --force to overwrite it`);
781
+ }
782
+ for (const [rel, body] of Object.entries(files)) {
783
+ const p = join(outDir, rel);
784
+ mkdirSync(dirname(p), { recursive: true });
785
+ writeFileSync(p, body, 'utf8');
786
+ }
787
+ for (const [rel, b64] of Object.entries(blobs)) {
788
+ const p = join(outDir, rel);
789
+ mkdirSync(dirname(p), { recursive: true });
790
+ writeFileSync(p, Buffer.from(b64, 'base64'));
791
+ }
792
+ console.log(`✓ Pulled ${json.pack_id}@${json.version} → ${outDir}`);
793
+ console.log(` ${Object.keys(files).length} file(s)${Object.keys(blobs).length ? `, ${Object.keys(blobs).length} blob(s)` : ''} content_sha ${String(json.content_sha).slice(0, 12)}…`);
794
+ console.log(`\nFix it, then: octwin deploy --dir ${outDir}`);
795
+ }
670
796
  /**
671
797
  * Read the deploy SSE stream, printing each progress frame's message live, and
672
798
  * return the terminal `done`/`error` event (or null if the stream ended without
@@ -734,11 +860,18 @@ function printDeploySuccess(id, version, t, r) {
734
860
  if (parts.length)
735
861
  console.log(` Seeded: ${parts.join(', ')}`);
736
862
  }
863
+ // A redeploy rebuilds the pack's tools, and suspended flow runs live with them.
864
+ // Say so: otherwise the next tap on a card rendered before the deploy comes back
865
+ // stale and reads like a flow bug.
866
+ const dropped = r?.suspended_runs_dropped;
867
+ if (dropped > 0) {
868
+ console.log(` ⓘ ${dropped} suspended run(s) invalidated — a tap on any card rendered before this deploy will report a stale run. Start those conversations again.`);
869
+ }
737
870
  console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
738
871
  }
739
872
  async function cmdDeploy(flags) {
740
873
  const packDir = resolve(flags.dir ?? '.');
741
- const t = resolveTarget(flags, packDir);
874
+ const t = resolveTarget(flags);
742
875
  const { url } = t;
743
876
  const { id, version, files, blobs } = localValidate(packDir);
744
877
  const endpoint = `${url}/api/self/p/packs/deploy`;
@@ -789,7 +922,7 @@ async function cmdDeploy(flags) {
789
922
  }
790
923
  async function cmdStatus(flags) {
791
924
  const packDir = resolve(flags.dir ?? '.');
792
- const t = resolveTarget(flags, packDir);
925
+ const t = resolveTarget(flags);
793
926
  const { url } = t;
794
927
  const manifestPath = join(packDir, 'manifest.yaml');
795
928
  if (!existsSync(manifestPath))
@@ -845,9 +978,130 @@ async function cmdStatus(flags) {
845
978
  console.log(` (local manifest is ${localVersion}; deployed is ${json.installed_version} — \`octwin deploy\` to push local edits.)`);
846
979
  }
847
980
  }
981
+ /** Filesystem-safe entry filename (entry names are already tame — `record_list`,
982
+ * `whatsapp`, `xrm` — but never trust a name straight into a path). */
983
+ function kbEntryFileName(name) {
984
+ return name.replace(/[^A-Za-z0-9._-]/g, '_');
985
+ }
986
+ /** First sentence (or a hard clamp) of a possibly-long `describe` — INDEX.md needs
987
+ * one scannable line per entry, not the whole contract.
988
+ *
989
+ * Sentence detection ignores punctuation nested in brackets: primitive `describe`
990
+ * text routinely inlines an envelope shape (`… { rows, total, …, refs? } …`) whose
991
+ * `?` would otherwise cut the summary off mid-brace. */
992
+ function kbOneLiner(text, max = 160) {
993
+ if (typeof text !== 'string' || !text.trim())
994
+ return '';
995
+ const flat = text.replace(/\s+/g, ' ').trim();
996
+ let depth = 0;
997
+ let end = -1;
998
+ for (let i = 0; i < flat.length; i++) {
999
+ const ch = flat[i];
1000
+ if (ch === '{' || ch === '(' || ch === '[')
1001
+ depth++;
1002
+ else if (ch === '}' || ch === ')' || ch === ']')
1003
+ depth = Math.max(0, depth - 1);
1004
+ else if (depth === 0 && (ch === '.' || ch === '!' || ch === '?')) {
1005
+ const next = flat[i + 1];
1006
+ if (next === undefined || next === ' ') {
1007
+ end = i + 1;
1008
+ break;
1009
+ }
1010
+ }
1011
+ }
1012
+ const line = end >= 40 ? flat.slice(0, end) : flat;
1013
+ return line.length > max ? line.slice(0, max - 1).trimEnd() + '…' : line;
1014
+ }
1015
+ /** Enumerate a catalog's entries per the platform-supplied descriptor. Handles both
1016
+ * collection shapes in use: an ARRAY of named objects (`primitives`, keyed by
1017
+ * `name`) and an OBJECT MAP keyed by entry name (`declarations`, `system-entities`).
1018
+ * Returns [] when the descriptor doesn't fit the payload, so a shape surprise
1019
+ * degrades to "write the flat file" instead of throwing mid-pull. */
1020
+ function enumerateKbEntries(catalog, d) {
1021
+ const collection = catalog?.[d.at];
1022
+ if (Array.isArray(collection)) {
1023
+ if (!d.by)
1024
+ return [];
1025
+ const out = [];
1026
+ for (const item of collection) {
1027
+ const name = item?.[d.by];
1028
+ if (typeof name !== 'string' || !name)
1029
+ return []; // not the shape we were told — bail wholesale
1030
+ out.push({ name, summary: kbOneLiner(d.summary ? item[d.summary] : ''), value: item });
1031
+ }
1032
+ return out;
1033
+ }
1034
+ if (collection && typeof collection === 'object') {
1035
+ return Object.entries(collection).map(([name, value]) => ({
1036
+ name,
1037
+ summary: kbOneLiner(d.summary ? value?.[d.summary] : ''),
1038
+ value,
1039
+ }));
1040
+ }
1041
+ return [];
1042
+ }
1043
+ /**
1044
+ * Build `INDEX.md` — the map an authoring agent reads FIRST.
1045
+ *
1046
+ * The KB is ~800 KB across three dozen files; reading it whole costs more context
1047
+ * than the pack being authored. This index is one ~7k-token read that names every
1048
+ * doc and every catalog entry with a one-line summary and its exact path, so the
1049
+ * agent can jump straight to the ~600-token file it actually needs.
1050
+ */
1051
+ function buildKbIndexMarkdown(bundle, exploded) {
1052
+ const index = bundle.index ?? [];
1053
+ const docs = index.filter(e => e.kind === 'doc');
1054
+ const catalogs = index.filter(e => e.kind === 'catalog');
1055
+ const L = [];
1056
+ L.push('# Octwin platform capability reference — INDEX');
1057
+ L.push('');
1058
+ L.push(`Reference version ${bundle.version ?? '?'} · content_hash \`${bundle.content_hash ?? '?'}\` · pulled ${bundle.generated_at ?? '?'}`);
1059
+ L.push('');
1060
+ L.push('**This is the map. Read it, then open only the specific file you need — never a whole catalog.**');
1061
+ L.push('Everything the platform supports is here; if a step, function, field, or render intent is NOT in');
1062
+ L.push('this index, it does not exist for a pure-YAML pack. Do not fill a gap from memory.');
1063
+ L.push('');
1064
+ L.push('## Start here');
1065
+ L.push('');
1066
+ L.push('1. `craft-capabilities.md` — how this reference fits together.');
1067
+ L.push('2. `craft-ux.md` — what a *good* pack looks like (home hub, rich cards, confirm-before-commit).');
1068
+ L.push('3. `craft-flows.md` — the flow DSL in practice.');
1069
+ L.push('4. Then the tables below, on demand.');
1070
+ L.push('');
1071
+ L.push('## Guides & reference docs');
1072
+ L.push('');
1073
+ L.push('| Doc | Read it for | File |');
1074
+ L.push('|---|---|---|');
1075
+ for (const d of docs)
1076
+ L.push(`| ${d.title ?? d.key} | ${kbOneLiner(d.summary)} | \`${d.key}.md\` |`);
1077
+ L.push('');
1078
+ L.push('## Catalogs — exact machine-readable schemas');
1079
+ L.push('');
1080
+ for (const c of catalogs) {
1081
+ const entries = exploded.get(c.key);
1082
+ L.push(`### ${c.title ?? c.key}`);
1083
+ L.push('');
1084
+ L.push(kbOneLiner(c.summary, 400));
1085
+ L.push('');
1086
+ if (!entries || entries.length === 0) {
1087
+ L.push(`Single document: \`${c.key}.json\``);
1088
+ L.push('');
1089
+ continue;
1090
+ }
1091
+ L.push(`${entries.length} entries in \`${c.key}/\` — one file each.`);
1092
+ L.push('');
1093
+ L.push('| Entry | What it does | File |');
1094
+ L.push('|---|---|---|');
1095
+ for (const e of entries) {
1096
+ L.push(`| \`${e.name}\` | ${e.summary.replace(/\|/g, '\\|')} | \`${c.key}/${kbEntryFileName(e.name)}.json\` |`);
1097
+ }
1098
+ L.push('');
1099
+ }
1100
+ return L.join('\n') + '\n';
1101
+ }
848
1102
  async function cmdPlatformKb(flags) {
849
1103
  const packDir = resolve(flags.dir ?? '.');
850
- const t = resolveTarget(flags, packDir);
1104
+ const t = resolveTarget(flags);
851
1105
  const { url } = t;
852
1106
  console.log(`→ Pulling the platform capability reference from ${url} …`);
853
1107
  const res = await fetchOrDie(`${url}/api/self/t/octwin-platform-kb`, {
@@ -872,27 +1126,65 @@ async function cmdPlatformKb(flags) {
872
1126
  const prior = readLocalKb(packDir);
873
1127
  // Write the reference into <packDir>/.octwin/platform-kb/ — markdown docs (the
874
1128
  // skill reads these first) + JSON catalogs (precise field schemas). Gitignored.
1129
+ //
1130
+ // WIPE first: this directory is a pure cache of one pull, and now that catalogs
1131
+ // explode into per-entry files, leftovers actively mislead. A withdrawn primitive
1132
+ // or a retired catalog would otherwise linger as a file the authoring agent reads
1133
+ // as current — the exact "capability that doesn't exist" failure the KB prevents.
1134
+ //
1135
+ // Clear the CONTENTS, not the directory itself: on Windows a directory that is
1136
+ // any process's working directory cannot be removed (EPERM), and an author with
1137
+ // a shell sitting in the pulled reference is not an error case worth failing a
1138
+ // pull over. Each removal is individually tolerant for the same reason.
875
1139
  const outDir = join(packDir, '.octwin', 'platform-kb');
876
1140
  mkdirSync(outDir, { recursive: true });
1141
+ for (const stale of readdirSync(outDir)) {
1142
+ try {
1143
+ rmSync(join(outDir, stale), { recursive: true, force: true });
1144
+ }
1145
+ catch { /* keep going; we overwrite below */ }
1146
+ }
877
1147
  let mdCount = 0;
878
- let jsonCount = 0;
879
1148
  for (const [key, val] of Object.entries(bundle.docs ?? {})) {
880
1149
  if (val == null)
881
1150
  continue;
882
1151
  writeFileSync(join(outDir, `${key}.md`), val, 'utf8');
883
1152
  mdCount++;
884
1153
  }
1154
+ // Catalogs: EXPLODE the ones the platform told us how to enumerate (one file per
1155
+ // primitive / declaration / render intent / …), so reaching one entry costs a
1156
+ // ~600-token read instead of parsing a 120 KB blob. Catalogs with no descriptor
1157
+ // (or an unexpected payload shape) fall back to the flat file.
1158
+ const byKey = new Map((bundle.index ?? []).map(e => [e.key, e]));
1159
+ const exploded = new Map();
1160
+ let catalogCount = 0;
1161
+ let entryCount = 0;
885
1162
  for (const [key, val] of Object.entries(bundle.sources ?? {})) {
886
1163
  if (val == null)
887
1164
  continue;
888
- writeFileSync(join(outDir, `${key}.json`), JSON.stringify(val, null, 2) + '\n', 'utf8');
889
- jsonCount++;
1165
+ catalogCount++;
1166
+ const descriptor = byKey.get(key)?.entries;
1167
+ const entries = descriptor ? enumerateKbEntries(val, descriptor) : [];
1168
+ if (entries.length === 0) {
1169
+ writeFileSync(join(outDir, `${key}.json`), JSON.stringify(val, null, 2) + '\n', 'utf8');
1170
+ continue;
1171
+ }
1172
+ const dir = join(outDir, key);
1173
+ mkdirSync(dir, { recursive: true });
1174
+ for (const entry of entries) {
1175
+ writeFileSync(join(dir, `${kbEntryFileName(entry.name)}.json`), JSON.stringify(entry.value, null, 2) + '\n', 'utf8');
1176
+ }
1177
+ exploded.set(key, entries);
1178
+ entryCount += entries.length;
890
1179
  }
1180
+ // The map the authoring skill reads first.
1181
+ writeFileSync(join(outDir, 'INDEX.md'), buildKbIndexMarkdown(bundle, exploded), 'utf8');
891
1182
  // Persist `content_hash` too — the staleness observer (`notifyIfKbStale`) reads
892
1183
  // it back and compares against the platform's current hash to nudge a re-pull.
893
1184
  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');
894
1185
  console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
895
- console.log(` ${mdCount} markdown docs + ${jsonCount} JSON catalogs (reference version ${bundle.version ?? '?'})`);
1186
+ console.log(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`);
1187
+ console.log(' Start at INDEX.md — it maps every doc and every catalog entry to its file.');
896
1188
  // Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
897
1189
  // moved (a schema shape being replaced shows as a `~ changed`), not just a count.
898
1190
  if (prior?.content_hash) {
@@ -935,8 +1227,7 @@ function targetLabel(t) {
935
1227
  }
936
1228
  /** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
937
1229
  async function cmdRecords(flags) {
938
- const packDir = resolve(flags.dir ?? '.');
939
- const t = resolveTarget(flags, packDir);
1230
+ const t = resolveTarget(flags);
940
1231
  const { url } = t;
941
1232
  const base = `${url}/api/self/p`;
942
1233
  const entity = flags._[0];
@@ -956,8 +1247,10 @@ async function cmdRecords(flags) {
956
1247
  return;
957
1248
  }
958
1249
  console.log(`Entities in ${targetLabel(t)}:`);
1250
+ // `open_count` is the non-archived, non-terminal, RBAC-scoped subset — not
1251
+ // the entity's total. Label it, or it reads as "this entity has 2 records".
959
1252
  for (const e of ents)
960
- console.log(` ${e.entity} (${e.open_count ?? 0} records)`);
1253
+ console.log(` ${e.entity} (${e.open_count ?? 0} open)`);
961
1254
  console.log('\nList records: octwin records <entity>');
962
1255
  return;
963
1256
  }
@@ -990,8 +1283,7 @@ async function cmdRecords(flags) {
990
1283
  /** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
991
1284
  * or show one's event timeline (full text + the renders each turn produced). */
992
1285
  async function cmdLogs(flags) {
993
- const packDir = resolve(flags.dir ?? '.');
994
- const t = resolveTarget(flags, packDir);
1286
+ const t = resolveTarget(flags);
995
1287
  const { url } = t;
996
1288
  const base = `${url}/api/self/p`;
997
1289
  const convId = flags._[0];
@@ -1165,8 +1457,135 @@ class SseFrameReader {
1165
1457
  const REPLAY_SETTLE_MS = 400; // quiet gap that marks the end of the connect replay burst
1166
1458
  const TURN_SETTLE_MS = 2_000; // quiet gap after a render = the turn finished sending
1167
1459
  const REPLY_TIMEOUT_MS = 45_000; // hard cap waiting for the first render of the turn
1168
- /** `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json]` — drive one
1169
- * turn through the dev web channel and print everything it rendered.
1460
+ /**
1461
+ * Parse a `--script` file ONE TURN PER LINE, in order:
1462
+ *
1463
+ * # a comment, and blank lines, are skipped
1464
+ * احجز موعد → a typed message
1465
+ * tap:t:invoke:book:doctor_id=D1 → press a rendered row / button
1466
+ * media:./licence.jpg → upload a file
1467
+ * media:./licence.jpg | here you go → upload WITH a caption
1468
+ *
1469
+ * A `tap:` line keeps everything after the first colon verbatim, because a tap id
1470
+ * is itself colon-delimited (`t:invoke:target:bindings`).
1471
+ */
1472
+ function parseChatScript(body) {
1473
+ const turns = [];
1474
+ for (const raw of body.split(/\r?\n/)) {
1475
+ const line = raw.trim();
1476
+ if (!line || line.startsWith('#'))
1477
+ continue;
1478
+ if (line.startsWith('tap:')) {
1479
+ turns.push({ tap: line.slice(4).trim() });
1480
+ continue;
1481
+ }
1482
+ if (line.startsWith('media:')) {
1483
+ const rest = line.slice(6);
1484
+ const bar = rest.indexOf('|');
1485
+ turns.push(bar === -1
1486
+ ? { media: rest.trim() }
1487
+ : { media: rest.slice(0, bar).trim(), text: rest.slice(bar + 1).trim() });
1488
+ continue;
1489
+ }
1490
+ turns.push({ text: line });
1491
+ }
1492
+ return turns;
1493
+ }
1494
+ /**
1495
+ * Send ONE turn and print everything it rendered. Returns the new frame boundary
1496
+ * (so the next turn only accepts frames newer than this turn's output) and how
1497
+ * many renders arrived.
1498
+ *
1499
+ * Splitting this out is what makes `--script` reliable. Driving a multi-step flow
1500
+ * by chaining shell invocations (`chat A && chat B`) races the agent loop: this
1501
+ * command ends a turn on a QUIET GAP, and the server-side loop can still be
1502
+ * running when the process exits, so the next invocation's inbound lands
1503
+ * mid-turn. The agent then fills required fields with placeholder text, or starts
1504
+ * a second workflow run. Inside one process the loop simply waits for the settle
1505
+ * before sending the next turn, over the same SSE connection.
1506
+ */
1507
+ async function runChatTurn(args) {
1508
+ const { url, tenant, project, from, turn, frames, asJson } = args;
1509
+ let boundary = args.boundary;
1510
+ // Fresh idempotency key per turn — the platform dedups inbound on `local_id`
1511
+ // for 5 minutes, so a reused id makes the second turn a silent no-op.
1512
+ const localId = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1513
+ const inbound = `${url}/api/web/inbound/${tenant}/${project}`;
1514
+ let postRes;
1515
+ if (turn.media && !turn.tap) {
1516
+ const { blob, kind, filename } = await resolveMediaPart(url, turn.media);
1517
+ console.log(`→ [${from}] (media:${kind}) ${filename}${turn.text ? ` — "${turn.text}"` : ''}`);
1518
+ const form = new FormData();
1519
+ form.append('type', kind);
1520
+ form.append('from', from);
1521
+ form.append('local_id', localId);
1522
+ if (turn.text)
1523
+ form.append('caption', turn.text);
1524
+ form.append('file', blob, filename);
1525
+ postRes = await fetchOrDie(inbound, { method: 'POST', body: form }, 'send media');
1526
+ }
1527
+ else {
1528
+ console.log(`→ [${from}] ${turn.tap ? `(tap) ${turn.tap}` : turn.text}`);
1529
+ const body = turn.tap
1530
+ ? { type: 'interactive', from, tap_id: turn.tap, ...(turn.text ? { raw_title: turn.text } : {}), local_id: localId }
1531
+ : { type: 'text', from, text: turn.text, local_id: localId };
1532
+ postRes = await fetchOrDie(inbound, {
1533
+ method: 'POST',
1534
+ headers: { 'content-type': 'application/json' },
1535
+ body: JSON.stringify(body),
1536
+ }, 'send message');
1537
+ }
1538
+ if (!postRes.ok) {
1539
+ await frames.cancel();
1540
+ die(`send rejected (HTTP ${postRes.status}): ${await postRes.text()}`);
1541
+ }
1542
+ if (!asJson)
1543
+ console.log(` … delivered — waiting for the reply (up to ${Math.round(REPLY_TIMEOUT_MS / 1000)}s)`);
1544
+ // Collect THIS turn's renders (id > boundary). A turn can send several
1545
+ // messages, so keep reading until a quiet gap after the last render.
1546
+ const deadline = Date.now() + REPLY_TIMEOUT_MS;
1547
+ let rendersSeen = 0;
1548
+ for (;;) {
1549
+ const remaining = deadline - Date.now();
1550
+ if (remaining <= 0)
1551
+ break;
1552
+ const f = await frames.next(rendersSeen > 0 ? TURN_SETTLE_MS : Math.min(remaining, REPLY_TIMEOUT_MS));
1553
+ if (f === 'timeout') {
1554
+ if (rendersSeen > 0)
1555
+ break;
1556
+ else
1557
+ continue;
1558
+ }
1559
+ if (f === 'done')
1560
+ break;
1561
+ if (f.id != null && f.id <= boundary)
1562
+ continue; // late replay stragglers
1563
+ if (f.id != null)
1564
+ boundary = f.id;
1565
+ if (asJson) {
1566
+ console.log(JSON.stringify(f.ev));
1567
+ if (f.ev?.kind === 'render')
1568
+ rendersSeen++;
1569
+ continue;
1570
+ }
1571
+ if (f.ev?.kind !== 'render')
1572
+ continue; // status/typing noise
1573
+ rendersSeen++;
1574
+ console.log(`← ${f.ev.body ?? '(no text body)'}${f.ev.hint?.type && f.ev.hint.type !== 'text' ? ` (render: ${f.ev.hint.type})` : ''}`);
1575
+ printHint(f.ev.hint);
1576
+ }
1577
+ return { boundary, rendersSeen };
1578
+ }
1579
+ /** `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--script <file>] [--json]`
1580
+ * — drive a turn (or a whole scripted conversation) through the dev web channel
1581
+ * and print everything it rendered.
1582
+ *
1583
+ * ONE TURN PER INVOCATION. A turn ends on a QUIET GAP (`TURN_SETTLE_MS`), which
1584
+ * can arrive before the server-side agent loop has actually finished — so
1585
+ * chaining invocations (`chat A && chat B`) races it, and the second inbound can
1586
+ * land mid-turn (the agent then fills required fields with placeholder text, or
1587
+ * starts a second workflow run). To drive a multi-step flow, use `--script`: it
1588
+ * runs the turns in one process over one connection, waiting for each to settle.
1170
1589
  *
1171
1590
  * Multi-turn works: the platform keeps ONE open conversation per handle, so the
1172
1591
  * same `--as` continues the same conversation. Two traps this command handles:
@@ -1176,18 +1595,19 @@ const REPLY_TIMEOUT_MS = 45_000; // hard cap waiting for the first render of the
1176
1595
  * drain the replay first and only accept frames newer than it as the reply
1177
1596
  * (naively printing the first render showed LAST turn's message again). */
1178
1597
  async function cmdChat(flags) {
1179
- const packDir = resolve(flags.dir ?? '.');
1180
- const cfg = readPackConfig(packDir);
1181
- const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
1598
+ // Chat needs a url but NOT a token (an explicit --tenant is enough), so it reads
1599
+ // the raw target rather than going through the token-requiring `resolveTarget`.
1600
+ const raw = readTarget(flags);
1601
+ const url = raw.url;
1182
1602
  if (!url)
1183
- die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
1184
- let tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || '';
1185
- let project = flags.project || process.env.PACK_PROJECT || cfg.project || '';
1603
+ die('no platform url — run `octwin login --url <url> --token oct_…`, or pass --url / PACK_PLATFORM_URL');
1604
+ let tenant = raw.tenant ?? '';
1605
+ let project = raw.project ?? '';
1186
1606
  // The dev web channel is tenant/project-pathed (it simulates an end-user on a
1187
1607
  // specific project). When they aren't configured, derive them from the token —
1188
1608
  // its tenant + optional project pin — via the slug-free `/api/self/t/whoami`.
1189
1609
  if (!tenant || !project) {
1190
- const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
1610
+ const token = raw.token;
1191
1611
  if (token) {
1192
1612
  try {
1193
1613
  const who = await fetch(`${url}/api/self/t/whoami`, {
@@ -1203,7 +1623,7 @@ async function cmdChat(flags) {
1203
1623
  }
1204
1624
  }
1205
1625
  if (!tenant)
1206
- die('no tenant — set --tenant / PACK_TENANT / pack.json, or pass a --token to derive it');
1626
+ die('no tenant — set --tenant / PACK_TENANT, or log in so the token can supply it');
1207
1627
  if (!project)
1208
1628
  project = 'main';
1209
1629
  const from = flags.as ?? 'cli-tester';
@@ -1211,10 +1631,23 @@ async function cmdChat(flags) {
1211
1631
  const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
1212
1632
  const mediaArg = typeof flags.media === 'string' ? flags.media : undefined;
1213
1633
  const message = flags._[0];
1214
- if (!message && !tapId && !mediaArg)
1215
- die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]');
1216
- // Fresh idempotency key per call (see the command doc above).
1217
- const localId = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1634
+ const scriptArg = typeof flags.script === 'string' ? flags.script : undefined;
1635
+ let turns;
1636
+ if (scriptArg) {
1637
+ const scriptPath = resolve(scriptArg);
1638
+ if (!existsSync(scriptPath))
1639
+ die(`no such script file: ${scriptPath}`);
1640
+ turns = parseChatScript(readFileSync(scriptPath, 'utf8'));
1641
+ if (turns.length === 0)
1642
+ die(`${scriptPath} has no turns (blank lines and # comments are skipped)`);
1643
+ }
1644
+ else {
1645
+ if (!message && !tapId && !mediaArg) {
1646
+ die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]\n' +
1647
+ ' or: octwin chat --script <file> [--as <handle>] (one turn per line; "tap:<id>" / "media:<path>")');
1648
+ }
1649
+ turns = [{ ...(message ? { text: message } : {}), ...(tapId ? { tap: tapId } : {}), ...(mediaArg ? { media: mediaArg } : {}) }];
1650
+ }
1218
1651
  if (!asJson)
1219
1652
  console.log(`→ Connecting to ${tenant}/${project} as '${from}' …`);
1220
1653
  const evRes = await fetchOrDie(`${url}/api/web/events/${tenant}/${project}/${encodeURIComponent(from)}`, { headers: { accept: 'text/event-stream' } }, 'open chat stream');
@@ -1236,74 +1669,25 @@ async function cmdChat(flags) {
1236
1669
  if (f.id != null && f.id > boundary)
1237
1670
  boundary = f.id;
1238
1671
  }
1239
- // Phase 2 — send the inbound (media upload, an interactive tap, or text).
1240
- const inbound = `${url}/api/web/inbound/${tenant}/${project}`;
1241
- let postRes;
1242
- if (mediaArg && !tapId) {
1243
- // Multipart upload → the platform's media pipeline sets $inbound_media, which
1244
- // a running collect folds into $state.<field> (docs/19 §8a). Any accompanying
1245
- // message rides as the media caption.
1246
- const { blob, kind, filename } = await resolveMediaPart(url, mediaArg);
1247
- console.log(`→ [${from}] (media:${kind}) ${filename}${message ? ` — "${message}"` : ''}`);
1248
- const form = new FormData();
1249
- form.append('type', kind);
1250
- form.append('from', from);
1251
- form.append('local_id', localId);
1252
- if (message)
1253
- form.append('caption', message);
1254
- form.append('file', blob, filename);
1255
- postRes = await fetchOrDie(inbound, { method: 'POST', body: form }, 'send media'); // fetch sets the multipart boundary
1256
- }
1257
- else {
1258
- console.log(`→ [${from}] ${tapId ? `(tap) ${tapId}` : message}`);
1259
- const body = tapId
1260
- ? { type: 'interactive', from, tap_id: tapId, ...(message ? { raw_title: message } : {}), local_id: localId }
1261
- : { type: 'text', from, text: message, local_id: localId };
1262
- postRes = await fetchOrDie(inbound, {
1263
- method: 'POST',
1264
- headers: { 'content-type': 'application/json' },
1265
- body: JSON.stringify(body),
1266
- }, 'send message');
1267
- }
1268
- if (!postRes.ok) {
1269
- await cancel();
1270
- die(`send rejected (HTTP ${postRes.status}): ${await postRes.text()}`);
1271
- }
1272
- if (!asJson)
1273
- console.log(` … delivered — waiting for the reply (up to ${Math.round(REPLY_TIMEOUT_MS / 1000)}s)`);
1274
- // Phase 3 — collect THIS turn's renders (id > boundary). A turn can send
1275
- // several messages, so keep reading until a quiet gap after the last render.
1276
- const deadline = Date.now() + REPLY_TIMEOUT_MS;
1277
- let rendersSeen = 0;
1278
- for (;;) {
1279
- const remaining = deadline - Date.now();
1280
- if (remaining <= 0)
1281
- break;
1282
- const f = await frames.next(rendersSeen > 0 ? TURN_SETTLE_MS : Math.min(remaining, REPLY_TIMEOUT_MS));
1283
- if (f === 'timeout') {
1284
- if (rendersSeen > 0)
1285
- break;
1286
- else
1287
- continue;
1288
- }
1289
- if (f === 'done')
1290
- break;
1291
- if (f.id != null && f.id <= boundary)
1292
- continue; // late replay stragglers
1293
- if (asJson) {
1294
- console.log(JSON.stringify(f.ev));
1295
- if (f.ev?.kind === 'render')
1296
- rendersSeen++;
1297
- continue;
1672
+ // Phase 2+3run each turn in order, over the SAME connection. One turn is
1673
+ // the normal case; `--script` drives a whole conversation.
1674
+ let totalRenders = 0;
1675
+ for (const [i, turn] of turns.entries()) {
1676
+ if (turns.length > 1)
1677
+ console.log(`\n── turn ${i + 1}/${turns.length} ──`);
1678
+ const r = await runChatTurn({ url, tenant, project, from, turn, frames, boundary, asJson });
1679
+ boundary = r.boundary;
1680
+ totalRenders += r.rendersSeen;
1681
+ if (r.rendersSeen === 0 && turns.length > 1) {
1682
+ // Stop rather than fire the rest of the script into a conversation that
1683
+ // isn't answering — the remaining turns would land out of context.
1684
+ await cancel();
1685
+ console.error(` turn ${i + 1} produced no render after ${Math.round(REPLY_TIMEOUT_MS / 1000)}s — stopping the script here.`);
1686
+ process.exit(1);
1298
1687
  }
1299
- if (f.ev?.kind !== 'render')
1300
- continue; // status/typing noise
1301
- rendersSeen++;
1302
- console.log(`← ${f.ev.body ?? '(no text body)'}${f.ev.hint?.type && f.ev.hint.type !== 'text' ? ` (render: ${f.ev.hint.type})` : ''}`);
1303
- printHint(f.ev.hint);
1304
1688
  }
1305
1689
  await cancel();
1306
- if (rendersSeen === 0) {
1690
+ if (totalRenders === 0) {
1307
1691
  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.`);
1308
1692
  process.exit(1);
1309
1693
  }
@@ -1318,8 +1702,7 @@ async function cmdMedia(flags) {
1318
1702
  const sub = flags._[0];
1319
1703
  if (sub !== 'generate')
1320
1704
  die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
1321
- const packDir = resolve(flags.dir ?? '.');
1322
- const t = resolveTarget(flags, packDir);
1705
+ const t = resolveTarget(flags);
1323
1706
  const { url } = t;
1324
1707
  const prompt = flags._[1];
1325
1708
  if (!prompt)
@@ -1374,8 +1757,7 @@ async function cmdMedia(flags) {
1374
1757
  /** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
1375
1758
  * the aggregate inbox, one case + its timeline, or the queue list. */
1376
1759
  async function cmdCases(flags) {
1377
- const packDir = resolve(flags.dir ?? '.');
1378
- const t = resolveTarget(flags, packDir);
1760
+ const t = resolveTarget(flags);
1379
1761
  const { url } = t;
1380
1762
  const base = `${url}/api/self/p`;
1381
1763
  const caseId = flags._[0];
@@ -1517,8 +1899,7 @@ function printGoverned(label, g) {
1517
1899
  * EFFECTIVE model/memory settings and which layer won, plus (`--prompt`) the exact
1518
1900
  * system prompt the LLM sees for this project. Needs an `agents:read` token. */
1519
1901
  async function cmdAgents(flags) {
1520
- const packDir = resolve(flags.dir ?? '.');
1521
- const t = resolveTarget(flags, packDir);
1902
+ const t = resolveTarget(flags);
1522
1903
  const { url } = t;
1523
1904
  const base = `${url}/api/self/p/agents`;
1524
1905
  const ref = flags._[0];
@@ -1615,8 +1996,7 @@ function printPaymentNote(paymentStatus) {
1615
1996
  * the orders a conversation created: money breakdown, payment state, allowed
1616
1997
  * transitions. Needs an `orders:read` token + the `orders` plan feature. */
1617
1998
  async function cmdOrders(flags) {
1618
- const packDir = resolve(flags.dir ?? '.');
1619
- const t = resolveTarget(flags, packDir);
1999
+ const t = resolveTarget(flags);
1620
2000
  const { url } = t;
1621
2001
  const base = `${url}/api/self/p/orders`;
1622
2002
  const referenceId = flags._[0];
@@ -1689,8 +2069,7 @@ function printNoAnalyticsData(entity) {
1689
2069
  * [--stage <id>] [--json]` — stage conversion over ANY pipelined XRM entity
1690
2070
  * (orders, carts, cases, bookings, or a pack's own). Needs `records:read`. */
1691
2071
  async function cmdAnalytics(flags) {
1692
- const packDir = resolve(flags.dir ?? '.');
1693
- const t = resolveTarget(flags, packDir);
2072
+ const t = resolveTarget(flags);
1694
2073
  const { url } = t;
1695
2074
  const base = `${url}/api/self/p/xrm/analytics`;
1696
2075
  const entity = flags._[0];
@@ -1812,8 +2191,7 @@ async function cmdAnalytics(flags) {
1812
2191
  * sells, their stock, and the WhatsApp catalog binding. Needs `catalog:read` + the
1813
2192
  * `catalog` plan feature. */
1814
2193
  async function cmdCatalog(flags) {
1815
- const packDir = resolve(flags.dir ?? '.');
1816
- const t = resolveTarget(flags, packDir);
2194
+ const t = resolveTarget(flags);
1817
2195
  const { url } = t;
1818
2196
  const base = `${url}/api/self/p/catalog`;
1819
2197
  const asJson = flags.json === true;
@@ -1873,8 +2251,7 @@ async function cmdCatalog(flags) {
1873
2251
  * — the scheduling engine's state, or the computed slots for one bookable resource
1874
2252
  * (the verification the `--seed` availability fan-out was missing). `scheduling:read`. */
1875
2253
  async function cmdScheduling(flags) {
1876
- const packDir = resolve(flags.dir ?? '.');
1877
- const t = resolveTarget(flags, packDir);
2254
+ const t = resolveTarget(flags);
1878
2255
  const { url } = t;
1879
2256
  const base = `${url}/api/self/p/scheduling`;
1880
2257
  const asJson = flags.json === true;
@@ -1961,7 +2338,7 @@ Multi-turn: the platform keeps ONE open conversation per --as handle — consecu
1961
2338
  button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
1962
2339
  Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
1963
2340
  octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
1964
- Config (deploy): flags > pack.json > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login.
2341
+ Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
1965
2342
  Per-command usage: octwin <command> --help`);
1966
2343
  }
1967
2344
  /** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
@@ -1974,7 +2351,8 @@ const COMMAND_HELP = {
1974
2351
  manifest + flow-DSL validation (all errors at once) — same check as deploy.`,
1975
2352
  login: `octwin login --url <platformUrl> --token oct_…
1976
2353
  Save a deploy token (console → Settings → API tokens) for that platform url,
1977
- and echo the workspace + project pin + scopes the token reaches.`,
2354
+ make that url the DEFAULT deploy target for every later command, and echo the
2355
+ workspace + project pin + scopes the token reaches.`,
1978
2356
  whoami: `octwin whoami [--url <url>] [--tenant <slug>]
1979
2357
  Verify the resolved token authenticates against the tenant.`,
1980
2358
  deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
@@ -1992,14 +2370,37 @@ const COMMAND_HELP = {
1992
2370
  No id = recent conversations (handle, status, last activity; --as filters).
1993
2371
  With id = the full event timeline including what each turn rendered.
1994
2372
  --json = raw events (verbatim payloads).`,
2373
+ pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
2374
+ Write a DEPLOYED pack's source back to disk — the inverse of deploy.
2375
+ A pack pushed with 'octwin deploy' lives on the platform as an artifact the
2376
+ runtime serves but nothing hands back, so its only source copy is the machine
2377
+ that pushed it. Pull it, fix it, redeploy it.
2378
+ Defaults to the version installed on the target project; --version overrides.
2379
+ --dir defaults to ./<packId>; a non-empty dir needs --force.
2380
+ The pulled dir redeploys where it came from — the target is your saved login.
2381
+ You may pull a pack your tenant OWNS (deployed); an operator token pulls any.`,
1995
2382
  chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
1996
- Drive one turn through the dev web channel and print every render with its
2383
+ octwin chat --script <file> [--as <handle>] [--json]
2384
+ Drive ONE turn through the dev web channel and print every render with its
1997
2385
  tap ids. Same --as handle = same conversation (multi-turn works).
1998
2386
  --tap presses a rendered button/list row instead of sending text.
1999
2387
  --media uploads a local file (or a media id from 'media generate --json') as
2000
2388
  an image/document/audio inbound — any "message" rides as its caption; feeds a
2001
2389
  running media-collect flow (e.g. activate-app).
2002
- --json dumps the raw SSE envelopes for the turn.`,
2390
+ --json dumps the raw SSE envelopes for the turn.
2391
+
2392
+ --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
2393
+ process over one connection — waiting for each turn to settle before sending
2394
+ the next. Use this for any multi-step flow: chaining shell invocations races
2395
+ the agent loop, because a turn ends on a quiet gap that can arrive while the
2396
+ server is still working (the symptom is placeholder-filled fields or a second
2397
+ workflow run). Blank lines and # comments are skipped:
2398
+
2399
+ # book an appointment end to end
2400
+ احجز موعد
2401
+ tap:t:invoke:book-appointment:doctor_id=D1
2402
+ media:./licence.jpg | here is my licence
2403
+ tap:t:resume:book-appointment:run_id=R1;_ctl_approved=true`,
2003
2404
  media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
2004
2405
  AI-generate an image (needs a media:generate-scoped token), store it as a
2005
2406
  public asset, and print its MEDIA- handle + serve URL. --out downloads the
@@ -2066,6 +2467,9 @@ async function main() {
2066
2467
  case 'deploy':
2067
2468
  await cmdDeploy(flags);
2068
2469
  break;
2470
+ case 'pull':
2471
+ await cmdPull(flags);
2472
+ break;
2069
2473
  case 'status':
2070
2474
  await cmdStatus(flags);
2071
2475
  break;