octwin-cli 0.1.15 → 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/CHANGELOG.md +132 -0
- package/README.md +26 -22
- package/dist/index.js +704 -260
- package/dist/lib/args-check.js +110 -0
- package/dist/lib/pack-source.js +68 -0
- package/dist/lib/render-check.js +85 -0
- package/dist/lib/validate.js +50 -4
- package/package.json +1 -1
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
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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,29 +231,56 @@ 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
|
-
|
|
221
|
-
|
|
234
|
+
/** Per-blob / total ceilings, mirroring the server so oversize fails LOCALLY. */
|
|
235
|
+
const MAX_BLOB_BYTES = 2 * 1024 * 1024;
|
|
236
|
+
const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
237
|
+
/**
|
|
238
|
+
* Collect a pack directory into its two halves: `files` (`{ relPath: utf8 }`) and
|
|
239
|
+
* `blobs` (`{ relPath: base64 }`).
|
|
240
|
+
*
|
|
241
|
+
* Every file used to be read with `readFileSync(full, 'utf8')`, which silently
|
|
242
|
+
* MANGLED any committed image — the bytes went through a lossy UTF-8 decode and
|
|
243
|
+
* arrived corrupt. Binary files now split off into `blobs`, transported as base64
|
|
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.)
|
|
250
|
+
*/
|
|
222
251
|
function collectBundleFiles(packDir) {
|
|
223
252
|
const files = {};
|
|
253
|
+
const blobs = {};
|
|
254
|
+
let totalBlobBytes = 0;
|
|
224
255
|
const walk = (dir, prefix) => {
|
|
225
256
|
for (const name of readdirSync(dir)) {
|
|
226
257
|
const full = join(dir, name);
|
|
227
258
|
const rel = prefix ? `${prefix}/${name}` : name;
|
|
228
259
|
if (statSync(full).isDirectory()) {
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
260
|
+
if (!isSkippedDir(name))
|
|
261
|
+
walk(full, rel); // prune before descending
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const kind = classifyPackPath(rel);
|
|
265
|
+
if (kind === 'skip')
|
|
266
|
+
continue;
|
|
267
|
+
if (kind === 'blob') {
|
|
268
|
+
const buf = readFileSync(full);
|
|
269
|
+
if (buf.byteLength > MAX_BLOB_BYTES) {
|
|
270
|
+
die(`'${rel}' is ${(buf.byteLength / 1024 / 1024).toFixed(1)} MB — the per-file limit is ${MAX_BLOB_BYTES / 1024 / 1024} MB`);
|
|
271
|
+
}
|
|
272
|
+
totalBlobBytes += buf.byteLength;
|
|
273
|
+
blobs[rel] = buf.toString('base64');
|
|
232
274
|
continue;
|
|
233
275
|
}
|
|
234
|
-
if (name === 'pack.json')
|
|
235
|
-
continue; // deploy config, not part of the pack
|
|
236
|
-
if (name.startsWith('.'))
|
|
237
|
-
continue; // .gitignore etc. — not pack content
|
|
238
276
|
files[rel] = readFileSync(full, 'utf8');
|
|
239
277
|
}
|
|
240
278
|
};
|
|
241
279
|
walk(packDir, '');
|
|
242
|
-
|
|
280
|
+
if (totalBlobBytes > MAX_ARTIFACT_BYTES) {
|
|
281
|
+
die(`binary payload is ${(totalBlobBytes / 1024 / 1024).toFixed(1)} MB — the per-pack limit is ${MAX_ARTIFACT_BYTES / 1024 / 1024} MB`);
|
|
282
|
+
}
|
|
283
|
+
return { files, blobs };
|
|
243
284
|
}
|
|
244
285
|
function readManifestIdVersion(files) {
|
|
245
286
|
const raw = files['manifest.yaml'];
|
|
@@ -251,17 +292,18 @@ function readManifestIdVersion(files) {
|
|
|
251
292
|
}
|
|
252
293
|
return { id: doc.id, version: doc.version };
|
|
253
294
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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';
|
|
265
307
|
function credsPath() { return join(homedir(), '.octwin', 'credentials.json'); }
|
|
266
308
|
function readCreds() {
|
|
267
309
|
try {
|
|
@@ -275,6 +317,8 @@ function writeCreds(map) {
|
|
|
275
317
|
mkdirSync(join(homedir(), '.octwin'), { recursive: true });
|
|
276
318
|
writeFileSync(credsPath(), JSON.stringify(map, null, 2), 'utf8');
|
|
277
319
|
}
|
|
320
|
+
/** The platform url of the last `octwin login` — the default target. */
|
|
321
|
+
function savedDefaultUrl() { return readCreds()[DEFAULT_URL_KEY] ?? ''; }
|
|
278
322
|
// ── update check (daily, fail-silent, TTY-only) ──────────────────────────────
|
|
279
323
|
function updateCachePath() { return join(homedir(), '.octwin', 'update-check.json'); }
|
|
280
324
|
/** True when semver `a` is strictly greater than `b` (simple x.y.z compare). */
|
|
@@ -389,7 +433,7 @@ async function notifyIfKbStale(flags) {
|
|
|
389
433
|
const local = readLocalKb(packDir);
|
|
390
434
|
if (!local?.content_hash)
|
|
391
435
|
return; // never pulled → the skill already says to pull
|
|
392
|
-
const t = resolveTargetOrNull(flags
|
|
436
|
+
const t = resolveTargetOrNull(flags);
|
|
393
437
|
if (!t)
|
|
394
438
|
return;
|
|
395
439
|
const ctrl = new AbortController();
|
|
@@ -432,6 +476,7 @@ function commandTouchesPlatform(command, flags) {
|
|
|
432
476
|
case 'test':
|
|
433
477
|
case 'chat':
|
|
434
478
|
case 'media':
|
|
479
|
+
case 'pull':
|
|
435
480
|
case 'records':
|
|
436
481
|
case 'cases':
|
|
437
482
|
case 'logs':
|
|
@@ -465,13 +510,9 @@ function cmdInit(flags) {
|
|
|
465
510
|
description: flags.description ?? undefined,
|
|
466
511
|
displayName: flags['display-name'] ?? undefined,
|
|
467
512
|
});
|
|
468
|
-
//
|
|
469
|
-
// The
|
|
470
|
-
//
|
|
471
|
-
// overrides (they also seed `octwin chat`, which is tenant/project-pathed).
|
|
472
|
-
writeFileSync(join(dir, 'pack.json'), JSON.stringify({
|
|
473
|
-
platform_url: 'http://localhost:3000',
|
|
474
|
-
}, 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.
|
|
475
516
|
writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.pack-bundles/\n.octwin/\n', 'utf8');
|
|
476
517
|
if (!existsSync(join(dir, 'README.md'))) {
|
|
477
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');
|
|
@@ -482,25 +523,74 @@ function cmdInit(flags) {
|
|
|
482
523
|
console.log(' git init && git add -A && git commit -m "init pack"');
|
|
483
524
|
console.log(' # edit manifest.yaml / flows / prompts, then:');
|
|
484
525
|
console.log(' octwin validate');
|
|
485
|
-
console.log(' #
|
|
526
|
+
console.log(' # point the CLI at your platform (the token carries the tenant):');
|
|
486
527
|
console.log(' octwin login --url <platformUrl> --token <deploy-token>');
|
|
487
528
|
console.log(' octwin deploy');
|
|
488
529
|
}
|
|
489
530
|
function localValidate(packDir) {
|
|
490
|
-
const files = collectBundleFiles(packDir);
|
|
531
|
+
const { files, blobs } = collectBundleFiles(packDir);
|
|
491
532
|
const { id, version } = readManifestIdVersion(files);
|
|
492
|
-
const r = validatePackBundle(id, files);
|
|
533
|
+
const r = validatePackBundle(id, files, blobs);
|
|
493
534
|
if (!r.ok) {
|
|
494
535
|
for (const e of r.errors)
|
|
495
536
|
console.error(` ✗ ${e}`);
|
|
496
537
|
die(`bundle validation failed (${r.errors.length} error${r.errors.length === 1 ? '' : 's'})`);
|
|
497
538
|
}
|
|
498
|
-
return { id, version, files };
|
|
539
|
+
return { id, version, files, blobs };
|
|
499
540
|
}
|
|
500
541
|
async function cmdValidate(flags) {
|
|
501
542
|
const packDir = resolve(flags.dir ?? '.');
|
|
502
|
-
const { id, version, files } = localValidate(packDir); // offline structural gate first (fast, no server/token)
|
|
503
|
-
console.log(`✓ ${id}@${version} passes the offline structural check (${Object.keys(files).length} files)`);
|
|
543
|
+
const { id, version, files, blobs } = localValidate(packDir); // offline structural gate first (fast, no server/token)
|
|
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
|
+
}
|
|
504
594
|
if (flags.remote !== true) {
|
|
505
595
|
console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
|
|
506
596
|
console.log(' (all errors at once) before you deploy.');
|
|
@@ -508,13 +598,13 @@ async function cmdValidate(flags) {
|
|
|
508
598
|
}
|
|
509
599
|
// Remote: the SAME validation the deploy route runs — manifest `.strict()` +
|
|
510
600
|
// every flow (schema/expression/structure) — returning ALL errors at once.
|
|
511
|
-
const t = resolveTarget(flags
|
|
601
|
+
const t = resolveTarget(flags);
|
|
512
602
|
const { url } = t;
|
|
513
603
|
console.log(`→ Validating against ${targetLabel(t)} @ ${url} …`);
|
|
514
604
|
const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
|
|
515
605
|
method: 'POST',
|
|
516
606
|
headers: { 'content-type': 'application/json', ...authHeaders(t) },
|
|
517
|
-
body: JSON.stringify({ files }),
|
|
607
|
+
body: JSON.stringify({ files, blobs }),
|
|
518
608
|
}, 'remote validate');
|
|
519
609
|
const text = await res.text();
|
|
520
610
|
let json;
|
|
@@ -564,8 +654,9 @@ async function cmdLogin(flags) {
|
|
|
564
654
|
const token = flags.token ?? process.env.PACK_TOKEN ?? die('missing --token');
|
|
565
655
|
const creds = readCreds();
|
|
566
656
|
creds[url] = token;
|
|
657
|
+
creds[DEFAULT_URL_KEY] = url; // login sets the default deploy target
|
|
567
658
|
writeCreds(creds);
|
|
568
|
-
console.log(`✓ Saved token for ${url}`);
|
|
659
|
+
console.log(`✓ Saved token for ${url} — now the default target`);
|
|
569
660
|
// Best-effort: echo what the token reaches (workspace + project pin + scopes)
|
|
570
661
|
// so a fresh token self-identifies without a second `octwin whoami`. A network
|
|
571
662
|
// failure never fails the save — the token is stored regardless.
|
|
@@ -592,36 +683,35 @@ function authHeaders(t) {
|
|
|
592
683
|
return h;
|
|
593
684
|
}
|
|
594
685
|
/** Resolve platform url + token (+ optional tenant/project overrides):
|
|
595
|
-
* flags >
|
|
596
|
-
*
|
|
597
|
-
function resolveTarget(flags
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
|
|
603
|
-
if (!url)
|
|
604
|
-
die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
|
|
605
|
-
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)
|
|
606
693
|
die('no token — generate an API token in the console (Settings → API tokens), then `octwin login --url <url> --token oct_…` or pass --token');
|
|
607
|
-
return
|
|
694
|
+
return t;
|
|
608
695
|
}
|
|
609
696
|
/** Non-fatal `resolveTarget`: returns null (never dies) when url or token is
|
|
610
697
|
* missing. Used by the fail-silent KB-staleness observer, which must never
|
|
611
698
|
* interrupt a command over a config gap. */
|
|
612
|
-
function resolveTargetOrNull(flags
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
+
};
|
|
621
712
|
}
|
|
622
713
|
async function cmdWhoami(flags) {
|
|
623
|
-
const
|
|
624
|
-
const t = resolveTarget(flags, packDir);
|
|
714
|
+
const t = resolveTarget(flags);
|
|
625
715
|
console.log(`→ Checking the token against ${t.url} …`);
|
|
626
716
|
const res = await fetchOrDie(`${t.url}/api/self/t/whoami`, { headers: authHeaders(t) }, 'token check');
|
|
627
717
|
if (res.ok) {
|
|
@@ -636,6 +726,73 @@ async function cmdWhoami(flags) {
|
|
|
636
726
|
: await res.text();
|
|
637
727
|
die(`token check failed (HTTP ${res.status}) — ${why}`);
|
|
638
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
|
+
}
|
|
639
796
|
/**
|
|
640
797
|
* Read the deploy SSE stream, printing each progress frame's message live, and
|
|
641
798
|
* return the terminal `done`/`error` event (or null if the stream ended without
|
|
@@ -703,13 +860,20 @@ function printDeploySuccess(id, version, t, r) {
|
|
|
703
860
|
if (parts.length)
|
|
704
861
|
console.log(` Seeded: ${parts.join(', ')}`);
|
|
705
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
|
+
}
|
|
706
870
|
console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
|
|
707
871
|
}
|
|
708
872
|
async function cmdDeploy(flags) {
|
|
709
873
|
const packDir = resolve(flags.dir ?? '.');
|
|
710
|
-
const t = resolveTarget(flags
|
|
874
|
+
const t = resolveTarget(flags);
|
|
711
875
|
const { url } = t;
|
|
712
|
-
const { id, version, files } = localValidate(packDir);
|
|
876
|
+
const { id, version, files, blobs } = localValidate(packDir);
|
|
713
877
|
const endpoint = `${url}/api/self/p/packs/deploy`;
|
|
714
878
|
const seed = flags.seed === true;
|
|
715
879
|
console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${seed ? ' — with demo seed' : ''} …`);
|
|
@@ -718,7 +882,7 @@ async function cmdDeploy(flags) {
|
|
|
718
882
|
// Ask for a progress stream; the platform falls back to plain JSON if it
|
|
719
883
|
// (or an error before any progress) can't stream — handled below.
|
|
720
884
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
|
|
721
|
-
body: JSON.stringify({ files, seed }),
|
|
885
|
+
body: JSON.stringify({ files, blobs, seed }),
|
|
722
886
|
}, 'deploy');
|
|
723
887
|
// Streaming path — live install + seed progress (image generation can take a
|
|
724
888
|
// while, so `--seed` prints per-record / per-image lines as they happen).
|
|
@@ -758,7 +922,7 @@ async function cmdDeploy(flags) {
|
|
|
758
922
|
}
|
|
759
923
|
async function cmdStatus(flags) {
|
|
760
924
|
const packDir = resolve(flags.dir ?? '.');
|
|
761
|
-
const t = resolveTarget(flags
|
|
925
|
+
const t = resolveTarget(flags);
|
|
762
926
|
const { url } = t;
|
|
763
927
|
const manifestPath = join(packDir, 'manifest.yaml');
|
|
764
928
|
if (!existsSync(manifestPath))
|
|
@@ -790,13 +954,22 @@ async function cmdStatus(flags) {
|
|
|
790
954
|
}
|
|
791
955
|
console.log(`${id} on ${targetLabel(t)} @ ${url}`);
|
|
792
956
|
console.log(` installed version : ${json.installed_version}`);
|
|
793
|
-
|
|
957
|
+
// Reads the CONTENT SHA, not a version string. It printed `loaded=${json.loaded_version}` — a
|
|
958
|
+
// field that stopped existing when reload moved to sha keying, so this line always said
|
|
959
|
+
// `(none)` and the drift warning below could never fire. The sha is also the more useful fact:
|
|
960
|
+
// re-publishing the SAME version changes it, which is exactly the author's inner loop.
|
|
961
|
+
const shortSha = (s) => (typeof s === 'string' && s ? s.slice(0, 12) + '…' : '(none)');
|
|
962
|
+
console.log(` live on instance : registered=${json.registered} loaded=${shortSha(json.loaded_content_sha)}`);
|
|
963
|
+
console.log(` catalog artifact : ${shortSha(json.catalog_content_sha)}${json.origin ? ` origin=${json.origin}` : ''}`);
|
|
794
964
|
console.log(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
|
|
795
965
|
if (!json.registered) {
|
|
796
966
|
console.log('\n… not warm on the instance you hit yet — it loads on the next inbound (chat once, then re-check).');
|
|
797
967
|
}
|
|
798
|
-
else if (json.
|
|
799
|
-
console.log(
|
|
968
|
+
else if (json.up_to_date === false) {
|
|
969
|
+
console.log('\n⚠ this instance is running an OLDER artifact than the catalog holds — it picks up the current one on the next inbound turn (chat once, then re-check).');
|
|
970
|
+
}
|
|
971
|
+
else if (json.catalog_status === 'withdrawn') {
|
|
972
|
+
console.log('\n⚠ live and current, but the pack is WITHDRAWN from the catalog — existing installs keep running; new installs are refused.');
|
|
800
973
|
}
|
|
801
974
|
else {
|
|
802
975
|
console.log('\n✓ live and current.');
|
|
@@ -805,9 +978,130 @@ async function cmdStatus(flags) {
|
|
|
805
978
|
console.log(` (local manifest is ${localVersion}; deployed is ${json.installed_version} — \`octwin deploy\` to push local edits.)`);
|
|
806
979
|
}
|
|
807
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
|
+
}
|
|
808
1102
|
async function cmdPlatformKb(flags) {
|
|
809
1103
|
const packDir = resolve(flags.dir ?? '.');
|
|
810
|
-
const t = resolveTarget(flags
|
|
1104
|
+
const t = resolveTarget(flags);
|
|
811
1105
|
const { url } = t;
|
|
812
1106
|
console.log(`→ Pulling the platform capability reference from ${url} …`);
|
|
813
1107
|
const res = await fetchOrDie(`${url}/api/self/t/octwin-platform-kb`, {
|
|
@@ -832,27 +1126,65 @@ async function cmdPlatformKb(flags) {
|
|
|
832
1126
|
const prior = readLocalKb(packDir);
|
|
833
1127
|
// Write the reference into <packDir>/.octwin/platform-kb/ — markdown docs (the
|
|
834
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.
|
|
835
1139
|
const outDir = join(packDir, '.octwin', 'platform-kb');
|
|
836
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
|
+
}
|
|
837
1147
|
let mdCount = 0;
|
|
838
|
-
let jsonCount = 0;
|
|
839
1148
|
for (const [key, val] of Object.entries(bundle.docs ?? {})) {
|
|
840
1149
|
if (val == null)
|
|
841
1150
|
continue;
|
|
842
1151
|
writeFileSync(join(outDir, `${key}.md`), val, 'utf8');
|
|
843
1152
|
mdCount++;
|
|
844
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;
|
|
845
1162
|
for (const [key, val] of Object.entries(bundle.sources ?? {})) {
|
|
846
1163
|
if (val == null)
|
|
847
1164
|
continue;
|
|
848
|
-
|
|
849
|
-
|
|
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;
|
|
850
1179
|
}
|
|
1180
|
+
// The map the authoring skill reads first.
|
|
1181
|
+
writeFileSync(join(outDir, 'INDEX.md'), buildKbIndexMarkdown(bundle, exploded), 'utf8');
|
|
851
1182
|
// Persist `content_hash` too — the staleness observer (`notifyIfKbStale`) reads
|
|
852
1183
|
// it back and compares against the platform's current hash to nudge a re-pull.
|
|
853
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');
|
|
854
1185
|
console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
|
|
855
|
-
console.log(` ${mdCount} markdown docs + ${
|
|
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.');
|
|
856
1188
|
// Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
|
|
857
1189
|
// moved (a schema shape being replaced shows as a `~ changed`), not just a count.
|
|
858
1190
|
if (prior?.content_hash) {
|
|
@@ -895,8 +1227,7 @@ function targetLabel(t) {
|
|
|
895
1227
|
}
|
|
896
1228
|
/** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
|
|
897
1229
|
async function cmdRecords(flags) {
|
|
898
|
-
const
|
|
899
|
-
const t = resolveTarget(flags, packDir);
|
|
1230
|
+
const t = resolveTarget(flags);
|
|
900
1231
|
const { url } = t;
|
|
901
1232
|
const base = `${url}/api/self/p`;
|
|
902
1233
|
const entity = flags._[0];
|
|
@@ -916,8 +1247,10 @@ async function cmdRecords(flags) {
|
|
|
916
1247
|
return;
|
|
917
1248
|
}
|
|
918
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".
|
|
919
1252
|
for (const e of ents)
|
|
920
|
-
console.log(` ${e.entity} (${e.open_count ?? 0}
|
|
1253
|
+
console.log(` ${e.entity} (${e.open_count ?? 0} open)`);
|
|
921
1254
|
console.log('\nList records: octwin records <entity>');
|
|
922
1255
|
return;
|
|
923
1256
|
}
|
|
@@ -950,8 +1283,7 @@ async function cmdRecords(flags) {
|
|
|
950
1283
|
/** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
|
|
951
1284
|
* or show one's event timeline (full text + the renders each turn produced). */
|
|
952
1285
|
async function cmdLogs(flags) {
|
|
953
|
-
const
|
|
954
|
-
const t = resolveTarget(flags, packDir);
|
|
1286
|
+
const t = resolveTarget(flags);
|
|
955
1287
|
const { url } = t;
|
|
956
1288
|
const base = `${url}/api/self/p`;
|
|
957
1289
|
const convId = flags._[0];
|
|
@@ -1125,8 +1457,135 @@ class SseFrameReader {
|
|
|
1125
1457
|
const REPLAY_SETTLE_MS = 400; // quiet gap that marks the end of the connect replay burst
|
|
1126
1458
|
const TURN_SETTLE_MS = 2_000; // quiet gap after a render = the turn finished sending
|
|
1127
1459
|
const REPLY_TIMEOUT_MS = 45_000; // hard cap waiting for the first render of the turn
|
|
1128
|
-
/**
|
|
1129
|
-
*
|
|
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.
|
|
1130
1589
|
*
|
|
1131
1590
|
* Multi-turn works: the platform keeps ONE open conversation per handle, so the
|
|
1132
1591
|
* same `--as` continues the same conversation. Two traps this command handles:
|
|
@@ -1136,18 +1595,19 @@ const REPLY_TIMEOUT_MS = 45_000; // hard cap waiting for the first render of the
|
|
|
1136
1595
|
* drain the replay first and only accept frames newer than it as the reply
|
|
1137
1596
|
* (naively printing the first render showed LAST turn's message again). */
|
|
1138
1597
|
async function cmdChat(flags) {
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
const
|
|
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;
|
|
1142
1602
|
if (!url)
|
|
1143
|
-
die('no platform url —
|
|
1144
|
-
let tenant =
|
|
1145
|
-
let 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 ?? '';
|
|
1146
1606
|
// The dev web channel is tenant/project-pathed (it simulates an end-user on a
|
|
1147
1607
|
// specific project). When they aren't configured, derive them from the token —
|
|
1148
1608
|
// its tenant + optional project pin — via the slug-free `/api/self/t/whoami`.
|
|
1149
1609
|
if (!tenant || !project) {
|
|
1150
|
-
const token =
|
|
1610
|
+
const token = raw.token;
|
|
1151
1611
|
if (token) {
|
|
1152
1612
|
try {
|
|
1153
1613
|
const who = await fetch(`${url}/api/self/t/whoami`, {
|
|
@@ -1163,7 +1623,7 @@ async function cmdChat(flags) {
|
|
|
1163
1623
|
}
|
|
1164
1624
|
}
|
|
1165
1625
|
if (!tenant)
|
|
1166
|
-
die('no tenant — set --tenant / PACK_TENANT
|
|
1626
|
+
die('no tenant — set --tenant / PACK_TENANT, or log in so the token can supply it');
|
|
1167
1627
|
if (!project)
|
|
1168
1628
|
project = 'main';
|
|
1169
1629
|
const from = flags.as ?? 'cli-tester';
|
|
@@ -1171,10 +1631,23 @@ async function cmdChat(flags) {
|
|
|
1171
1631
|
const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
|
|
1172
1632
|
const mediaArg = typeof flags.media === 'string' ? flags.media : undefined;
|
|
1173
1633
|
const message = flags._[0];
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
+
}
|
|
1178
1651
|
if (!asJson)
|
|
1179
1652
|
console.log(`→ Connecting to ${tenant}/${project} as '${from}' …`);
|
|
1180
1653
|
const evRes = await fetchOrDie(`${url}/api/web/events/${tenant}/${project}/${encodeURIComponent(from)}`, { headers: { accept: 'text/event-stream' } }, 'open chat stream');
|
|
@@ -1196,74 +1669,25 @@ async function cmdChat(flags) {
|
|
|
1196
1669
|
if (f.id != null && f.id > boundary)
|
|
1197
1670
|
boundary = f.id;
|
|
1198
1671
|
}
|
|
1199
|
-
// Phase 2 —
|
|
1200
|
-
|
|
1201
|
-
let
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
form.append('file', blob, filename);
|
|
1215
|
-
postRes = await fetchOrDie(inbound, { method: 'POST', body: form }, 'send media'); // fetch sets the multipart boundary
|
|
1216
|
-
}
|
|
1217
|
-
else {
|
|
1218
|
-
console.log(`→ [${from}] ${tapId ? `(tap) ${tapId}` : message}`);
|
|
1219
|
-
const body = tapId
|
|
1220
|
-
? { type: 'interactive', from, tap_id: tapId, ...(message ? { raw_title: message } : {}), local_id: localId }
|
|
1221
|
-
: { type: 'text', from, text: message, local_id: localId };
|
|
1222
|
-
postRes = await fetchOrDie(inbound, {
|
|
1223
|
-
method: 'POST',
|
|
1224
|
-
headers: { 'content-type': 'application/json' },
|
|
1225
|
-
body: JSON.stringify(body),
|
|
1226
|
-
}, 'send message');
|
|
1227
|
-
}
|
|
1228
|
-
if (!postRes.ok) {
|
|
1229
|
-
await cancel();
|
|
1230
|
-
die(`send rejected (HTTP ${postRes.status}): ${await postRes.text()}`);
|
|
1231
|
-
}
|
|
1232
|
-
if (!asJson)
|
|
1233
|
-
console.log(` … delivered — waiting for the reply (up to ${Math.round(REPLY_TIMEOUT_MS / 1000)}s)`);
|
|
1234
|
-
// Phase 3 — collect THIS turn's renders (id > boundary). A turn can send
|
|
1235
|
-
// several messages, so keep reading until a quiet gap after the last render.
|
|
1236
|
-
const deadline = Date.now() + REPLY_TIMEOUT_MS;
|
|
1237
|
-
let rendersSeen = 0;
|
|
1238
|
-
for (;;) {
|
|
1239
|
-
const remaining = deadline - Date.now();
|
|
1240
|
-
if (remaining <= 0)
|
|
1241
|
-
break;
|
|
1242
|
-
const f = await frames.next(rendersSeen > 0 ? TURN_SETTLE_MS : Math.min(remaining, REPLY_TIMEOUT_MS));
|
|
1243
|
-
if (f === 'timeout') {
|
|
1244
|
-
if (rendersSeen > 0)
|
|
1245
|
-
break;
|
|
1246
|
-
else
|
|
1247
|
-
continue;
|
|
1248
|
-
}
|
|
1249
|
-
if (f === 'done')
|
|
1250
|
-
break;
|
|
1251
|
-
if (f.id != null && f.id <= boundary)
|
|
1252
|
-
continue; // late replay stragglers
|
|
1253
|
-
if (asJson) {
|
|
1254
|
-
console.log(JSON.stringify(f.ev));
|
|
1255
|
-
if (f.ev?.kind === 'render')
|
|
1256
|
-
rendersSeen++;
|
|
1257
|
-
continue;
|
|
1672
|
+
// Phase 2+3 — run 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);
|
|
1258
1687
|
}
|
|
1259
|
-
if (f.ev?.kind !== 'render')
|
|
1260
|
-
continue; // status/typing noise
|
|
1261
|
-
rendersSeen++;
|
|
1262
|
-
console.log(`← ${f.ev.body ?? '(no text body)'}${f.ev.hint?.type && f.ev.hint.type !== 'text' ? ` (render: ${f.ev.hint.type})` : ''}`);
|
|
1263
|
-
printHint(f.ev.hint);
|
|
1264
1688
|
}
|
|
1265
1689
|
await cancel();
|
|
1266
|
-
if (
|
|
1690
|
+
if (totalRenders === 0) {
|
|
1267
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.`);
|
|
1268
1692
|
process.exit(1);
|
|
1269
1693
|
}
|
|
@@ -1278,8 +1702,7 @@ async function cmdMedia(flags) {
|
|
|
1278
1702
|
const sub = flags._[0];
|
|
1279
1703
|
if (sub !== 'generate')
|
|
1280
1704
|
die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
|
|
1281
|
-
const
|
|
1282
|
-
const t = resolveTarget(flags, packDir);
|
|
1705
|
+
const t = resolveTarget(flags);
|
|
1283
1706
|
const { url } = t;
|
|
1284
1707
|
const prompt = flags._[1];
|
|
1285
1708
|
if (!prompt)
|
|
@@ -1334,8 +1757,7 @@ async function cmdMedia(flags) {
|
|
|
1334
1757
|
/** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
|
|
1335
1758
|
* the aggregate inbox, one case + its timeline, or the queue list. */
|
|
1336
1759
|
async function cmdCases(flags) {
|
|
1337
|
-
const
|
|
1338
|
-
const t = resolveTarget(flags, packDir);
|
|
1760
|
+
const t = resolveTarget(flags);
|
|
1339
1761
|
const { url } = t;
|
|
1340
1762
|
const base = `${url}/api/self/p`;
|
|
1341
1763
|
const caseId = flags._[0];
|
|
@@ -1477,8 +1899,7 @@ function printGoverned(label, g) {
|
|
|
1477
1899
|
* EFFECTIVE model/memory settings and which layer won, plus (`--prompt`) the exact
|
|
1478
1900
|
* system prompt the LLM sees for this project. Needs an `agents:read` token. */
|
|
1479
1901
|
async function cmdAgents(flags) {
|
|
1480
|
-
const
|
|
1481
|
-
const t = resolveTarget(flags, packDir);
|
|
1902
|
+
const t = resolveTarget(flags);
|
|
1482
1903
|
const { url } = t;
|
|
1483
1904
|
const base = `${url}/api/self/p/agents`;
|
|
1484
1905
|
const ref = flags._[0];
|
|
@@ -1575,8 +1996,7 @@ function printPaymentNote(paymentStatus) {
|
|
|
1575
1996
|
* the orders a conversation created: money breakdown, payment state, allowed
|
|
1576
1997
|
* transitions. Needs an `orders:read` token + the `orders` plan feature. */
|
|
1577
1998
|
async function cmdOrders(flags) {
|
|
1578
|
-
const
|
|
1579
|
-
const t = resolveTarget(flags, packDir);
|
|
1999
|
+
const t = resolveTarget(flags);
|
|
1580
2000
|
const { url } = t;
|
|
1581
2001
|
const base = `${url}/api/self/p/orders`;
|
|
1582
2002
|
const referenceId = flags._[0];
|
|
@@ -1649,8 +2069,7 @@ function printNoAnalyticsData(entity) {
|
|
|
1649
2069
|
* [--stage <id>] [--json]` — stage conversion over ANY pipelined XRM entity
|
|
1650
2070
|
* (orders, carts, cases, bookings, or a pack's own). Needs `records:read`. */
|
|
1651
2071
|
async function cmdAnalytics(flags) {
|
|
1652
|
-
const
|
|
1653
|
-
const t = resolveTarget(flags, packDir);
|
|
2072
|
+
const t = resolveTarget(flags);
|
|
1654
2073
|
const { url } = t;
|
|
1655
2074
|
const base = `${url}/api/self/p/xrm/analytics`;
|
|
1656
2075
|
const entity = flags._[0];
|
|
@@ -1772,8 +2191,7 @@ async function cmdAnalytics(flags) {
|
|
|
1772
2191
|
* sells, their stock, and the WhatsApp catalog binding. Needs `catalog:read` + the
|
|
1773
2192
|
* `catalog` plan feature. */
|
|
1774
2193
|
async function cmdCatalog(flags) {
|
|
1775
|
-
const
|
|
1776
|
-
const t = resolveTarget(flags, packDir);
|
|
2194
|
+
const t = resolveTarget(flags);
|
|
1777
2195
|
const { url } = t;
|
|
1778
2196
|
const base = `${url}/api/self/p/catalog`;
|
|
1779
2197
|
const asJson = flags.json === true;
|
|
@@ -1833,8 +2251,7 @@ async function cmdCatalog(flags) {
|
|
|
1833
2251
|
* — the scheduling engine's state, or the computed slots for one bookable resource
|
|
1834
2252
|
* (the verification the `--seed` availability fan-out was missing). `scheduling:read`. */
|
|
1835
2253
|
async function cmdScheduling(flags) {
|
|
1836
|
-
const
|
|
1837
|
-
const t = resolveTarget(flags, packDir);
|
|
2254
|
+
const t = resolveTarget(flags);
|
|
1838
2255
|
const { url } = t;
|
|
1839
2256
|
const base = `${url}/api/self/p/scheduling`;
|
|
1840
2257
|
const asJson = flags.json === true;
|
|
@@ -1894,110 +2311,134 @@ async function cmdScheduling(flags) {
|
|
|
1894
2311
|
console.log('\nSlots for one resource: octwin scheduling --slots <resourceRecordId> (ids: octwin records <entity>)');
|
|
1895
2312
|
}
|
|
1896
2313
|
function help() {
|
|
1897
|
-
console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
|
|
1898
|
-
|
|
1899
|
-
octwin --version # print the CLI version (+ any upgrade notice)
|
|
1900
|
-
octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
|
|
1901
|
-
octwin validate [--dir .] [--remote] # --remote runs the platform's FULL schema check (all errors at once)
|
|
1902
|
-
octwin login --url <platformUrl> --token oct_… # a deploy token from the console
|
|
1903
|
-
octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
|
|
1904
|
-
octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
1905
|
-
octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
|
|
1906
|
-
octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
|
|
1907
|
-
octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
|
|
1908
|
-
octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
|
|
1909
|
-
octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
|
|
1910
|
-
octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
|
|
1911
|
-
octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
|
|
1912
|
-
octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
|
|
1913
|
-
octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
|
|
1914
|
-
octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
|
|
1915
|
-
octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
|
|
1916
|
-
octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
1917
|
-
octwin test [--dir .] # = validate --remote (the full platform check)
|
|
1918
|
-
|
|
1919
|
-
Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
|
|
1920
|
-
\`octwin chat --as <h>\` calls continue the same conversation; press a rendered
|
|
1921
|
-
button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
|
|
1922
|
-
Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
|
|
1923
|
-
octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
|
|
1924
|
-
Config (deploy): flags >
|
|
2314
|
+
console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
|
|
2315
|
+
|
|
2316
|
+
octwin --version # print the CLI version (+ any upgrade notice)
|
|
2317
|
+
octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
|
|
2318
|
+
octwin validate [--dir .] [--remote] # --remote runs the platform's FULL schema check (all errors at once)
|
|
2319
|
+
octwin login --url <platformUrl> --token oct_… # a deploy token from the console
|
|
2320
|
+
octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
|
|
2321
|
+
octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
2322
|
+
octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
|
|
2323
|
+
octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
|
|
2324
|
+
octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
|
|
2325
|
+
octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
|
|
2326
|
+
octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
|
|
2327
|
+
octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
|
|
2328
|
+
octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
|
|
2329
|
+
octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
|
|
2330
|
+
octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
|
|
2331
|
+
octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
|
|
2332
|
+
octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
|
|
2333
|
+
octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
2334
|
+
octwin test [--dir .] # = validate --remote (the full platform check)
|
|
2335
|
+
|
|
2336
|
+
Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
|
|
2337
|
+
\`octwin chat --as <h>\` calls continue the same conversation; press a rendered
|
|
2338
|
+
button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
|
|
2339
|
+
Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
|
|
2340
|
+
octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
|
|
2341
|
+
Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
|
|
1925
2342
|
Per-command usage: octwin <command> --help`);
|
|
1926
2343
|
}
|
|
1927
2344
|
/** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
|
|
1928
2345
|
* network/auth work (a --help that 401s is worse than no help at all). */
|
|
1929
2346
|
const COMMAND_HELP = {
|
|
1930
|
-
init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
|
|
2347
|
+
init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
|
|
1931
2348
|
Scaffold a pure-YAML starter pack into <dir>.`,
|
|
1932
|
-
validate: `octwin validate [--dir .] [--remote]
|
|
1933
|
-
Offline structural check; --remote additionally runs the platform's FULL
|
|
2349
|
+
validate: `octwin validate [--dir .] [--remote]
|
|
2350
|
+
Offline structural check; --remote additionally runs the platform's FULL
|
|
1934
2351
|
manifest + flow-DSL validation (all errors at once) — same check as deploy.`,
|
|
1935
|
-
login: `octwin login --url <platformUrl> --token oct_…
|
|
1936
|
-
Save a deploy token (console → Settings → API tokens) for that platform url,
|
|
1937
|
-
|
|
1938
|
-
|
|
2352
|
+
login: `octwin login --url <platformUrl> --token oct_…
|
|
2353
|
+
Save a deploy token (console → Settings → API tokens) for that platform url,
|
|
2354
|
+
make that url the DEFAULT deploy target for every later command, and echo the
|
|
2355
|
+
workspace + project pin + scopes the token reaches.`,
|
|
2356
|
+
whoami: `octwin whoami [--url <url>] [--tenant <slug>]
|
|
1939
2357
|
Verify the resolved token authenticates against the tenant.`,
|
|
1940
|
-
deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
1941
|
-
Upload the pack bundle, validate server-side, install onto the project.
|
|
2358
|
+
deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
2359
|
+
Upload the pack bundle, validate server-side, install onto the project.
|
|
1942
2360
|
--seed additionally applies the pack's demo seed (streams progress).`,
|
|
1943
|
-
status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
|
|
2361
|
+
status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
|
|
1944
2362
|
Show installed vs live version + the flow list for this pack.`,
|
|
1945
|
-
records: `octwin records [entity] [id] [--limit 50]
|
|
1946
|
-
Inspect the pack's XRM data. No args = list entities. Cases/tickets are
|
|
2363
|
+
records: `octwin records [entity] [id] [--limit 50]
|
|
2364
|
+
Inspect the pack's XRM data. No args = list entities. Cases/tickets are
|
|
1947
2365
|
casework, not XRM — use \`octwin cases\` for those.`,
|
|
1948
|
-
cases: `octwin cases [caseId] [--queues] [--limit 50] [--json]
|
|
1949
|
-
Inspect casework (support tickets): the inbox, one case + its timeline
|
|
2366
|
+
cases: `octwin cases [caseId] [--queues] [--limit 50] [--json]
|
|
2367
|
+
Inspect casework (support tickets): the inbox, one case + its timeline
|
|
1950
2368
|
(+ applicable decisions), or --queues for queue keys + open counts.`,
|
|
1951
|
-
logs: `octwin logs [conversationId] [--as <handle>] [--json]
|
|
1952
|
-
No id = recent conversations (handle, status, last activity; --as filters).
|
|
1953
|
-
With id = the full event timeline including what each turn rendered.
|
|
2369
|
+
logs: `octwin logs [conversationId] [--as <handle>] [--json]
|
|
2370
|
+
No id = recent conversations (handle, status, last activity; --as filters).
|
|
2371
|
+
With id = the full event timeline including what each turn rendered.
|
|
1954
2372
|
--json = raw events (verbatim payloads).`,
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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.`,
|
|
2382
|
+
chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
|
|
2383
|
+
octwin chat --script <file> [--as <handle>] [--json]
|
|
2384
|
+
Drive ONE turn through the dev web channel and print every render with its
|
|
2385
|
+
tap ids. Same --as handle = same conversation (multi-turn works).
|
|
2386
|
+
--tap presses a rendered button/list row instead of sending text.
|
|
2387
|
+
--media uploads a local file (or a media id from 'media generate --json') as
|
|
2388
|
+
an image/document/audio inbound — any "message" rides as its caption; feeds a
|
|
2389
|
+
running media-collect flow (e.g. activate-app).
|
|
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`,
|
|
2404
|
+
media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
|
|
2405
|
+
AI-generate an image (needs a media:generate-scoped token), store it as a
|
|
2406
|
+
public asset, and print its MEDIA- handle + serve URL. --out downloads the
|
|
2407
|
+
bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
|
|
1967
2408
|
width, height, bytes }. Pair with 'octwin chat --media' to drive media flows.`,
|
|
1968
|
-
agents: `octwin agents [packId::agentId] [--prompt] [--json]
|
|
1969
|
-
No args = the roster with each agent's EFFECTIVE model and which layer set it.
|
|
1970
|
-
With an agent = every governed setting (model / memory.last_messages /
|
|
1971
|
-
working_memory) plus the layer that won — an operator PLATFORM default can
|
|
1972
|
-
override what your manifest declares, and this is where you see that.
|
|
1973
|
-
--prompt = the exact system prompt the LLM sees for this project (pack
|
|
1974
|
-
instructions + platform protocol + any project overlay). Needs agents:read.
|
|
2409
|
+
agents: `octwin agents [packId::agentId] [--prompt] [--json]
|
|
2410
|
+
No args = the roster with each agent's EFFECTIVE model and which layer set it.
|
|
2411
|
+
With an agent = every governed setting (model / memory.last_messages /
|
|
2412
|
+
working_memory) plus the layer that won — an operator PLATFORM default can
|
|
2413
|
+
override what your manifest declares, and this is where you see that.
|
|
2414
|
+
--prompt = the exact system prompt the LLM sees for this project (pack
|
|
2415
|
+
instructions + platform protocol + any project overlay). Needs agents:read.
|
|
1975
2416
|
The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.`,
|
|
1976
|
-
orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
|
|
1977
|
-
No args = the order list (#number, status/payment, total, contact). With a
|
|
1978
|
-
reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
|
|
1979
|
-
payment_ref, and the allowed status transitions. Needs orders:read + the
|
|
1980
|
-
\`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
|
|
2417
|
+
orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
|
|
2418
|
+
No args = the order list (#number, status/payment, total, contact). With a
|
|
2419
|
+
reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
|
|
2420
|
+
payment_ref, and the allowed status transitions. Needs orders:read + the
|
|
2421
|
+
\`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
|
|
1981
2422
|
so \`pending\` on a gateway-less workspace is expected, not a bug.`,
|
|
1982
|
-
analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
|
|
1983
|
-
No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
|
|
1984
|
-
With an entity = stage-by-stage conversion (default --funnel) over the last 30
|
|
1985
|
-
days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
|
|
2423
|
+
analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
|
|
2424
|
+
No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
|
|
2425
|
+
With an entity = stage-by-stage conversion (default --funnel) over the last 30
|
|
2426
|
+
days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
|
|
1986
2427
|
range-filtered). Needs records:read + a \`view\` grant on \`record.<entity>\`.`,
|
|
1987
|
-
catalog: `octwin catalog [--readiness] [--json]
|
|
1988
|
-
The commerce \`product\` records + price, availability, stock (null = not
|
|
1989
|
-
inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
|
|
1990
|
-
Graph checklist (LIVE Graph calls; needs a bound access token). Needs
|
|
2428
|
+
catalog: `octwin catalog [--readiness] [--json]
|
|
2429
|
+
The commerce \`product\` records + price, availability, stock (null = not
|
|
2430
|
+
inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
|
|
2431
|
+
Graph checklist (LIVE Graph calls; needs a bound access token). Needs
|
|
1991
2432
|
catalog:read + the \`catalog\` plan feature.`,
|
|
1992
|
-
scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
|
|
1993
|
-
No args = the engine state (bookable resource types, upcoming slots, booked
|
|
1994
|
-
seats). --slots <recordId> computes the slots for one bookable resource
|
|
1995
|
-
(occupancy included; --days is clamped to 1-31 server-side) — the way to verify
|
|
2433
|
+
scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
|
|
2434
|
+
No args = the engine state (bookable resource types, upcoming slots, booked
|
|
2435
|
+
seats). --slots <recordId> computes the slots for one bookable resource
|
|
2436
|
+
(occupancy included; --days is clamped to 1-31 server-side) — the way to verify
|
|
1996
2437
|
the availability rules a \`deploy --seed\` created. Needs scheduling:read.`,
|
|
1997
|
-
'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
1998
|
-
Pull the platform capability reference (markdown + JSON catalogs) into
|
|
2438
|
+
'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
2439
|
+
Pull the platform capability reference (markdown + JSON catalogs) into
|
|
1999
2440
|
.octwin/platform-kb/ for the octwin-pack authoring skill.`,
|
|
2000
|
-
test: `octwin test [--dir .]
|
|
2441
|
+
test: `octwin test [--dir .]
|
|
2001
2442
|
Alias for \`octwin validate --remote\` — the full platform check.`,
|
|
2002
2443
|
};
|
|
2003
2444
|
async function main() {
|
|
@@ -2026,6 +2467,9 @@ async function main() {
|
|
|
2026
2467
|
case 'deploy':
|
|
2027
2468
|
await cmdDeploy(flags);
|
|
2028
2469
|
break;
|
|
2470
|
+
case 'pull':
|
|
2471
|
+
await cmdPull(flags);
|
|
2472
|
+
break;
|
|
2029
2473
|
case 'status':
|
|
2030
2474
|
await cmdStatus(flags);
|
|
2031
2475
|
break;
|