octwin-cli 0.1.16 → 0.1.21
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 +136 -0
- package/README.md +33 -25
- package/dist/index.js +637 -179
- package/dist/lib/args-check.js +110 -0
- package/dist/lib/pack-source.js +68 -0
- package/dist/lib/rename.js +12 -41
- package/dist/lib/render-check.js +85 -0
- 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.
|
|
@@ -121,9 +135,16 @@ const COMMAND_REQUIREMENTS = {
|
|
|
121
135
|
validate: { scope: 'pack:deploy' },
|
|
122
136
|
status: { scope: 'pack:deploy' },
|
|
123
137
|
test: { scope: 'pack:deploy' },
|
|
138
|
+
// `pull` reads a stored artifact through the deploy route's sibling, so it needs the
|
|
139
|
+
// same scope. Omitting it here meant a 403 on the one command that recovers a pack's
|
|
140
|
+
// only source copy printed the generic hint WITHOUT naming the scope to grant.
|
|
141
|
+
pull: { scope: 'pack:deploy' },
|
|
124
142
|
'platform-kb': { scope: 'pack:deploy' },
|
|
125
143
|
media: { scope: 'media:generate' },
|
|
126
|
-
|
|
144
|
+
// The plan feature gates RECORD reads, not the entity list (`/xrm/entities` carries only
|
|
145
|
+
// the scope guard) — so the hint says which half it applies to rather than blaming the
|
|
146
|
+
// plan for a 403 the plan did not cause.
|
|
147
|
+
records: { scope: 'records:read', feature: 'records', featureAppliesTo: 'reading records (listing entities needs only the scope)' },
|
|
127
148
|
analytics: { scope: 'records:read', feature: 'records' },
|
|
128
149
|
cases: { scope: 'cases:read', feature: 'cases' },
|
|
129
150
|
logs: { scope: 'conversations:read' },
|
|
@@ -144,7 +165,10 @@ function scopeRequirementHint() {
|
|
|
144
165
|
const special = req.scope === 'pack:deploy' || req.scope === 'media:generate';
|
|
145
166
|
return `needs the \`${req.scope}\` scope`
|
|
146
167
|
+ (special ? ' (granted DIRECTLY only — a `tenant:admin` token does not confer it)' : '')
|
|
147
|
-
+ (req.feature
|
|
168
|
+
+ (req.feature
|
|
169
|
+
? `, and the \`${req.feature}\` plan feature on this workspace`
|
|
170
|
+
+ (req.featureAppliesTo ? ` for ${req.featureAppliesTo}` : '')
|
|
171
|
+
: '');
|
|
148
172
|
}
|
|
149
173
|
/** Print the auth hints below an HTTP-failure line when it's a 401/403 — so every
|
|
150
174
|
* command explains a token problem, not just the inspect family (author-feedback A7):
|
|
@@ -217,12 +241,6 @@ async function resolveMediaPart(url, arg) {
|
|
|
217
241
|
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
242
|
}
|
|
219
243
|
// ── 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
244
|
/** Per-blob / total ceilings, mirroring the server so oversize fails LOCALLY. */
|
|
227
245
|
const MAX_BLOB_BYTES = 2 * 1024 * 1024;
|
|
228
246
|
const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
@@ -234,6 +252,11 @@ const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
|
234
252
|
* MANGLED any committed image — the bytes went through a lossy UTF-8 decode and
|
|
235
253
|
* arrived corrupt. Binary files now split off into `blobs`, transported as base64
|
|
236
254
|
* (transport only; they land in `bytea` server-side).
|
|
255
|
+
*
|
|
256
|
+
* WHAT is content and WHICH half it lands in are not decided here — `classifyPackPath`
|
|
257
|
+
* decides, the same rule the operator's GitHub repo import uses. This walk only
|
|
258
|
+
* supplies the bytes. (It had its own answer once, and the two disagreed: a `*.test.ts`
|
|
259
|
+
* or `xrm.yaml.example` beside a flow imported fine and failed to deploy.)
|
|
237
260
|
*/
|
|
238
261
|
function collectBundleFiles(packDir) {
|
|
239
262
|
const files = {};
|
|
@@ -244,17 +267,14 @@ function collectBundleFiles(packDir) {
|
|
|
244
267
|
const full = join(dir, name);
|
|
245
268
|
const rel = prefix ? `${prefix}/${name}` : name;
|
|
246
269
|
if (statSync(full).isDirectory()) {
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
walk(full, rel);
|
|
270
|
+
if (!isSkippedDir(name))
|
|
271
|
+
walk(full, rel); // prune before descending
|
|
250
272
|
continue;
|
|
251
273
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
|
|
257
|
-
if (BINARY_EXT.has(ext)) {
|
|
274
|
+
const kind = classifyPackPath(rel);
|
|
275
|
+
if (kind === 'skip')
|
|
276
|
+
continue;
|
|
277
|
+
if (kind === 'blob') {
|
|
258
278
|
const buf = readFileSync(full);
|
|
259
279
|
if (buf.byteLength > MAX_BLOB_BYTES) {
|
|
260
280
|
die(`'${rel}' is ${(buf.byteLength / 1024 / 1024).toFixed(1)} MB — the per-file limit is ${MAX_BLOB_BYTES / 1024 / 1024} MB`);
|
|
@@ -282,17 +302,18 @@ function readManifestIdVersion(files) {
|
|
|
282
302
|
}
|
|
283
303
|
return { id: doc.id, version: doc.version };
|
|
284
304
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
305
|
+
// ── config (env + saved login) ──────────────────────────────────────────────
|
|
306
|
+
/**
|
|
307
|
+
* The saved login IS the deploy target. `~/.octwin/credentials.json` is a flat
|
|
308
|
+
* map of platform url → token, plus ONE reserved key holding the url the last
|
|
309
|
+
* `octwin login` pointed at. It cannot collide with a token entry: every url key
|
|
310
|
+
* contains `://`, and `default_url` does not.
|
|
311
|
+
*
|
|
312
|
+
* A pack directory therefore holds pack content and nothing else — the deploy
|
|
313
|
+
* target is a property of the machine, not of the pack. (The retired `pack.json`
|
|
314
|
+
* was a second home for a fact `octwin login` already stated.)
|
|
315
|
+
*/
|
|
316
|
+
const DEFAULT_URL_KEY = 'default_url';
|
|
296
317
|
function credsPath() { return join(homedir(), '.octwin', 'credentials.json'); }
|
|
297
318
|
function readCreds() {
|
|
298
319
|
try {
|
|
@@ -306,6 +327,8 @@ function writeCreds(map) {
|
|
|
306
327
|
mkdirSync(join(homedir(), '.octwin'), { recursive: true });
|
|
307
328
|
writeFileSync(credsPath(), JSON.stringify(map, null, 2), 'utf8');
|
|
308
329
|
}
|
|
330
|
+
/** The platform url of the last `octwin login` — the default target. */
|
|
331
|
+
function savedDefaultUrl() { return readCreds()[DEFAULT_URL_KEY] ?? ''; }
|
|
309
332
|
// ── update check (daily, fail-silent, TTY-only) ──────────────────────────────
|
|
310
333
|
function updateCachePath() { return join(homedir(), '.octwin', 'update-check.json'); }
|
|
311
334
|
/** True when semver `a` is strictly greater than `b` (simple x.y.z compare). */
|
|
@@ -420,7 +443,7 @@ async function notifyIfKbStale(flags) {
|
|
|
420
443
|
const local = readLocalKb(packDir);
|
|
421
444
|
if (!local?.content_hash)
|
|
422
445
|
return; // never pulled → the skill already says to pull
|
|
423
|
-
const t = resolveTargetOrNull(flags
|
|
446
|
+
const t = resolveTargetOrNull(flags);
|
|
424
447
|
if (!t)
|
|
425
448
|
return;
|
|
426
449
|
const ctrl = new AbortController();
|
|
@@ -429,8 +452,18 @@ async function notifyIfKbStale(flags) {
|
|
|
429
452
|
headers: authHeaders(t), signal: ctrl.signal,
|
|
430
453
|
});
|
|
431
454
|
clearTimeout(timer);
|
|
432
|
-
if (!res.ok)
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
// The meta poll needs `pack:deploy`, but this nudge rides on every networked
|
|
457
|
+
// command — so an author inspecting data with a narrow (`records:read`-only) token
|
|
458
|
+
// got NO drift signal at all, silently, and a stale reference is exactly what makes
|
|
459
|
+
// an author invent a primitive from memory. Say so once; stay silent for every other
|
|
460
|
+
// failure (offline, timeout, a platform without the route).
|
|
461
|
+
if (res.status === 401 || res.status === 403) {
|
|
462
|
+
console.error('\nⓘ can\'t check whether the platform capability reference drifted — that needs a `pack:deploy` token.');
|
|
463
|
+
console.error(' Refresh it directly with a deploy token: octwin platform-kb --token oct_…');
|
|
464
|
+
}
|
|
433
465
|
return;
|
|
466
|
+
}
|
|
434
467
|
const meta = await res.json();
|
|
435
468
|
if (meta.content_hash && meta.content_hash !== local.content_hash) {
|
|
436
469
|
// Per-entry summary (now that the index carries per-entry hashes) — the
|
|
@@ -463,6 +496,7 @@ function commandTouchesPlatform(command, flags) {
|
|
|
463
496
|
case 'test':
|
|
464
497
|
case 'chat':
|
|
465
498
|
case 'media':
|
|
499
|
+
case 'pull':
|
|
466
500
|
case 'records':
|
|
467
501
|
case 'cases':
|
|
468
502
|
case 'logs':
|
|
@@ -491,18 +525,12 @@ function cmdInit(flags) {
|
|
|
491
525
|
cpSync(TEMPLATE_DIR, dir, { recursive: true });
|
|
492
526
|
applyRenames(dir, {
|
|
493
527
|
packId: id,
|
|
494
|
-
flowId: 'main',
|
|
495
|
-
agentId: 'assistant',
|
|
496
528
|
description: flags.description ?? undefined,
|
|
497
529
|
displayName: flags['display-name'] ?? undefined,
|
|
498
530
|
});
|
|
499
|
-
//
|
|
500
|
-
// The
|
|
501
|
-
//
|
|
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');
|
|
531
|
+
// Repo hygiene + a README. Nothing else: the scaffold writes pack CONTENT only.
|
|
532
|
+
// The deploy target is the saved login (`octwin login --url … --token …`), and
|
|
533
|
+
// the token carries its own tenant + optional project pin.
|
|
506
534
|
writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.pack-bundles/\n.octwin/\n', 'utf8');
|
|
507
535
|
if (!existsSync(join(dir, 'README.md'))) {
|
|
508
536
|
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 +541,7 @@ function cmdInit(flags) {
|
|
|
513
541
|
console.log(' git init && git add -A && git commit -m "init pack"');
|
|
514
542
|
console.log(' # edit manifest.yaml / flows / prompts, then:');
|
|
515
543
|
console.log(' octwin validate');
|
|
516
|
-
console.log(' #
|
|
544
|
+
console.log(' # point the CLI at your platform (the token carries the tenant):');
|
|
517
545
|
console.log(' octwin login --url <platformUrl> --token <deploy-token>');
|
|
518
546
|
console.log(' octwin deploy');
|
|
519
547
|
}
|
|
@@ -532,6 +560,55 @@ async function cmdValidate(flags) {
|
|
|
532
560
|
const packDir = resolve(flags.dir ?? '.');
|
|
533
561
|
const { id, version, files, blobs } = localValidate(packDir); // offline structural gate first (fast, no server/token)
|
|
534
562
|
console.log(`✓ ${id}@${version} passes the offline structural check (${Object.keys(files).length} files, ${Object.keys(blobs).length} image(s))`);
|
|
563
|
+
// Render-intent fields, checked against the pulled KB. Skipped (silently) when
|
|
564
|
+
// the author hasn't pulled the reference yet — Step 0.5 of the skill says to.
|
|
565
|
+
const allowedRenderKeys = loadAllowedRenderKeys(packDir);
|
|
566
|
+
if (allowedRenderKeys) {
|
|
567
|
+
const findings = Object.entries(files)
|
|
568
|
+
.filter(([p]) => /\.ya?ml$/i.test(p))
|
|
569
|
+
.flatMap(([p, body]) => {
|
|
570
|
+
let doc;
|
|
571
|
+
try {
|
|
572
|
+
doc = parseYaml(body);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return [];
|
|
576
|
+
} // a YAML syntax error is the structural gate's to report
|
|
577
|
+
return findRenderKeyViolations(doc, p, allowedRenderKeys);
|
|
578
|
+
});
|
|
579
|
+
if (findings.length) {
|
|
580
|
+
console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
|
|
581
|
+
for (const f of findings)
|
|
582
|
+
console.error(` ✗ ${describeRenderFinding(f)}`);
|
|
583
|
+
die('fix these before deploying — the platform rejects them at load, and before that they rendered as nothing');
|
|
584
|
+
}
|
|
585
|
+
console.log('✓ render intents use only fields the platform renders');
|
|
586
|
+
}
|
|
587
|
+
// Primitive `args:` keys, same source and same degrade-to-no-op contract.
|
|
588
|
+
// Cannot see inside a `use:` template body (expansion is the platform's job);
|
|
589
|
+
// `--remote` covers that.
|
|
590
|
+
const argSpecs = loadPrimitiveArgSpecs(packDir);
|
|
591
|
+
if (argSpecs) {
|
|
592
|
+
const findings = Object.entries(files)
|
|
593
|
+
.filter(([p]) => /\.ya?ml$/i.test(p))
|
|
594
|
+
.flatMap(([p, body]) => {
|
|
595
|
+
let doc;
|
|
596
|
+
try {
|
|
597
|
+
doc = parseYaml(body);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
return findArgViolations(doc, p, argSpecs);
|
|
603
|
+
});
|
|
604
|
+
if (findings.length) {
|
|
605
|
+
console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
|
|
606
|
+
for (const f of findings)
|
|
607
|
+
console.error(` ✗ ${describeArgFinding(f)}`);
|
|
608
|
+
die('fix these before deploying — an undeclared argument is dropped with no error at runtime');
|
|
609
|
+
}
|
|
610
|
+
console.log('✓ primitive arguments match their declared inputs');
|
|
611
|
+
}
|
|
535
612
|
if (flags.remote !== true) {
|
|
536
613
|
console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
|
|
537
614
|
console.log(' (all errors at once) before you deploy.');
|
|
@@ -539,7 +616,7 @@ async function cmdValidate(flags) {
|
|
|
539
616
|
}
|
|
540
617
|
// Remote: the SAME validation the deploy route runs — manifest `.strict()` +
|
|
541
618
|
// every flow (schema/expression/structure) — returning ALL errors at once.
|
|
542
|
-
const t = resolveTarget(flags
|
|
619
|
+
const t = resolveTarget(flags);
|
|
543
620
|
const { url } = t;
|
|
544
621
|
console.log(`→ Validating against ${targetLabel(t)} @ ${url} …`);
|
|
545
622
|
const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
|
|
@@ -595,8 +672,9 @@ async function cmdLogin(flags) {
|
|
|
595
672
|
const token = flags.token ?? process.env.PACK_TOKEN ?? die('missing --token');
|
|
596
673
|
const creds = readCreds();
|
|
597
674
|
creds[url] = token;
|
|
675
|
+
creds[DEFAULT_URL_KEY] = url; // login sets the default deploy target
|
|
598
676
|
writeCreds(creds);
|
|
599
|
-
console.log(`✓ Saved token for ${url}`);
|
|
677
|
+
console.log(`✓ Saved token for ${url} — now the default target`);
|
|
600
678
|
// Best-effort: echo what the token reaches (workspace + project pin + scopes)
|
|
601
679
|
// so a fresh token self-identifies without a second `octwin whoami`. A network
|
|
602
680
|
// failure never fails the save — the token is stored regardless.
|
|
@@ -623,36 +701,35 @@ function authHeaders(t) {
|
|
|
623
701
|
return h;
|
|
624
702
|
}
|
|
625
703
|
/** Resolve platform url + token (+ optional tenant/project overrides):
|
|
626
|
-
* flags >
|
|
627
|
-
*
|
|
628
|
-
function resolveTarget(flags
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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)
|
|
704
|
+
* flags > env > saved login. Tenant is derived from the token server-side, so
|
|
705
|
+
* only url + token are required — and `octwin login` supplies both. */
|
|
706
|
+
function resolveTarget(flags) {
|
|
707
|
+
const t = readTarget(flags);
|
|
708
|
+
if (!t.url)
|
|
709
|
+
die('no platform url — run `octwin login --url <url> --token oct_…`, or pass --url / PACK_PLATFORM_URL');
|
|
710
|
+
if (!t.token)
|
|
637
711
|
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
|
|
712
|
+
return t;
|
|
639
713
|
}
|
|
640
714
|
/** Non-fatal `resolveTarget`: returns null (never dies) when url or token is
|
|
641
715
|
* missing. Used by the fail-silent KB-staleness observer, which must never
|
|
642
716
|
* interrupt a command over a config gap. */
|
|
643
|
-
function resolveTargetOrNull(flags
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
717
|
+
function resolveTargetOrNull(flags) {
|
|
718
|
+
const t = readTarget(flags);
|
|
719
|
+
return t.url && t.token ? t : null;
|
|
720
|
+
}
|
|
721
|
+
/** The raw resolution both wrappers share — may return empty url/token. */
|
|
722
|
+
function readTarget(flags) {
|
|
723
|
+
const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? savedDefaultUrl()).replace(/\/$/, '');
|
|
724
|
+
return {
|
|
725
|
+
url,
|
|
726
|
+
token: flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '',
|
|
727
|
+
tenant: flags.tenant || process.env.PACK_TENANT || undefined,
|
|
728
|
+
project: flags.project || process.env.PACK_PROJECT || undefined,
|
|
729
|
+
};
|
|
652
730
|
}
|
|
653
731
|
async function cmdWhoami(flags) {
|
|
654
|
-
const
|
|
655
|
-
const t = resolveTarget(flags, packDir);
|
|
732
|
+
const t = resolveTarget(flags);
|
|
656
733
|
console.log(`→ Checking the token against ${t.url} …`);
|
|
657
734
|
const res = await fetchOrDie(`${t.url}/api/self/t/whoami`, { headers: authHeaders(t) }, 'token check');
|
|
658
735
|
if (res.ok) {
|
|
@@ -667,6 +744,73 @@ async function cmdWhoami(flags) {
|
|
|
667
744
|
: await res.text();
|
|
668
745
|
die(`token check failed (HTTP ${res.status}) — ${why}`);
|
|
669
746
|
}
|
|
747
|
+
/**
|
|
748
|
+
* `octwin pull <packId> [--dir <out>] [--version v] [--force]` — write a
|
|
749
|
+
* DEPLOYED pack's source back to disk.
|
|
750
|
+
*
|
|
751
|
+
* The inverse of `deploy`, and the reason it exists: a pack pushed with
|
|
752
|
+
* `octwin deploy` lives on the platform as an artifact the runtime serves but
|
|
753
|
+
* nothing hands back, so its only source copy is the machine that pushed it.
|
|
754
|
+
* Lose that machine — or inherit a pack someone else deployed — and the source is
|
|
755
|
+
* gone while the bot keeps running. `pull` closes the loop: fetch, fix, redeploy.
|
|
756
|
+
*
|
|
757
|
+
* You may pull a pack YOUR tenant owns (an operator token may pull any). A pack
|
|
758
|
+
* you merely installed from the marketplace is not yours to read.
|
|
759
|
+
*
|
|
760
|
+
* Writes into `<out>/` and refuses a non-empty directory unless `--force`, so it
|
|
761
|
+
* can't quietly clobber local edits. The pulled directory is immediately
|
|
762
|
+
* `octwin deploy`-able back to where it came from — the target is the saved
|
|
763
|
+
* login, so nothing machine-specific needs to land in the pack dir.
|
|
764
|
+
*/
|
|
765
|
+
async function cmdPull(flags) {
|
|
766
|
+
const packId = flags._[0] ?? '';
|
|
767
|
+
if (!packId)
|
|
768
|
+
die('usage: octwin pull <packId> [--dir <out>] [--version <v>] [--force]');
|
|
769
|
+
const outDir = resolve(flags.dir ?? packId);
|
|
770
|
+
const t = resolveTarget(flags);
|
|
771
|
+
const { url } = t;
|
|
772
|
+
const qs = typeof flags.version === 'string' ? `?version=${encodeURIComponent(flags.version)}` : '';
|
|
773
|
+
console.log(`→ Pulling ${packId}${qs ? `@${flags.version}` : ''} from ${targetLabel(t)} @ ${url} …`);
|
|
774
|
+
const res = await fetchOrDie(`${url}/api/self/p/packs/${encodeURIComponent(packId)}/source${qs}`, { headers: authHeaders(t) }, 'pull');
|
|
775
|
+
const text = await res.text();
|
|
776
|
+
let json;
|
|
777
|
+
try {
|
|
778
|
+
json = JSON.parse(text);
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
json = text;
|
|
782
|
+
}
|
|
783
|
+
if (!res.ok) {
|
|
784
|
+
console.error(`✗ pull failed (HTTP ${res.status})`);
|
|
785
|
+
printAuthHint(res.status, url);
|
|
786
|
+
if (res.status === 403) {
|
|
787
|
+
console.error(' → a pack is pullable by the tenant that OWNS it (deployed it), or by an operator.');
|
|
788
|
+
}
|
|
789
|
+
console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
792
|
+
const files = json.files ?? {};
|
|
793
|
+
const blobs = json.blobs ?? {};
|
|
794
|
+
const total = Object.keys(files).length + Object.keys(blobs).length;
|
|
795
|
+
if (total === 0)
|
|
796
|
+
die(`${packId}@${json.version} has no files — nothing to write`);
|
|
797
|
+
if (existsSync(outDir) && readdirSync(outDir).length > 0 && flags.force !== true) {
|
|
798
|
+
die(`${outDir} is not empty — pass --force to overwrite it`);
|
|
799
|
+
}
|
|
800
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
801
|
+
const p = join(outDir, rel);
|
|
802
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
803
|
+
writeFileSync(p, body, 'utf8');
|
|
804
|
+
}
|
|
805
|
+
for (const [rel, b64] of Object.entries(blobs)) {
|
|
806
|
+
const p = join(outDir, rel);
|
|
807
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
808
|
+
writeFileSync(p, Buffer.from(b64, 'base64'));
|
|
809
|
+
}
|
|
810
|
+
console.log(`✓ Pulled ${json.pack_id}@${json.version} → ${outDir}`);
|
|
811
|
+
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)}…`);
|
|
812
|
+
console.log(`\nFix it, then: octwin deploy --dir ${outDir}`);
|
|
813
|
+
}
|
|
670
814
|
/**
|
|
671
815
|
* Read the deploy SSE stream, printing each progress frame's message live, and
|
|
672
816
|
* return the terminal `done`/`error` event (or null if the stream ended without
|
|
@@ -716,6 +860,36 @@ async function readDeployProgress(body) {
|
|
|
716
860
|
}
|
|
717
861
|
return { terminal, stepErrors };
|
|
718
862
|
}
|
|
863
|
+
/**
|
|
864
|
+
* The anonymous-marketplace verdict, for `deploy` and `status` alike.
|
|
865
|
+
*
|
|
866
|
+
* `listing.public: true` in the manifest puts a pack into an operator review queue, and the
|
|
867
|
+
* state lived only in the console — so an author working from the CLI got no acknowledgement
|
|
868
|
+
* that the request had registered, and no sight of a rejection note (which the platform
|
|
869
|
+
* REQUIRES precisely because it is their only feedback). Silent for `none`/absent, so a pack
|
|
870
|
+
* that never asked prints nothing.
|
|
871
|
+
*
|
|
872
|
+
* `pending` after a redeploy is normal rather than a regression: an approval pins the sha it
|
|
873
|
+
* reviewed, so any content edit returns the pack to the queue on its own.
|
|
874
|
+
*/
|
|
875
|
+
function printPublicListing(state, note, live) {
|
|
876
|
+
const n = typeof note === 'string' && note ? ` — operator note: ${note}` : '';
|
|
877
|
+
switch (state) {
|
|
878
|
+
case 'pending':
|
|
879
|
+
console.log(' ⓘ marketplace listing: PENDING operator review (any content edit re-queues it — the approval pins a content sha).');
|
|
880
|
+
break;
|
|
881
|
+
case 'approved':
|
|
882
|
+
console.log(live === false
|
|
883
|
+
? ' ⓘ marketplace listing: approved, but NOT currently public — the approved content sha no longer matches what you publish. It is back in the review queue.'
|
|
884
|
+
: ' ✓ marketplace listing: live on the public marketplace.');
|
|
885
|
+
break;
|
|
886
|
+
case 'rejected':
|
|
887
|
+
console.log(` ⚠ marketplace listing: REJECTED${n}`);
|
|
888
|
+
break;
|
|
889
|
+
default: // 'none' | null | absent → never asked
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
719
893
|
function printDeploySuccess(id, version, t, r) {
|
|
720
894
|
console.log(`✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
|
|
721
895
|
if (r?.warning)
|
|
@@ -734,11 +908,19 @@ function printDeploySuccess(id, version, t, r) {
|
|
|
734
908
|
if (parts.length)
|
|
735
909
|
console.log(` Seeded: ${parts.join(', ')}`);
|
|
736
910
|
}
|
|
911
|
+
// A redeploy rebuilds the pack's tools, and suspended flow runs live with them.
|
|
912
|
+
// Say so: otherwise the next tap on a card rendered before the deploy comes back
|
|
913
|
+
// stale and reads like a flow bug.
|
|
914
|
+
const dropped = r?.suspended_runs_dropped;
|
|
915
|
+
if (dropped > 0) {
|
|
916
|
+
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.`);
|
|
917
|
+
}
|
|
918
|
+
printPublicListing(r?.public_listing, r?.public_review_note);
|
|
737
919
|
console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
|
|
738
920
|
}
|
|
739
921
|
async function cmdDeploy(flags) {
|
|
740
922
|
const packDir = resolve(flags.dir ?? '.');
|
|
741
|
-
const t = resolveTarget(flags
|
|
923
|
+
const t = resolveTarget(flags);
|
|
742
924
|
const { url } = t;
|
|
743
925
|
const { id, version, files, blobs } = localValidate(packDir);
|
|
744
926
|
const endpoint = `${url}/api/self/p/packs/deploy`;
|
|
@@ -789,7 +971,7 @@ async function cmdDeploy(flags) {
|
|
|
789
971
|
}
|
|
790
972
|
async function cmdStatus(flags) {
|
|
791
973
|
const packDir = resolve(flags.dir ?? '.');
|
|
792
|
-
const t = resolveTarget(flags
|
|
974
|
+
const t = resolveTarget(flags);
|
|
793
975
|
const { url } = t;
|
|
794
976
|
const manifestPath = join(packDir, 'manifest.yaml');
|
|
795
977
|
if (!existsSync(manifestPath))
|
|
@@ -841,13 +1023,138 @@ async function cmdStatus(flags) {
|
|
|
841
1023
|
else {
|
|
842
1024
|
console.log('\n✓ live and current.');
|
|
843
1025
|
}
|
|
1026
|
+
// Independent of the runtime verdict above: a listing decision controls anonymous
|
|
1027
|
+
// visibility only and never touches a running install, so it is reported alongside
|
|
1028
|
+
// rather than folded into that ladder.
|
|
1029
|
+
printPublicListing(json.public_listing, json.public_review_note, json.publicly_listed);
|
|
844
1030
|
if (localVersion !== '?' && localVersion !== json.installed_version) {
|
|
845
1031
|
console.log(` (local manifest is ${localVersion}; deployed is ${json.installed_version} — \`octwin deploy\` to push local edits.)`);
|
|
846
1032
|
}
|
|
847
1033
|
}
|
|
1034
|
+
/** Filesystem-safe entry filename (entry names are already tame — `record_list`,
|
|
1035
|
+
* `whatsapp`, `xrm` — but never trust a name straight into a path). */
|
|
1036
|
+
function kbEntryFileName(name) {
|
|
1037
|
+
return name.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
1038
|
+
}
|
|
1039
|
+
/** First sentence (or a hard clamp) of a possibly-long `describe` — INDEX.md needs
|
|
1040
|
+
* one scannable line per entry, not the whole contract.
|
|
1041
|
+
*
|
|
1042
|
+
* Sentence detection ignores punctuation nested in brackets: primitive `describe`
|
|
1043
|
+
* text routinely inlines an envelope shape (`… { rows, total, …, refs? } …`) whose
|
|
1044
|
+
* `?` would otherwise cut the summary off mid-brace. */
|
|
1045
|
+
function kbOneLiner(text, max = 160) {
|
|
1046
|
+
if (typeof text !== 'string' || !text.trim())
|
|
1047
|
+
return '';
|
|
1048
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
1049
|
+
let depth = 0;
|
|
1050
|
+
let end = -1;
|
|
1051
|
+
for (let i = 0; i < flat.length; i++) {
|
|
1052
|
+
const ch = flat[i];
|
|
1053
|
+
if (ch === '{' || ch === '(' || ch === '[')
|
|
1054
|
+
depth++;
|
|
1055
|
+
else if (ch === '}' || ch === ')' || ch === ']')
|
|
1056
|
+
depth = Math.max(0, depth - 1);
|
|
1057
|
+
else if (depth === 0 && (ch === '.' || ch === '!' || ch === '?')) {
|
|
1058
|
+
const next = flat[i + 1];
|
|
1059
|
+
if (next === undefined || next === ' ') {
|
|
1060
|
+
end = i + 1;
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const line = end >= 40 ? flat.slice(0, end) : flat;
|
|
1066
|
+
return line.length > max ? line.slice(0, max - 1).trimEnd() + '…' : line;
|
|
1067
|
+
}
|
|
1068
|
+
/** Enumerate a catalog's entries per the platform-supplied descriptor. Handles both
|
|
1069
|
+
* collection shapes in use: an ARRAY of named objects (`primitives`, keyed by
|
|
1070
|
+
* `name`) and an OBJECT MAP keyed by entry name (`declarations`, `system-entities`).
|
|
1071
|
+
* Returns [] when the descriptor doesn't fit the payload, so a shape surprise
|
|
1072
|
+
* degrades to "write the flat file" instead of throwing mid-pull. */
|
|
1073
|
+
function enumerateKbEntries(catalog, d) {
|
|
1074
|
+
const collection = catalog?.[d.at];
|
|
1075
|
+
if (Array.isArray(collection)) {
|
|
1076
|
+
if (!d.by)
|
|
1077
|
+
return [];
|
|
1078
|
+
const out = [];
|
|
1079
|
+
for (const item of collection) {
|
|
1080
|
+
const name = item?.[d.by];
|
|
1081
|
+
if (typeof name !== 'string' || !name)
|
|
1082
|
+
return []; // not the shape we were told — bail wholesale
|
|
1083
|
+
out.push({ name, summary: kbOneLiner(d.summary ? item[d.summary] : ''), value: item });
|
|
1084
|
+
}
|
|
1085
|
+
return out;
|
|
1086
|
+
}
|
|
1087
|
+
if (collection && typeof collection === 'object') {
|
|
1088
|
+
return Object.entries(collection).map(([name, value]) => ({
|
|
1089
|
+
name,
|
|
1090
|
+
summary: kbOneLiner(d.summary ? value?.[d.summary] : ''),
|
|
1091
|
+
value,
|
|
1092
|
+
}));
|
|
1093
|
+
}
|
|
1094
|
+
return [];
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Build `INDEX.md` — the map an authoring agent reads FIRST.
|
|
1098
|
+
*
|
|
1099
|
+
* The KB is ~800 KB across three dozen files; reading it whole costs more context
|
|
1100
|
+
* than the pack being authored. This index is one ~7k-token read that names every
|
|
1101
|
+
* doc and every catalog entry with a one-line summary and its exact path, so the
|
|
1102
|
+
* agent can jump straight to the ~600-token file it actually needs.
|
|
1103
|
+
*/
|
|
1104
|
+
function buildKbIndexMarkdown(bundle, exploded) {
|
|
1105
|
+
const index = bundle.index ?? [];
|
|
1106
|
+
const docs = index.filter(e => e.kind === 'doc');
|
|
1107
|
+
const catalogs = index.filter(e => e.kind === 'catalog');
|
|
1108
|
+
const L = [];
|
|
1109
|
+
L.push('# Octwin platform capability reference — INDEX');
|
|
1110
|
+
L.push('');
|
|
1111
|
+
L.push(`Reference version ${bundle.version ?? '?'} · content_hash \`${bundle.content_hash ?? '?'}\` · pulled ${bundle.generated_at ?? '?'}`);
|
|
1112
|
+
L.push('');
|
|
1113
|
+
L.push('**This is the map. Read it, then open only the specific file you need — never a whole catalog.**');
|
|
1114
|
+
L.push('Everything the platform supports is here; if a step, function, field, or render intent is NOT in');
|
|
1115
|
+
L.push('this index, it does not exist for a pure-YAML pack. Do not fill a gap from memory.');
|
|
1116
|
+
L.push('');
|
|
1117
|
+
L.push('## Start here');
|
|
1118
|
+
L.push('');
|
|
1119
|
+
L.push('1. `craft-capabilities.md` — how this reference fits together.');
|
|
1120
|
+
L.push('2. `craft-ux.md` — what a *good* pack looks like (home hub, rich cards, confirm-before-commit).');
|
|
1121
|
+
L.push('3. `craft-flows.md` — the flow DSL in practice.');
|
|
1122
|
+
L.push('4. Then the tables below, on demand.');
|
|
1123
|
+
L.push('');
|
|
1124
|
+
L.push('## Guides & reference docs');
|
|
1125
|
+
L.push('');
|
|
1126
|
+
L.push('| Doc | Read it for | File |');
|
|
1127
|
+
L.push('|---|---|---|');
|
|
1128
|
+
for (const d of docs)
|
|
1129
|
+
L.push(`| ${d.title ?? d.key} | ${kbOneLiner(d.summary)} | \`${d.key}.md\` |`);
|
|
1130
|
+
L.push('');
|
|
1131
|
+
L.push('## Catalogs — exact machine-readable schemas');
|
|
1132
|
+
L.push('');
|
|
1133
|
+
for (const c of catalogs) {
|
|
1134
|
+
const entries = exploded.get(c.key);
|
|
1135
|
+
L.push(`### ${c.title ?? c.key}`);
|
|
1136
|
+
L.push('');
|
|
1137
|
+
L.push(kbOneLiner(c.summary, 400));
|
|
1138
|
+
L.push('');
|
|
1139
|
+
if (!entries || entries.length === 0) {
|
|
1140
|
+
L.push(`Single document: \`${c.key}.json\``);
|
|
1141
|
+
L.push('');
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
L.push(`${entries.length} entries in \`${c.key}/\` — one file each.`);
|
|
1145
|
+
L.push('');
|
|
1146
|
+
L.push('| Entry | What it does | File |');
|
|
1147
|
+
L.push('|---|---|---|');
|
|
1148
|
+
for (const e of entries) {
|
|
1149
|
+
L.push(`| \`${e.name}\` | ${e.summary.replace(/\|/g, '\\|')} | \`${c.key}/${kbEntryFileName(e.name)}.json\` |`);
|
|
1150
|
+
}
|
|
1151
|
+
L.push('');
|
|
1152
|
+
}
|
|
1153
|
+
return L.join('\n') + '\n';
|
|
1154
|
+
}
|
|
848
1155
|
async function cmdPlatformKb(flags) {
|
|
849
1156
|
const packDir = resolve(flags.dir ?? '.');
|
|
850
|
-
const t = resolveTarget(flags
|
|
1157
|
+
const t = resolveTarget(flags);
|
|
851
1158
|
const { url } = t;
|
|
852
1159
|
console.log(`→ Pulling the platform capability reference from ${url} …`);
|
|
853
1160
|
const res = await fetchOrDie(`${url}/api/self/t/octwin-platform-kb`, {
|
|
@@ -872,27 +1179,65 @@ async function cmdPlatformKb(flags) {
|
|
|
872
1179
|
const prior = readLocalKb(packDir);
|
|
873
1180
|
// Write the reference into <packDir>/.octwin/platform-kb/ — markdown docs (the
|
|
874
1181
|
// skill reads these first) + JSON catalogs (precise field schemas). Gitignored.
|
|
1182
|
+
//
|
|
1183
|
+
// WIPE first: this directory is a pure cache of one pull, and now that catalogs
|
|
1184
|
+
// explode into per-entry files, leftovers actively mislead. A withdrawn primitive
|
|
1185
|
+
// or a retired catalog would otherwise linger as a file the authoring agent reads
|
|
1186
|
+
// as current — the exact "capability that doesn't exist" failure the KB prevents.
|
|
1187
|
+
//
|
|
1188
|
+
// Clear the CONTENTS, not the directory itself: on Windows a directory that is
|
|
1189
|
+
// any process's working directory cannot be removed (EPERM), and an author with
|
|
1190
|
+
// a shell sitting in the pulled reference is not an error case worth failing a
|
|
1191
|
+
// pull over. Each removal is individually tolerant for the same reason.
|
|
875
1192
|
const outDir = join(packDir, '.octwin', 'platform-kb');
|
|
876
1193
|
mkdirSync(outDir, { recursive: true });
|
|
1194
|
+
for (const stale of readdirSync(outDir)) {
|
|
1195
|
+
try {
|
|
1196
|
+
rmSync(join(outDir, stale), { recursive: true, force: true });
|
|
1197
|
+
}
|
|
1198
|
+
catch { /* keep going; we overwrite below */ }
|
|
1199
|
+
}
|
|
877
1200
|
let mdCount = 0;
|
|
878
|
-
let jsonCount = 0;
|
|
879
1201
|
for (const [key, val] of Object.entries(bundle.docs ?? {})) {
|
|
880
1202
|
if (val == null)
|
|
881
1203
|
continue;
|
|
882
1204
|
writeFileSync(join(outDir, `${key}.md`), val, 'utf8');
|
|
883
1205
|
mdCount++;
|
|
884
1206
|
}
|
|
1207
|
+
// Catalogs: EXPLODE the ones the platform told us how to enumerate (one file per
|
|
1208
|
+
// primitive / declaration / render intent / …), so reaching one entry costs a
|
|
1209
|
+
// ~600-token read instead of parsing a 120 KB blob. Catalogs with no descriptor
|
|
1210
|
+
// (or an unexpected payload shape) fall back to the flat file.
|
|
1211
|
+
const byKey = new Map((bundle.index ?? []).map(e => [e.key, e]));
|
|
1212
|
+
const exploded = new Map();
|
|
1213
|
+
let catalogCount = 0;
|
|
1214
|
+
let entryCount = 0;
|
|
885
1215
|
for (const [key, val] of Object.entries(bundle.sources ?? {})) {
|
|
886
1216
|
if (val == null)
|
|
887
1217
|
continue;
|
|
888
|
-
|
|
889
|
-
|
|
1218
|
+
catalogCount++;
|
|
1219
|
+
const descriptor = byKey.get(key)?.entries;
|
|
1220
|
+
const entries = descriptor ? enumerateKbEntries(val, descriptor) : [];
|
|
1221
|
+
if (entries.length === 0) {
|
|
1222
|
+
writeFileSync(join(outDir, `${key}.json`), JSON.stringify(val, null, 2) + '\n', 'utf8');
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
const dir = join(outDir, key);
|
|
1226
|
+
mkdirSync(dir, { recursive: true });
|
|
1227
|
+
for (const entry of entries) {
|
|
1228
|
+
writeFileSync(join(dir, `${kbEntryFileName(entry.name)}.json`), JSON.stringify(entry.value, null, 2) + '\n', 'utf8');
|
|
1229
|
+
}
|
|
1230
|
+
exploded.set(key, entries);
|
|
1231
|
+
entryCount += entries.length;
|
|
890
1232
|
}
|
|
1233
|
+
// The map the authoring skill reads first.
|
|
1234
|
+
writeFileSync(join(outDir, 'INDEX.md'), buildKbIndexMarkdown(bundle, exploded), 'utf8');
|
|
891
1235
|
// Persist `content_hash` too — the staleness observer (`notifyIfKbStale`) reads
|
|
892
1236
|
// it back and compares against the platform's current hash to nudge a re-pull.
|
|
893
1237
|
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
1238
|
console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
|
|
895
|
-
console.log(` ${mdCount} markdown docs + ${
|
|
1239
|
+
console.log(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`);
|
|
1240
|
+
console.log(' Start at INDEX.md — it maps every doc and every catalog entry to its file.');
|
|
896
1241
|
// Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
|
|
897
1242
|
// moved (a schema shape being replaced shows as a `~ changed`), not just a count.
|
|
898
1243
|
if (prior?.content_hash) {
|
|
@@ -935,8 +1280,7 @@ function targetLabel(t) {
|
|
|
935
1280
|
}
|
|
936
1281
|
/** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
|
|
937
1282
|
async function cmdRecords(flags) {
|
|
938
|
-
const
|
|
939
|
-
const t = resolveTarget(flags, packDir);
|
|
1283
|
+
const t = resolveTarget(flags);
|
|
940
1284
|
const { url } = t;
|
|
941
1285
|
const base = `${url}/api/self/p`;
|
|
942
1286
|
const entity = flags._[0];
|
|
@@ -956,8 +1300,10 @@ async function cmdRecords(flags) {
|
|
|
956
1300
|
return;
|
|
957
1301
|
}
|
|
958
1302
|
console.log(`Entities in ${targetLabel(t)}:`);
|
|
1303
|
+
// `open_count` is the non-archived, non-terminal, RBAC-scoped subset — not
|
|
1304
|
+
// the entity's total. Label it, or it reads as "this entity has 2 records".
|
|
959
1305
|
for (const e of ents)
|
|
960
|
-
console.log(` ${e.entity} (${e.open_count ?? 0}
|
|
1306
|
+
console.log(` ${e.entity} (${e.open_count ?? 0} open)`);
|
|
961
1307
|
console.log('\nList records: octwin records <entity>');
|
|
962
1308
|
return;
|
|
963
1309
|
}
|
|
@@ -990,8 +1336,7 @@ async function cmdRecords(flags) {
|
|
|
990
1336
|
/** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
|
|
991
1337
|
* or show one's event timeline (full text + the renders each turn produced). */
|
|
992
1338
|
async function cmdLogs(flags) {
|
|
993
|
-
const
|
|
994
|
-
const t = resolveTarget(flags, packDir);
|
|
1339
|
+
const t = resolveTarget(flags);
|
|
995
1340
|
const { url } = t;
|
|
996
1341
|
const base = `${url}/api/self/p`;
|
|
997
1342
|
const convId = flags._[0];
|
|
@@ -1165,8 +1510,135 @@ class SseFrameReader {
|
|
|
1165
1510
|
const REPLAY_SETTLE_MS = 400; // quiet gap that marks the end of the connect replay burst
|
|
1166
1511
|
const TURN_SETTLE_MS = 2_000; // quiet gap after a render = the turn finished sending
|
|
1167
1512
|
const REPLY_TIMEOUT_MS = 45_000; // hard cap waiting for the first render of the turn
|
|
1168
|
-
/**
|
|
1169
|
-
*
|
|
1513
|
+
/**
|
|
1514
|
+
* Parse a `--script` file — ONE TURN PER LINE, in order:
|
|
1515
|
+
*
|
|
1516
|
+
* # a comment, and blank lines, are skipped
|
|
1517
|
+
* احجز موعد → a typed message
|
|
1518
|
+
* tap:t:invoke:book:doctor_id=D1 → press a rendered row / button
|
|
1519
|
+
* media:./licence.jpg → upload a file
|
|
1520
|
+
* media:./licence.jpg | here you go → upload WITH a caption
|
|
1521
|
+
*
|
|
1522
|
+
* A `tap:` line keeps everything after the first colon verbatim, because a tap id
|
|
1523
|
+
* is itself colon-delimited (`t:invoke:target:bindings`).
|
|
1524
|
+
*/
|
|
1525
|
+
function parseChatScript(body) {
|
|
1526
|
+
const turns = [];
|
|
1527
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
1528
|
+
const line = raw.trim();
|
|
1529
|
+
if (!line || line.startsWith('#'))
|
|
1530
|
+
continue;
|
|
1531
|
+
if (line.startsWith('tap:')) {
|
|
1532
|
+
turns.push({ tap: line.slice(4).trim() });
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
if (line.startsWith('media:')) {
|
|
1536
|
+
const rest = line.slice(6);
|
|
1537
|
+
const bar = rest.indexOf('|');
|
|
1538
|
+
turns.push(bar === -1
|
|
1539
|
+
? { media: rest.trim() }
|
|
1540
|
+
: { media: rest.slice(0, bar).trim(), text: rest.slice(bar + 1).trim() });
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
turns.push({ text: line });
|
|
1544
|
+
}
|
|
1545
|
+
return turns;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Send ONE turn and print everything it rendered. Returns the new frame boundary
|
|
1549
|
+
* (so the next turn only accepts frames newer than this turn's output) and how
|
|
1550
|
+
* many renders arrived.
|
|
1551
|
+
*
|
|
1552
|
+
* Splitting this out is what makes `--script` reliable. Driving a multi-step flow
|
|
1553
|
+
* by chaining shell invocations (`chat A && chat B`) races the agent loop: this
|
|
1554
|
+
* command ends a turn on a QUIET GAP, and the server-side loop can still be
|
|
1555
|
+
* running when the process exits, so the next invocation's inbound lands
|
|
1556
|
+
* mid-turn. The agent then fills required fields with placeholder text, or starts
|
|
1557
|
+
* a second workflow run. Inside one process the loop simply waits for the settle
|
|
1558
|
+
* before sending the next turn, over the same SSE connection.
|
|
1559
|
+
*/
|
|
1560
|
+
async function runChatTurn(args) {
|
|
1561
|
+
const { url, tenant, project, from, turn, frames, asJson } = args;
|
|
1562
|
+
let boundary = args.boundary;
|
|
1563
|
+
// Fresh idempotency key per turn — the platform dedups inbound on `local_id`
|
|
1564
|
+
// for 5 minutes, so a reused id makes the second turn a silent no-op.
|
|
1565
|
+
const localId = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1566
|
+
const inbound = `${url}/api/web/inbound/${tenant}/${project}`;
|
|
1567
|
+
let postRes;
|
|
1568
|
+
if (turn.media && !turn.tap) {
|
|
1569
|
+
const { blob, kind, filename } = await resolveMediaPart(url, turn.media);
|
|
1570
|
+
console.log(`→ [${from}] (media:${kind}) ${filename}${turn.text ? ` — "${turn.text}"` : ''}`);
|
|
1571
|
+
const form = new FormData();
|
|
1572
|
+
form.append('type', kind);
|
|
1573
|
+
form.append('from', from);
|
|
1574
|
+
form.append('local_id', localId);
|
|
1575
|
+
if (turn.text)
|
|
1576
|
+
form.append('caption', turn.text);
|
|
1577
|
+
form.append('file', blob, filename);
|
|
1578
|
+
postRes = await fetchOrDie(inbound, { method: 'POST', body: form }, 'send media');
|
|
1579
|
+
}
|
|
1580
|
+
else {
|
|
1581
|
+
console.log(`→ [${from}] ${turn.tap ? `(tap) ${turn.tap}` : turn.text}`);
|
|
1582
|
+
const body = turn.tap
|
|
1583
|
+
? { type: 'interactive', from, tap_id: turn.tap, ...(turn.text ? { raw_title: turn.text } : {}), local_id: localId }
|
|
1584
|
+
: { type: 'text', from, text: turn.text, local_id: localId };
|
|
1585
|
+
postRes = await fetchOrDie(inbound, {
|
|
1586
|
+
method: 'POST',
|
|
1587
|
+
headers: { 'content-type': 'application/json' },
|
|
1588
|
+
body: JSON.stringify(body),
|
|
1589
|
+
}, 'send message');
|
|
1590
|
+
}
|
|
1591
|
+
if (!postRes.ok) {
|
|
1592
|
+
await frames.cancel();
|
|
1593
|
+
die(`send rejected (HTTP ${postRes.status}): ${await postRes.text()}`);
|
|
1594
|
+
}
|
|
1595
|
+
if (!asJson)
|
|
1596
|
+
console.log(` … delivered — waiting for the reply (up to ${Math.round(REPLY_TIMEOUT_MS / 1000)}s)`);
|
|
1597
|
+
// Collect THIS turn's renders (id > boundary). A turn can send several
|
|
1598
|
+
// messages, so keep reading until a quiet gap after the last render.
|
|
1599
|
+
const deadline = Date.now() + REPLY_TIMEOUT_MS;
|
|
1600
|
+
let rendersSeen = 0;
|
|
1601
|
+
for (;;) {
|
|
1602
|
+
const remaining = deadline - Date.now();
|
|
1603
|
+
if (remaining <= 0)
|
|
1604
|
+
break;
|
|
1605
|
+
const f = await frames.next(rendersSeen > 0 ? TURN_SETTLE_MS : Math.min(remaining, REPLY_TIMEOUT_MS));
|
|
1606
|
+
if (f === 'timeout') {
|
|
1607
|
+
if (rendersSeen > 0)
|
|
1608
|
+
break;
|
|
1609
|
+
else
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
if (f === 'done')
|
|
1613
|
+
break;
|
|
1614
|
+
if (f.id != null && f.id <= boundary)
|
|
1615
|
+
continue; // late replay stragglers
|
|
1616
|
+
if (f.id != null)
|
|
1617
|
+
boundary = f.id;
|
|
1618
|
+
if (asJson) {
|
|
1619
|
+
console.log(JSON.stringify(f.ev));
|
|
1620
|
+
if (f.ev?.kind === 'render')
|
|
1621
|
+
rendersSeen++;
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
if (f.ev?.kind !== 'render')
|
|
1625
|
+
continue; // status/typing noise
|
|
1626
|
+
rendersSeen++;
|
|
1627
|
+
console.log(`← ${f.ev.body ?? '(no text body)'}${f.ev.hint?.type && f.ev.hint.type !== 'text' ? ` (render: ${f.ev.hint.type})` : ''}`);
|
|
1628
|
+
printHint(f.ev.hint);
|
|
1629
|
+
}
|
|
1630
|
+
return { boundary, rendersSeen };
|
|
1631
|
+
}
|
|
1632
|
+
/** `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--script <file>] [--json]`
|
|
1633
|
+
* — drive a turn (or a whole scripted conversation) through the dev web channel
|
|
1634
|
+
* and print everything it rendered.
|
|
1635
|
+
*
|
|
1636
|
+
* ONE TURN PER INVOCATION. A turn ends on a QUIET GAP (`TURN_SETTLE_MS`), which
|
|
1637
|
+
* can arrive before the server-side agent loop has actually finished — so
|
|
1638
|
+
* chaining invocations (`chat A && chat B`) races it, and the second inbound can
|
|
1639
|
+
* land mid-turn (the agent then fills required fields with placeholder text, or
|
|
1640
|
+
* starts a second workflow run). To drive a multi-step flow, use `--script`: it
|
|
1641
|
+
* runs the turns in one process over one connection, waiting for each to settle.
|
|
1170
1642
|
*
|
|
1171
1643
|
* Multi-turn works: the platform keeps ONE open conversation per handle, so the
|
|
1172
1644
|
* same `--as` continues the same conversation. Two traps this command handles:
|
|
@@ -1176,18 +1648,19 @@ const REPLY_TIMEOUT_MS = 45_000; // hard cap waiting for the first render of the
|
|
|
1176
1648
|
* drain the replay first and only accept frames newer than it as the reply
|
|
1177
1649
|
* (naively printing the first render showed LAST turn's message again). */
|
|
1178
1650
|
async function cmdChat(flags) {
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
const
|
|
1651
|
+
// Chat needs a url but NOT a token (an explicit --tenant is enough), so it reads
|
|
1652
|
+
// the raw target rather than going through the token-requiring `resolveTarget`.
|
|
1653
|
+
const raw = readTarget(flags);
|
|
1654
|
+
const url = raw.url;
|
|
1182
1655
|
if (!url)
|
|
1183
|
-
die('no platform url —
|
|
1184
|
-
let tenant =
|
|
1185
|
-
let project =
|
|
1656
|
+
die('no platform url — run `octwin login --url <url> --token oct_…`, or pass --url / PACK_PLATFORM_URL');
|
|
1657
|
+
let tenant = raw.tenant ?? '';
|
|
1658
|
+
let project = raw.project ?? '';
|
|
1186
1659
|
// The dev web channel is tenant/project-pathed (it simulates an end-user on a
|
|
1187
1660
|
// specific project). When they aren't configured, derive them from the token —
|
|
1188
1661
|
// its tenant + optional project pin — via the slug-free `/api/self/t/whoami`.
|
|
1189
1662
|
if (!tenant || !project) {
|
|
1190
|
-
const token =
|
|
1663
|
+
const token = raw.token;
|
|
1191
1664
|
if (token) {
|
|
1192
1665
|
try {
|
|
1193
1666
|
const who = await fetch(`${url}/api/self/t/whoami`, {
|
|
@@ -1203,7 +1676,7 @@ async function cmdChat(flags) {
|
|
|
1203
1676
|
}
|
|
1204
1677
|
}
|
|
1205
1678
|
if (!tenant)
|
|
1206
|
-
die('no tenant — set --tenant / PACK_TENANT
|
|
1679
|
+
die('no tenant — set --tenant / PACK_TENANT, or log in so the token can supply it');
|
|
1207
1680
|
if (!project)
|
|
1208
1681
|
project = 'main';
|
|
1209
1682
|
const from = flags.as ?? 'cli-tester';
|
|
@@ -1211,10 +1684,23 @@ async function cmdChat(flags) {
|
|
|
1211
1684
|
const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
|
|
1212
1685
|
const mediaArg = typeof flags.media === 'string' ? flags.media : undefined;
|
|
1213
1686
|
const message = flags._[0];
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1687
|
+
const scriptArg = typeof flags.script === 'string' ? flags.script : undefined;
|
|
1688
|
+
let turns;
|
|
1689
|
+
if (scriptArg) {
|
|
1690
|
+
const scriptPath = resolve(scriptArg);
|
|
1691
|
+
if (!existsSync(scriptPath))
|
|
1692
|
+
die(`no such script file: ${scriptPath}`);
|
|
1693
|
+
turns = parseChatScript(readFileSync(scriptPath, 'utf8'));
|
|
1694
|
+
if (turns.length === 0)
|
|
1695
|
+
die(`${scriptPath} has no turns (blank lines and # comments are skipped)`);
|
|
1696
|
+
}
|
|
1697
|
+
else {
|
|
1698
|
+
if (!message && !tapId && !mediaArg) {
|
|
1699
|
+
die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]\n' +
|
|
1700
|
+
' or: octwin chat --script <file> [--as <handle>] (one turn per line; "tap:<id>" / "media:<path>")');
|
|
1701
|
+
}
|
|
1702
|
+
turns = [{ ...(message ? { text: message } : {}), ...(tapId ? { tap: tapId } : {}), ...(mediaArg ? { media: mediaArg } : {}) }];
|
|
1703
|
+
}
|
|
1218
1704
|
if (!asJson)
|
|
1219
1705
|
console.log(`→ Connecting to ${tenant}/${project} as '${from}' …`);
|
|
1220
1706
|
const evRes = await fetchOrDie(`${url}/api/web/events/${tenant}/${project}/${encodeURIComponent(from)}`, { headers: { accept: 'text/event-stream' } }, 'open chat stream');
|
|
@@ -1236,74 +1722,25 @@ async function cmdChat(flags) {
|
|
|
1236
1722
|
if (f.id != null && f.id > boundary)
|
|
1237
1723
|
boundary = f.id;
|
|
1238
1724
|
}
|
|
1239
|
-
// Phase 2 —
|
|
1240
|
-
|
|
1241
|
-
let
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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;
|
|
1725
|
+
// Phase 2+3 — run each turn in order, over the SAME connection. One turn is
|
|
1726
|
+
// the normal case; `--script` drives a whole conversation.
|
|
1727
|
+
let totalRenders = 0;
|
|
1728
|
+
for (const [i, turn] of turns.entries()) {
|
|
1729
|
+
if (turns.length > 1)
|
|
1730
|
+
console.log(`\n── turn ${i + 1}/${turns.length} ──`);
|
|
1731
|
+
const r = await runChatTurn({ url, tenant, project, from, turn, frames, boundary, asJson });
|
|
1732
|
+
boundary = r.boundary;
|
|
1733
|
+
totalRenders += r.rendersSeen;
|
|
1734
|
+
if (r.rendersSeen === 0 && turns.length > 1) {
|
|
1735
|
+
// Stop rather than fire the rest of the script into a conversation that
|
|
1736
|
+
// isn't answering — the remaining turns would land out of context.
|
|
1737
|
+
await cancel();
|
|
1738
|
+
console.error(` turn ${i + 1} produced no render after ${Math.round(REPLY_TIMEOUT_MS / 1000)}s — stopping the script here.`);
|
|
1739
|
+
process.exit(1);
|
|
1298
1740
|
}
|
|
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
1741
|
}
|
|
1305
1742
|
await cancel();
|
|
1306
|
-
if (
|
|
1743
|
+
if (totalRenders === 0) {
|
|
1307
1744
|
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
1745
|
process.exit(1);
|
|
1309
1746
|
}
|
|
@@ -1318,8 +1755,7 @@ async function cmdMedia(flags) {
|
|
|
1318
1755
|
const sub = flags._[0];
|
|
1319
1756
|
if (sub !== 'generate')
|
|
1320
1757
|
die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
|
|
1321
|
-
const
|
|
1322
|
-
const t = resolveTarget(flags, packDir);
|
|
1758
|
+
const t = resolveTarget(flags);
|
|
1323
1759
|
const { url } = t;
|
|
1324
1760
|
const prompt = flags._[1];
|
|
1325
1761
|
if (!prompt)
|
|
@@ -1374,8 +1810,7 @@ async function cmdMedia(flags) {
|
|
|
1374
1810
|
/** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
|
|
1375
1811
|
* the aggregate inbox, one case + its timeline, or the queue list. */
|
|
1376
1812
|
async function cmdCases(flags) {
|
|
1377
|
-
const
|
|
1378
|
-
const t = resolveTarget(flags, packDir);
|
|
1813
|
+
const t = resolveTarget(flags);
|
|
1379
1814
|
const { url } = t;
|
|
1380
1815
|
const base = `${url}/api/self/p`;
|
|
1381
1816
|
const caseId = flags._[0];
|
|
@@ -1517,8 +1952,7 @@ function printGoverned(label, g) {
|
|
|
1517
1952
|
* EFFECTIVE model/memory settings and which layer won, plus (`--prompt`) the exact
|
|
1518
1953
|
* system prompt the LLM sees for this project. Needs an `agents:read` token. */
|
|
1519
1954
|
async function cmdAgents(flags) {
|
|
1520
|
-
const
|
|
1521
|
-
const t = resolveTarget(flags, packDir);
|
|
1955
|
+
const t = resolveTarget(flags);
|
|
1522
1956
|
const { url } = t;
|
|
1523
1957
|
const base = `${url}/api/self/p/agents`;
|
|
1524
1958
|
const ref = flags._[0];
|
|
@@ -1615,8 +2049,7 @@ function printPaymentNote(paymentStatus) {
|
|
|
1615
2049
|
* the orders a conversation created: money breakdown, payment state, allowed
|
|
1616
2050
|
* transitions. Needs an `orders:read` token + the `orders` plan feature. */
|
|
1617
2051
|
async function cmdOrders(flags) {
|
|
1618
|
-
const
|
|
1619
|
-
const t = resolveTarget(flags, packDir);
|
|
2052
|
+
const t = resolveTarget(flags);
|
|
1620
2053
|
const { url } = t;
|
|
1621
2054
|
const base = `${url}/api/self/p/orders`;
|
|
1622
2055
|
const referenceId = flags._[0];
|
|
@@ -1689,8 +2122,7 @@ function printNoAnalyticsData(entity) {
|
|
|
1689
2122
|
* [--stage <id>] [--json]` — stage conversion over ANY pipelined XRM entity
|
|
1690
2123
|
* (orders, carts, cases, bookings, or a pack's own). Needs `records:read`. */
|
|
1691
2124
|
async function cmdAnalytics(flags) {
|
|
1692
|
-
const
|
|
1693
|
-
const t = resolveTarget(flags, packDir);
|
|
2125
|
+
const t = resolveTarget(flags);
|
|
1694
2126
|
const { url } = t;
|
|
1695
2127
|
const base = `${url}/api/self/p/xrm/analytics`;
|
|
1696
2128
|
const entity = flags._[0];
|
|
@@ -1812,8 +2244,7 @@ async function cmdAnalytics(flags) {
|
|
|
1812
2244
|
* sells, their stock, and the WhatsApp catalog binding. Needs `catalog:read` + the
|
|
1813
2245
|
* `catalog` plan feature. */
|
|
1814
2246
|
async function cmdCatalog(flags) {
|
|
1815
|
-
const
|
|
1816
|
-
const t = resolveTarget(flags, packDir);
|
|
2247
|
+
const t = resolveTarget(flags);
|
|
1817
2248
|
const { url } = t;
|
|
1818
2249
|
const base = `${url}/api/self/p/catalog`;
|
|
1819
2250
|
const asJson = flags.json === true;
|
|
@@ -1873,8 +2304,7 @@ async function cmdCatalog(flags) {
|
|
|
1873
2304
|
* — the scheduling engine's state, or the computed slots for one bookable resource
|
|
1874
2305
|
* (the verification the `--seed` availability fan-out was missing). `scheduling:read`. */
|
|
1875
2306
|
async function cmdScheduling(flags) {
|
|
1876
|
-
const
|
|
1877
|
-
const t = resolveTarget(flags, packDir);
|
|
2307
|
+
const t = resolveTarget(flags);
|
|
1878
2308
|
const { url } = t;
|
|
1879
2309
|
const base = `${url}/api/self/p/scheduling`;
|
|
1880
2310
|
const asJson = flags.json === true;
|
|
@@ -1943,6 +2373,7 @@ function help() {
|
|
|
1943
2373
|
octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
|
|
1944
2374
|
octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
1945
2375
|
octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
|
|
2376
|
+
octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
|
|
1946
2377
|
octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
|
|
1947
2378
|
octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
|
|
1948
2379
|
octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
|
|
@@ -1961,7 +2392,7 @@ Multi-turn: the platform keeps ONE open conversation per --as handle — consecu
|
|
|
1961
2392
|
button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
|
|
1962
2393
|
Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
|
|
1963
2394
|
octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
|
|
1964
|
-
Config (deploy): flags >
|
|
2395
|
+
Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
|
|
1965
2396
|
Per-command usage: octwin <command> --help`);
|
|
1966
2397
|
}
|
|
1967
2398
|
/** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
|
|
@@ -1974,7 +2405,8 @@ const COMMAND_HELP = {
|
|
|
1974
2405
|
manifest + flow-DSL validation (all errors at once) — same check as deploy.`,
|
|
1975
2406
|
login: `octwin login --url <platformUrl> --token oct_…
|
|
1976
2407
|
Save a deploy token (console → Settings → API tokens) for that platform url,
|
|
1977
|
-
|
|
2408
|
+
make that url the DEFAULT deploy target for every later command, and echo the
|
|
2409
|
+
workspace + project pin + scopes the token reaches.`,
|
|
1978
2410
|
whoami: `octwin whoami [--url <url>] [--tenant <slug>]
|
|
1979
2411
|
Verify the resolved token authenticates against the tenant.`,
|
|
1980
2412
|
deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
@@ -1992,14 +2424,37 @@ const COMMAND_HELP = {
|
|
|
1992
2424
|
No id = recent conversations (handle, status, last activity; --as filters).
|
|
1993
2425
|
With id = the full event timeline including what each turn rendered.
|
|
1994
2426
|
--json = raw events (verbatim payloads).`,
|
|
2427
|
+
pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
|
|
2428
|
+
Write a DEPLOYED pack's source back to disk — the inverse of deploy.
|
|
2429
|
+
A pack pushed with 'octwin deploy' lives on the platform as an artifact the
|
|
2430
|
+
runtime serves but nothing hands back, so its only source copy is the machine
|
|
2431
|
+
that pushed it. Pull it, fix it, redeploy it.
|
|
2432
|
+
Defaults to the version installed on the target project; --version overrides.
|
|
2433
|
+
--dir defaults to ./<packId>; a non-empty dir needs --force.
|
|
2434
|
+
The pulled dir redeploys where it came from — the target is your saved login.
|
|
2435
|
+
You may pull a pack your tenant OWNS (deployed); an operator token pulls any.`,
|
|
1995
2436
|
chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
|
|
1996
|
-
|
|
2437
|
+
octwin chat --script <file> [--as <handle>] [--json]
|
|
2438
|
+
Drive ONE turn through the dev web channel and print every render with its
|
|
1997
2439
|
tap ids. Same --as handle = same conversation (multi-turn works).
|
|
1998
2440
|
--tap presses a rendered button/list row instead of sending text.
|
|
1999
2441
|
--media uploads a local file (or a media id from 'media generate --json') as
|
|
2000
2442
|
an image/document/audio inbound — any "message" rides as its caption; feeds a
|
|
2001
2443
|
running media-collect flow (e.g. activate-app).
|
|
2002
|
-
--json dumps the raw SSE envelopes for the turn
|
|
2444
|
+
--json dumps the raw SSE envelopes for the turn.
|
|
2445
|
+
|
|
2446
|
+
--script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
|
|
2447
|
+
process over one connection — waiting for each turn to settle before sending
|
|
2448
|
+
the next. Use this for any multi-step flow: chaining shell invocations races
|
|
2449
|
+
the agent loop, because a turn ends on a quiet gap that can arrive while the
|
|
2450
|
+
server is still working (the symptom is placeholder-filled fields or a second
|
|
2451
|
+
workflow run). Blank lines and # comments are skipped:
|
|
2452
|
+
|
|
2453
|
+
# book an appointment end to end
|
|
2454
|
+
احجز موعد
|
|
2455
|
+
tap:t:invoke:book-appointment:doctor_id=D1
|
|
2456
|
+
media:./licence.jpg | here is my licence
|
|
2457
|
+
tap:t:resume:book-appointment:run_id=R1;_ctl_approved=true`,
|
|
2003
2458
|
media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
|
|
2004
2459
|
AI-generate an image (needs a media:generate-scoped token), store it as a
|
|
2005
2460
|
public asset, and print its MEDIA- handle + serve URL. --out downloads the
|
|
@@ -2066,6 +2521,9 @@ async function main() {
|
|
|
2066
2521
|
case 'deploy':
|
|
2067
2522
|
await cmdDeploy(flags);
|
|
2068
2523
|
break;
|
|
2524
|
+
case 'pull':
|
|
2525
|
+
await cmdPull(flags);
|
|
2526
|
+
break;
|
|
2069
2527
|
case 'status':
|
|
2070
2528
|
await cmdStatus(flags);
|
|
2071
2529
|
break;
|