octwin-cli 0.1.21 → 0.3.0
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 +432 -350
- package/README.md +210 -188
- package/dist/index.js +1053 -91
- package/dist/lib/args-check.js +27 -10
- package/dist/lib/kb-path.js +76 -0
- package/dist/lib/page.js +61 -0
- package/dist/lib/render-check.js +20 -8
- package/dist/lib/validate.js +11 -2
- package/package.json +37 -37
- package/templates/starter/manifest.yaml +55 -54
package/dist/index.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* octwin --version | -v # print the CLI version (+ any upgrade notice)
|
|
11
11
|
* octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
|
|
12
|
-
* octwin validate [--dir .] [--remote] # --remote → the platform's FULL schema check, all errors at once
|
|
12
|
+
* octwin validate [--dir .] [--remote] [--require-kb] # --remote → the platform's FULL schema check + lint, all errors at once
|
|
13
13
|
* octwin login --url <platformUrl> --token oct_…
|
|
14
14
|
* octwin whoami [--url <url>] [--tenant <slug>]
|
|
15
|
+
* octwin projects [--archived] # the --project slugs this token can name
|
|
15
16
|
* octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
16
17
|
* octwin pull <packId> [--dir <out>] [--version v] [--force] # write a DEPLOYED pack's source back to disk
|
|
17
18
|
* octwin status [--dir .] # did my deploy land? which version is live?
|
|
@@ -60,7 +61,9 @@ import { applyRenames } from './lib/rename.js';
|
|
|
60
61
|
import { validatePackBundle } from './lib/validate.js';
|
|
61
62
|
import { loadAllowedRenderKeys, findRenderKeyViolations, describeRenderFinding } from './lib/render-check.js';
|
|
62
63
|
import { loadPrimitiveArgSpecs, findArgViolations, describeArgFinding } from './lib/args-check.js';
|
|
64
|
+
import { describeKbLookup, findPlatformKbDir } from './lib/kb-path.js';
|
|
63
65
|
import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
|
|
66
|
+
import { readPage, morePageHint } from './lib/page.js';
|
|
64
67
|
// The in-package starter template ships alongside `dist/` and `src/` (both one
|
|
65
68
|
// level under the package root), so `../templates/starter` resolves for the
|
|
66
69
|
// built CLI and `tsx` dev alike.
|
|
@@ -76,17 +79,64 @@ const VERSION = (() => {
|
|
|
76
79
|
return '0.0.0';
|
|
77
80
|
}
|
|
78
81
|
})();
|
|
82
|
+
/**
|
|
83
|
+
* Flags that may be REPEATED, collecting into an array (`--set a=1 --set b=2`).
|
|
84
|
+
*
|
|
85
|
+
* An explicit list rather than "collect every repeat", so no existing flag changes
|
|
86
|
+
* shape: `--limit 5 --limit 9` stays last-wins, and a caller reading `flags.limit`
|
|
87
|
+
* as a string keeps working. Only field-builders belong here.
|
|
88
|
+
*/
|
|
89
|
+
const REPEATABLE_FLAGS = new Set(['set', 'param']);
|
|
90
|
+
/** Read a repeatable flag as a list, whatever arity it was passed with. */
|
|
91
|
+
function flagList(flags, key) {
|
|
92
|
+
const v = flags[key];
|
|
93
|
+
if (Array.isArray(v))
|
|
94
|
+
return v;
|
|
95
|
+
if (typeof v === 'string')
|
|
96
|
+
return [v];
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `--flag value` · `--flag=value` · `--flag` (boolean) · positionals into `_`.
|
|
101
|
+
*
|
|
102
|
+
* `--flag=value` support was missing and failed SILENTLY in the worst way: the
|
|
103
|
+
* whole token became the key, so `--limit=5` set `flags['limit=5'] = true` and
|
|
104
|
+
* the command ran with its default limit rather than erroring. That is the same
|
|
105
|
+
* class of bug as the `Page` rename — a wrong result, not a message.
|
|
106
|
+
*/
|
|
79
107
|
function parseFlags(argv) {
|
|
80
108
|
const f = { _: [] };
|
|
109
|
+
const put = (key, value) => {
|
|
110
|
+
if (!REPEATABLE_FLAGS.has(key)) {
|
|
111
|
+
f[key] = value;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const prev = f[key];
|
|
115
|
+
if (prev === undefined)
|
|
116
|
+
f[key] = typeof value === 'string' ? [value] : value;
|
|
117
|
+
else if (Array.isArray(prev)) {
|
|
118
|
+
if (typeof value === 'string')
|
|
119
|
+
prev.push(value);
|
|
120
|
+
}
|
|
121
|
+
else if (typeof prev === 'string' && typeof value === 'string')
|
|
122
|
+
f[key] = [prev, value];
|
|
123
|
+
else
|
|
124
|
+
f[key] = value;
|
|
125
|
+
};
|
|
81
126
|
for (let i = 0; i < argv.length; i++) {
|
|
82
127
|
const a = argv[i];
|
|
83
128
|
if (a.startsWith('--')) {
|
|
84
|
-
const
|
|
129
|
+
const body = a.slice(2);
|
|
130
|
+
const eq = body.indexOf('=');
|
|
131
|
+
if (eq > 0) {
|
|
132
|
+
put(body.slice(0, eq), body.slice(eq + 1));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
85
135
|
const next = argv[i + 1];
|
|
86
136
|
if (next === undefined || next.startsWith('--'))
|
|
87
|
-
|
|
137
|
+
put(body, true);
|
|
88
138
|
else {
|
|
89
|
-
|
|
139
|
+
put(body, next);
|
|
90
140
|
i++;
|
|
91
141
|
}
|
|
92
142
|
}
|
|
@@ -119,17 +169,35 @@ function authFailureHint(status, url) {
|
|
|
119
169
|
: `the token is valid but not authorized here (missing scope, plan feature, or role)`;
|
|
120
170
|
}
|
|
121
171
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* calls. A 403 that names the missing scope is the difference between a two-minute
|
|
126
|
-
* fix (mint a wider token) and a support thread.
|
|
172
|
+
* Write VERBS need a different scope than the read they share a command with —
|
|
173
|
+
* `octwin cases` is `cases:read`, `octwin cases note` is `cases:write` — so the
|
|
174
|
+
* requirement is resolved by `<command> <verb>` first, then by command.
|
|
127
175
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* (`scopeSatisfies` — the preset branches only reach `:read`/`:write`). That is the
|
|
131
|
-
* single most common "but my token is admin" confusion, hence the explicit note below.
|
|
176
|
+
* Keyed on the verb rather than duplicating whole commands, because the hint's
|
|
177
|
+
* whole value is naming the scope that would actually fix the 403.
|
|
132
178
|
*/
|
|
179
|
+
const VERB_REQUIREMENTS = {
|
|
180
|
+
'records create': { scope: 'records:write', feature: 'records' },
|
|
181
|
+
'records patch': { scope: 'records:write', feature: 'records' },
|
|
182
|
+
'records stage': { scope: 'records:write', feature: 'records' },
|
|
183
|
+
'records note': { scope: 'records:write', feature: 'records' },
|
|
184
|
+
'records tasks': { scope: 'records:read', feature: 'tasks' },
|
|
185
|
+
'records task': { scope: 'records:write', feature: 'tasks' },
|
|
186
|
+
'cases assign': { scope: 'cases:write', feature: 'cases' },
|
|
187
|
+
'cases note': { scope: 'cases:write', feature: 'cases' },
|
|
188
|
+
'cases transition': { scope: 'cases:write', feature: 'cases' },
|
|
189
|
+
// `decide --dry-run` hits the PREVIEW route, which is `cases:read`. Naming the
|
|
190
|
+
// write scope is still the right hint: the committing form is the default.
|
|
191
|
+
'cases decide': { scope: 'cases:write', feature: 'cases' },
|
|
192
|
+
'orders transition': { scope: 'orders:write', feature: 'orders' },
|
|
193
|
+
'orders refund': { scope: 'orders:write', feature: 'orders' },
|
|
194
|
+
'catalog availability': { scope: 'catalog:write', feature: 'catalog' },
|
|
195
|
+
'catalog stock': { scope: 'catalog:write', feature: 'catalog' },
|
|
196
|
+
'scheduling rules': { scope: 'scheduling:read' },
|
|
197
|
+
'scheduling rule': { scope: 'scheduling:write' },
|
|
198
|
+
'scheduling exception': { scope: 'scheduling:write' },
|
|
199
|
+
'agents set': { scope: 'agents:write' },
|
|
200
|
+
};
|
|
133
201
|
const COMMAND_REQUIREMENTS = {
|
|
134
202
|
deploy: { scope: 'pack:deploy' },
|
|
135
203
|
validate: { scope: 'pack:deploy' },
|
|
@@ -140,6 +208,7 @@ const COMMAND_REQUIREMENTS = {
|
|
|
140
208
|
// only source copy printed the generic hint WITHOUT naming the scope to grant.
|
|
141
209
|
pull: { scope: 'pack:deploy' },
|
|
142
210
|
'platform-kb': { scope: 'pack:deploy' },
|
|
211
|
+
feedback: { scope: 'pack:deploy' },
|
|
143
212
|
media: { scope: 'media:generate' },
|
|
144
213
|
// The plan feature gates RECORD reads, not the entity list (`/xrm/entities` carries only
|
|
145
214
|
// the scope guard) — so the hint says which half it applies to rather than blaming the
|
|
@@ -152,14 +221,22 @@ const COMMAND_REQUIREMENTS = {
|
|
|
152
221
|
catalog: { scope: 'catalog:read', feature: 'catalog' },
|
|
153
222
|
scheduling: { scope: 'scheduling:read' },
|
|
154
223
|
agents: { scope: 'agents:read' },
|
|
224
|
+
// The route accepts `pack:deploy` too (that is the point of the command), but the
|
|
225
|
+
// hint names the scope a NON-deploy token would be missing — a `pack:deploy`
|
|
226
|
+
// holder never sees this line, because they never get the 403.
|
|
227
|
+
projects: { scope: 'projects:read' },
|
|
155
228
|
};
|
|
156
229
|
/** The command currently running — set once in `main()` so any failure printer can
|
|
157
|
-
* name the scope that command needs without threading it through every call.
|
|
230
|
+
* name the scope that command needs without threading it through every call.
|
|
231
|
+
* Carries the write VERB too (`cases note`), since that is what decides the scope. */
|
|
158
232
|
let CURRENT_COMMAND;
|
|
159
233
|
/** `→ needs the \`orders:read\` scope …` — the requirement line for the running
|
|
160
234
|
* command, or '' when the command has no declared requirement. */
|
|
161
235
|
function scopeRequirementHint() {
|
|
162
|
-
|
|
236
|
+
// Most specific first: `<command> <verb>` beats the command's own (read) entry.
|
|
237
|
+
const req = CURRENT_COMMAND
|
|
238
|
+
? (VERB_REQUIREMENTS[CURRENT_COMMAND] ?? COMMAND_REQUIREMENTS[CURRENT_COMMAND.split(' ')[0]])
|
|
239
|
+
: undefined;
|
|
163
240
|
if (!req)
|
|
164
241
|
return '';
|
|
165
242
|
const special = req.scope === 'pack:deploy' || req.scope === 'media:generate';
|
|
@@ -404,8 +481,11 @@ async function notifyIfOutdated() {
|
|
|
404
481
|
/** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
|
|
405
482
|
* (content hash + per-entry index), or null if nothing has been pulled yet. */
|
|
406
483
|
function readLocalKb(packDir) {
|
|
484
|
+
const kbDir = findPlatformKbDir(packDir); // walks up — a repo-root pull covers every pack under it
|
|
485
|
+
if (!kbDir)
|
|
486
|
+
return null;
|
|
407
487
|
try {
|
|
408
|
-
const idx = JSON.parse(readFileSync(join(
|
|
488
|
+
const idx = JSON.parse(readFileSync(join(kbDir, 'index.json'), 'utf8'));
|
|
409
489
|
return {
|
|
410
490
|
content_hash: typeof idx.content_hash === 'string' ? idx.content_hash : null,
|
|
411
491
|
index: Array.isArray(idx.index) ? idx.index : [],
|
|
@@ -501,11 +581,13 @@ function commandTouchesPlatform(command, flags) {
|
|
|
501
581
|
case 'cases':
|
|
502
582
|
case 'logs':
|
|
503
583
|
case 'whoami':
|
|
584
|
+
case 'feedback':
|
|
504
585
|
case 'agents':
|
|
505
586
|
case 'orders':
|
|
506
587
|
case 'analytics':
|
|
507
588
|
case 'catalog':
|
|
508
|
-
case 'scheduling':
|
|
589
|
+
case 'scheduling':
|
|
590
|
+
case 'projects': return true;
|
|
509
591
|
default: return false;
|
|
510
592
|
}
|
|
511
593
|
}
|
|
@@ -560,22 +642,28 @@ async function cmdValidate(flags) {
|
|
|
560
642
|
const packDir = resolve(flags.dir ?? '.');
|
|
561
643
|
const { id, version, files, blobs } = localValidate(packDir); // offline structural gate first (fast, no server/token)
|
|
562
644
|
console.log(`✓ ${id}@${version} passes the offline structural check (${Object.keys(files).length} files, ${Object.keys(blobs).length} image(s))`);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
645
|
+
/** Every YAML file in the bundle, parsed once. A syntax error is the structural gate's to report. */
|
|
646
|
+
const yamlDocs = () => Object.entries(files)
|
|
647
|
+
.filter(([p]) => /\.ya?ml$/i.test(p))
|
|
648
|
+
.flatMap(([p, body]) => {
|
|
649
|
+
try {
|
|
650
|
+
return [[p, parseYaml(body)]];
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
return [];
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
// Checks that need the pulled KB. Both DEGRADE when it is absent — the KB is a
|
|
657
|
+
// gitignored cache wiped by every pull, and `platform-kb pull` needs a
|
|
658
|
+
// `pack:deploy` scope a CI job may not have, so failing hard would break a fresh
|
|
659
|
+
// clone before the author could act. But a skip is now ANNOUNCED, and remembered:
|
|
660
|
+
// the ✓ used to print above these blocks unconditionally while the per-check ✓s
|
|
661
|
+
// lived inside the `if`s, so a KB-less run read as "one check, passed". An entire
|
|
662
|
+
// backlog batch reached production that way. The defect is the silence, not the skip.
|
|
663
|
+
const skipped = [];
|
|
664
|
+
const render = loadAllowedRenderKeys(packDir);
|
|
665
|
+
if (render.keys) {
|
|
666
|
+
const findings = yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys));
|
|
579
667
|
if (findings.length) {
|
|
580
668
|
console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
|
|
581
669
|
for (const f of findings)
|
|
@@ -584,23 +672,15 @@ async function cmdValidate(flags) {
|
|
|
584
672
|
}
|
|
585
673
|
console.log('✓ render intents use only fields the platform renders');
|
|
586
674
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
try {
|
|
597
|
-
doc = parseYaml(body);
|
|
598
|
-
}
|
|
599
|
-
catch {
|
|
600
|
-
return [];
|
|
601
|
-
}
|
|
602
|
-
return findArgViolations(doc, p, argSpecs);
|
|
603
|
-
});
|
|
675
|
+
else {
|
|
676
|
+
console.log(`⚠ ${describeKbLookup(render.lookup, 'render-intent fields')}`);
|
|
677
|
+
skipped.push('render-intent fields');
|
|
678
|
+
}
|
|
679
|
+
// Primitive `args:` keys, same source and same contract. Cannot see inside a
|
|
680
|
+
// `use:` template body (expansion is the platform's job); `--remote` covers that.
|
|
681
|
+
const args = loadPrimitiveArgSpecs(packDir);
|
|
682
|
+
if (args.specs) {
|
|
683
|
+
const findings = yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs));
|
|
604
684
|
if (findings.length) {
|
|
605
685
|
console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
|
|
606
686
|
for (const f of findings)
|
|
@@ -609,9 +689,26 @@ async function cmdValidate(flags) {
|
|
|
609
689
|
}
|
|
610
690
|
console.log('✓ primitive arguments match their declared inputs');
|
|
611
691
|
}
|
|
692
|
+
else {
|
|
693
|
+
console.log(`⚠ ${describeKbLookup(args.lookup, 'primitive arguments')}`);
|
|
694
|
+
skipped.push('primitive arguments');
|
|
695
|
+
}
|
|
696
|
+
// `--require-kb` is for CI, where a skip nobody reads is worse than a red build.
|
|
697
|
+
if (skipped.length && flags['require-kb'] === true) {
|
|
698
|
+
die(`--require-kb: ${skipped.length} check${skipped.length === 1 ? '' : 's'} could not run (${skipped.join(', ')})`);
|
|
699
|
+
}
|
|
612
700
|
if (flags.remote !== true) {
|
|
613
|
-
|
|
614
|
-
|
|
701
|
+
// The LAST line carries the skip. A reader who sees a ✓ and stops there is the
|
|
702
|
+
// failure mode; a caveat printed ABOVE the ✓ does not fix it.
|
|
703
|
+
if (skipped.length) {
|
|
704
|
+
console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.join(' and ')} ${skipped.length === 1 ? 'was' : 'were'} skipped.`);
|
|
705
|
+
console.log(' Run `octwin platform-kb pull` (once, at your repo root — it covers every pack under it),');
|
|
706
|
+
console.log(' or `octwin validate --remote` to have the platform run everything server-side.');
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
|
|
710
|
+
console.log(' (all errors at once) before you deploy.');
|
|
711
|
+
}
|
|
615
712
|
return;
|
|
616
713
|
}
|
|
617
714
|
// Remote: the SAME validation the deploy route runs — manifest `.strict()` +
|
|
@@ -1011,7 +1108,17 @@ async function cmdStatus(flags) {
|
|
|
1011
1108
|
console.log(` live on instance : registered=${json.registered} loaded=${shortSha(json.loaded_content_sha)}`);
|
|
1012
1109
|
console.log(` catalog artifact : ${shortSha(json.catalog_content_sha)}${json.origin ? ` origin=${json.origin}` : ''}`);
|
|
1013
1110
|
console.log(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
|
|
1014
|
-
|
|
1111
|
+
// Every line above is process-global — `registered` is true for a pack ANY
|
|
1112
|
+
// project on this instance loaded. `dispatches` is the project-scoped answer to
|
|
1113
|
+
// the question status is actually asked: can this pack receive a message here?
|
|
1114
|
+
// Dispatch takes the OLDEST active install and ignores the rest, so a second
|
|
1115
|
+
// install is not a warning, it is a pack that will never run.
|
|
1116
|
+
if (json.dispatches === false) {
|
|
1117
|
+
console.log(`\n✗ installed, but project '${t.project ?? '(pinned)'}' dispatches to `
|
|
1118
|
+
+ `'${json.dispatches_to ?? '(nothing)'}' — this pack CANNOT receive a message.`);
|
|
1119
|
+
console.log(' One pack per project: the oldest active install wins. Archive the other install to switch.');
|
|
1120
|
+
}
|
|
1121
|
+
else if (!json.registered) {
|
|
1015
1122
|
console.log('\n… not warm on the instance you hit yet — it loads on the next inbound (chat once, then re-check).');
|
|
1016
1123
|
}
|
|
1017
1124
|
else if (json.up_to_date === false) {
|
|
@@ -1238,6 +1345,8 @@ async function cmdPlatformKb(flags) {
|
|
|
1238
1345
|
console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
|
|
1239
1346
|
console.log(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`);
|
|
1240
1347
|
console.log(' Start at INDEX.md — it maps every doc and every catalog entry to its file.');
|
|
1348
|
+
console.log(' Every pack UNDER this directory finds it — `octwin validate` walks up to locate it,');
|
|
1349
|
+
console.log(' so one pull at a repo root covers a whole monorepo of packs.');
|
|
1241
1350
|
// Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
|
|
1242
1351
|
// moved (a schema shape being replaced shows as a `~ changed`), not just a count.
|
|
1243
1352
|
if (prior?.content_hash) {
|
|
@@ -1273,17 +1382,125 @@ async function apiGet(endpoint, t) {
|
|
|
1273
1382
|
}
|
|
1274
1383
|
return { status: res.status, json };
|
|
1275
1384
|
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Send a WRITE to an admin endpoint with the deploy token; returns `{ status, json }`.
|
|
1387
|
+
*
|
|
1388
|
+
* The mirror of `apiGet`, and the reason every write command is three lines: the
|
|
1389
|
+
* `content-type` + `authHeaders` block was inlined at each of the four original
|
|
1390
|
+
* write sites, and fifteen more copies is how one of them ends up subtly different.
|
|
1391
|
+
* A `204` (media delete) has no body to parse, hence the empty-text guard.
|
|
1392
|
+
*/
|
|
1393
|
+
async function apiSend(method, endpoint, body, t) {
|
|
1394
|
+
const res = await fetchOrDie(endpoint, {
|
|
1395
|
+
method,
|
|
1396
|
+
headers: { 'content-type': 'application/json', ...authHeaders(t) },
|
|
1397
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
1398
|
+
}, 'request');
|
|
1399
|
+
const text = await res.text();
|
|
1400
|
+
if (!text)
|
|
1401
|
+
return { status: res.status, json: null };
|
|
1402
|
+
let json;
|
|
1403
|
+
try {
|
|
1404
|
+
json = JSON.parse(text);
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
json = text;
|
|
1408
|
+
}
|
|
1409
|
+
return { status: res.status, json };
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Fail a write with the server's own reason, the auth explanation, and — where it
|
|
1413
|
+
* applies — the RBAC caveat a scope hint structurally cannot cover.
|
|
1414
|
+
*
|
|
1415
|
+
* Record and case writes are re-checked against the SPECIFIC row, so a 403 there
|
|
1416
|
+
* can mean "your token has the scope but your role has no grant on this record",
|
|
1417
|
+
* which is invisible to `COMMAND_REQUIREMENTS`. Saying so is the difference
|
|
1418
|
+
* between a two-minute fix and re-minting a token that was never the problem.
|
|
1419
|
+
*/
|
|
1420
|
+
function writeFail(what, status, json, url, rbacScoped = false) {
|
|
1421
|
+
if (status === 403 && rbacScoped) {
|
|
1422
|
+
console.error(' → a 403 here can also be an RBAC grant gap: the scope is checked on the token,');
|
|
1423
|
+
console.error(' then the verb is re-checked against THIS record. Check the pack\'s roles.yaml grants.');
|
|
1424
|
+
}
|
|
1425
|
+
die(`could not ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Build a fields object from repeated `--set k=v`, plus an optional
|
|
1429
|
+
* `--fields-json` escape hatch for anything nested.
|
|
1430
|
+
*
|
|
1431
|
+
* Values are coerced as JSON scalars (`true` / `false` / `null` / a number),
|
|
1432
|
+
* falling back to the raw string — so `--set price=9.99` sends a number and
|
|
1433
|
+
* `--set name=9 Bakery` sends a string. Anything richer than a scalar belongs in
|
|
1434
|
+
* `--fields-json`, rather than inventing a mini-syntax here.
|
|
1435
|
+
*/
|
|
1436
|
+
function fieldsFromFlags(flags) {
|
|
1437
|
+
const out = {};
|
|
1438
|
+
const raw = flags['fields-json'];
|
|
1439
|
+
if (typeof raw === 'string') {
|
|
1440
|
+
let parsed;
|
|
1441
|
+
try {
|
|
1442
|
+
parsed = JSON.parse(raw);
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
die('--fields-json is not valid JSON');
|
|
1446
|
+
}
|
|
1447
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1448
|
+
die('--fields-json must be a JSON object');
|
|
1449
|
+
}
|
|
1450
|
+
Object.assign(out, parsed);
|
|
1451
|
+
}
|
|
1452
|
+
for (const pair of flagList(flags, 'set')) {
|
|
1453
|
+
const eq = pair.indexOf('=');
|
|
1454
|
+
if (eq <= 0)
|
|
1455
|
+
die(`--set expects key=value (got '${pair}')`);
|
|
1456
|
+
const key = pair.slice(0, eq);
|
|
1457
|
+
const val = pair.slice(eq + 1);
|
|
1458
|
+
if (val === 'true')
|
|
1459
|
+
out[key] = true;
|
|
1460
|
+
else if (val === 'false')
|
|
1461
|
+
out[key] = false;
|
|
1462
|
+
else if (val === 'null')
|
|
1463
|
+
out[key] = null;
|
|
1464
|
+
else if (val !== '' && !Number.isNaN(Number(val)) && /^-?\d+(\.\d+)?$/.test(val))
|
|
1465
|
+
out[key] = Number(val);
|
|
1466
|
+
else
|
|
1467
|
+
out[key] = val;
|
|
1468
|
+
}
|
|
1469
|
+
return out;
|
|
1470
|
+
}
|
|
1276
1471
|
/** A progress-line label for the target workspace. The token names the tenant, so
|
|
1277
1472
|
* we surface at most the project (when pinned or overridden by `--project`). */
|
|
1278
1473
|
function targetLabel(t) {
|
|
1279
1474
|
return t.project ? `project '${t.project}'` : 'your workspace';
|
|
1280
1475
|
}
|
|
1476
|
+
/** `limit` + `offset` as a query string, from the two universal list flags.
|
|
1477
|
+
* `--offset` exists so `morePageHint`'s "next page" advice is a command the
|
|
1478
|
+
* author can actually run — every list route already accepted the parameter
|
|
1479
|
+
* (`parsePaging`), the CLI just never sent it. */
|
|
1480
|
+
function pagingQs(flags, defaultLimit = 50) {
|
|
1481
|
+
const limit = flags.limit ?? String(defaultLimit);
|
|
1482
|
+
const offset = flags.offset ?? '';
|
|
1483
|
+
return `limit=${encodeURIComponent(limit)}${offset ? `&offset=${encodeURIComponent(offset)}` : ''}`;
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Verbs that mean "write", not "an entity named this".
|
|
1487
|
+
*
|
|
1488
|
+
* `octwin records <entity>` and `octwin records note <id>` both land in `_[0]`, so
|
|
1489
|
+
* the two shapes genuinely collide. Reserved words win, and they are listed (not
|
|
1490
|
+
* guessed) so the ambiguity is documented rather than emergent — a pack that
|
|
1491
|
+
* declares an entity actually called `note` reaches it via `--entity note`.
|
|
1492
|
+
*/
|
|
1493
|
+
const RECORD_VERBS = new Set(['create', 'patch', 'stage', 'note', 'tasks', 'task']);
|
|
1281
1494
|
/** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
|
|
1282
1495
|
async function cmdRecords(flags) {
|
|
1496
|
+
// A leading reserved word is a write. To READ an entity whose name collides
|
|
1497
|
+
// with one, name it with the flag and pass no positional: `octwin records --entity note`.
|
|
1498
|
+
if (typeof flags._[0] === 'string' && RECORD_VERBS.has(flags._[0]))
|
|
1499
|
+
return cmdRecordsWrite(flags);
|
|
1283
1500
|
const t = resolveTarget(flags);
|
|
1284
1501
|
const { url } = t;
|
|
1285
1502
|
const base = `${url}/api/self/p`;
|
|
1286
|
-
const entity = flags._[0];
|
|
1503
|
+
const entity = (typeof flags.entity === 'string' ? flags.entity : flags._[0]);
|
|
1287
1504
|
const recordId = flags._[1];
|
|
1288
1505
|
console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${targetLabel(t)} …`);
|
|
1289
1506
|
if (!entity) {
|
|
@@ -1308,8 +1525,7 @@ async function cmdRecords(flags) {
|
|
|
1308
1525
|
return;
|
|
1309
1526
|
}
|
|
1310
1527
|
if (!recordId) {
|
|
1311
|
-
const
|
|
1312
|
-
const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, t);
|
|
1528
|
+
const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&${pagingQs(flags)}`, t);
|
|
1313
1529
|
if (status !== 200) {
|
|
1314
1530
|
// Always show the server's reason (it names the unknown entity). Cases are
|
|
1315
1531
|
// casework (worklist), not pack-declared XRM — point at the right command.
|
|
@@ -1318,12 +1534,15 @@ async function cmdRecords(flags) {
|
|
|
1318
1534
|
}
|
|
1319
1535
|
die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1320
1536
|
}
|
|
1321
|
-
const
|
|
1322
|
-
console.log(`${entity}: ${
|
|
1323
|
-
if (rows.length === 0)
|
|
1537
|
+
const page = readPage(json);
|
|
1538
|
+
console.log(`${entity}: ${page.total ?? page.rows.length} record(s)`);
|
|
1539
|
+
if (page.rows.length === 0)
|
|
1324
1540
|
console.log(' (none — if you expected data, mint a `records:read` token and check `octwin deploy --seed`)');
|
|
1325
|
-
for (const r of rows)
|
|
1541
|
+
for (const r of page.rows)
|
|
1326
1542
|
console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'}${r.stage ? ` [${r.stage}]` : ''} ${r.id}`);
|
|
1543
|
+
const more = morePageHint(page, `octwin records ${entity}`);
|
|
1544
|
+
if (more)
|
|
1545
|
+
console.log(more);
|
|
1327
1546
|
return;
|
|
1328
1547
|
}
|
|
1329
1548
|
const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`, t);
|
|
@@ -1333,6 +1552,186 @@ async function cmdRecords(flags) {
|
|
|
1333
1552
|
die(`could not read record (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1334
1553
|
console.log(JSON.stringify(json?.record ?? json, null, 2));
|
|
1335
1554
|
}
|
|
1555
|
+
/**
|
|
1556
|
+
* The write half of `octwin records` — create / patch / stage / note, plus tasks.
|
|
1557
|
+
*
|
|
1558
|
+
* Every verb here needs `records:write` (tasks need the `tasks` plan feature, not
|
|
1559
|
+
* `records`), and every one is re-checked by RBAC against the specific record, so
|
|
1560
|
+
* failures route through `writeFail(..., rbacScoped)`.
|
|
1561
|
+
*/
|
|
1562
|
+
async function cmdRecordsWrite(flags) {
|
|
1563
|
+
const t = resolveTarget(flags);
|
|
1564
|
+
const { url } = t;
|
|
1565
|
+
const base = `${url}/api/self/p`;
|
|
1566
|
+
const verb = flags._[0];
|
|
1567
|
+
const arg = flags._[1];
|
|
1568
|
+
const readBack = (id) => console.log(`\nRead it back: octwin records --entity <entity> ${id}`);
|
|
1569
|
+
if (verb === 'create') {
|
|
1570
|
+
const entity = arg ?? die('usage: octwin records create <entity> --set field=value …');
|
|
1571
|
+
const fields = fieldsFromFlags(flags);
|
|
1572
|
+
const body = { entity, fields };
|
|
1573
|
+
if (typeof flags.stage === 'string')
|
|
1574
|
+
body.stage = flags.stage;
|
|
1575
|
+
if (typeof flags.contact === 'string')
|
|
1576
|
+
body.contact_id = flags.contact;
|
|
1577
|
+
console.log(`→ Creating a ${entity} record in ${targetLabel(t)} …`);
|
|
1578
|
+
const { status, json } = await apiSend('POST', `${base}/xrm/records`, body, t);
|
|
1579
|
+
if (status !== 200 && status !== 201)
|
|
1580
|
+
writeFail(`create the ${entity} record`, status, json, url, true);
|
|
1581
|
+
if (json?.has_xrm === false)
|
|
1582
|
+
die('this pack declares no XRM entities');
|
|
1583
|
+
const rec = json?.record ?? {};
|
|
1584
|
+
// 200 = the dedupe key matched an existing row; 201 = genuinely new. Saying
|
|
1585
|
+
// "created" for a match would misreport what the pack's `dedupe_by` did.
|
|
1586
|
+
console.log(status === 201
|
|
1587
|
+
? `✓ Created #${rec.record_number ?? '?'} ${rec.id ?? ''}`
|
|
1588
|
+
: `✓ Matched an EXISTING record (the entity's dedupe key hit) — #${rec.record_number ?? '?'} ${rec.id ?? ''}`);
|
|
1589
|
+
if (rec.id)
|
|
1590
|
+
readBack(rec.id);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
if (verb === 'patch') {
|
|
1594
|
+
const id = arg ?? die('usage: octwin records patch <recordId> --entity <entity> --set field=value …');
|
|
1595
|
+
// The route requires `entity` even on an update — it resolves the validator
|
|
1596
|
+
// from it. A patch without it 400s server-side, so say it here instead.
|
|
1597
|
+
const entity = typeof flags.entity === 'string' ? flags.entity
|
|
1598
|
+
: die('octwin records patch needs --entity <entity> (the route resolves the field validator from it)');
|
|
1599
|
+
const fields = fieldsFromFlags(flags);
|
|
1600
|
+
if (Object.keys(fields).length === 0)
|
|
1601
|
+
die('nothing to patch — pass --set field=value (or --fields-json)');
|
|
1602
|
+
console.log(`→ Patching ${entity} ${id} in ${targetLabel(t)} …`);
|
|
1603
|
+
const { status, json } = await apiSend('PATCH', `${base}/xrm/records/${encodeURIComponent(id)}`, { entity, fields }, t);
|
|
1604
|
+
if (status !== 200)
|
|
1605
|
+
writeFail(`patch record ${id}`, status, json, url, true);
|
|
1606
|
+
console.log(`✓ Patched #${json?.record?.record_number ?? '?'}`);
|
|
1607
|
+
readBack(id);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (verb === 'stage') {
|
|
1611
|
+
const id = arg ?? die('usage: octwin records stage <recordId> --to <stage> [--note "..."]');
|
|
1612
|
+
const to = typeof flags.to === 'string' ? flags.to : die('octwin records stage needs --to <stage>');
|
|
1613
|
+
const body = { to_stage: to };
|
|
1614
|
+
if (typeof flags.note === 'string')
|
|
1615
|
+
body.note = flags.note;
|
|
1616
|
+
console.log(`→ Moving ${id} to '${to}' in ${targetLabel(t)} …`);
|
|
1617
|
+
const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/stage`, body, t);
|
|
1618
|
+
if (status === 400 && Array.isArray(json?.allowed)) {
|
|
1619
|
+
// The route returns the legal targets on an illegal move — the single most
|
|
1620
|
+
// useful thing to show, so don't bury it in the generic error line.
|
|
1621
|
+
console.error(`✗ '${to}' is not a legal move from this record's stage.`);
|
|
1622
|
+
console.error(` → allowed: ${json.allowed.join(', ') || '(none — terminal stage)'}`);
|
|
1623
|
+
process.exit(1);
|
|
1624
|
+
}
|
|
1625
|
+
if (status !== 200)
|
|
1626
|
+
writeFail(`move record ${id} to '${to}'`, status, json, url, true);
|
|
1627
|
+
console.log(`✓ #${json?.record?.record_number ?? '?'} is now at '${json?.record?.stage ?? to}'`);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (verb === 'note') {
|
|
1631
|
+
const id = arg ?? die('usage: octwin records note <recordId> "the note text"');
|
|
1632
|
+
const note = flags._[2] ?? die('octwin records note needs the note text as the last argument');
|
|
1633
|
+
console.log(`→ Adding a note to ${id} in ${targetLabel(t)} …`);
|
|
1634
|
+
const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/note`, { note }, t);
|
|
1635
|
+
if (status !== 200)
|
|
1636
|
+
writeFail(`note record ${id}`, status, json, url, true);
|
|
1637
|
+
console.log('✓ Note added to the record timeline.');
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
if (verb === 'tasks') {
|
|
1641
|
+
console.log(`→ Reading open tasks from ${targetLabel(t)} …`);
|
|
1642
|
+
const { status, json } = await apiGet(`${base}/xrm/tasks?${pagingQs(flags)}`, t);
|
|
1643
|
+
if (status !== 200)
|
|
1644
|
+
writeFail('read tasks', status, json, url);
|
|
1645
|
+
if (json?.has_xrm === false) {
|
|
1646
|
+
console.log('This pack declares no XRM entities.');
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const page = readPage(json);
|
|
1650
|
+
console.log(`Tasks: ${page.total ?? page.rows.length}`);
|
|
1651
|
+
if (page.rows.length === 0)
|
|
1652
|
+
console.log(' (none open)');
|
|
1653
|
+
for (const k of page.rows) {
|
|
1654
|
+
console.log(` ${k.title ?? '(untitled)'}${k.due_at ? ` due:${k.due_at}` : ''}${k.status ? ` [${k.status}]` : ''} ${k.id}`);
|
|
1655
|
+
}
|
|
1656
|
+
const more = morePageHint(page, 'octwin records tasks');
|
|
1657
|
+
if (more)
|
|
1658
|
+
console.log(more);
|
|
1659
|
+
console.log('\nClose one: octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]');
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
if (verb === 'task') {
|
|
1663
|
+
if (arg !== 'complete')
|
|
1664
|
+
die('usage: octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]');
|
|
1665
|
+
const id = flags._[2] ?? die('octwin records task complete needs a <taskId>');
|
|
1666
|
+
const outcome = typeof flags.outcome === 'string' ? flags.outcome : 'done';
|
|
1667
|
+
if (outcome !== 'done' && outcome !== 'cancelled')
|
|
1668
|
+
die(`--outcome must be 'done' or 'cancelled' (got '${outcome}')`);
|
|
1669
|
+
const body = { outcome };
|
|
1670
|
+
if (typeof flags.note === 'string')
|
|
1671
|
+
body.note = flags.note;
|
|
1672
|
+
console.log(`→ Closing task ${id} as '${outcome}' …`);
|
|
1673
|
+
const { status, json } = await apiSend('POST', `${base}/xrm/tasks/${encodeURIComponent(id)}/complete`, body, t);
|
|
1674
|
+
if (status === 404)
|
|
1675
|
+
die(`task '${id}' not found, or already closed`);
|
|
1676
|
+
if (status !== 200)
|
|
1677
|
+
writeFail(`complete task ${id}`, status, json, url, true);
|
|
1678
|
+
console.log(`✓ Task closed (${outcome}).`);
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
die(`unknown records verb '${verb}' — one of: ${[...RECORD_VERBS].join(', ')}`);
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* `octwin feedback [--dir .]` — submit the pack's `FEEDBACK.md` to the platform.
|
|
1685
|
+
*
|
|
1686
|
+
* The authoring skill's last step asks for a report bucketed by owner (CLI /
|
|
1687
|
+
* platform / KB). It used to end there: delivery was copy-paste into a chat, so a
|
|
1688
|
+
* report only counted if the author happened to hand it over.
|
|
1689
|
+
*
|
|
1690
|
+
* Attaches the two facts that decide triage and that nobody remembers to state —
|
|
1691
|
+
* the CLI version, and the `content_hash` of the capability reference the author
|
|
1692
|
+
* actually pulled. Most field reports so far were either already fixed in a newer
|
|
1693
|
+
* CLI or written against a stale KB, and both are one line each here.
|
|
1694
|
+
*/
|
|
1695
|
+
async function cmdFeedback(flags) {
|
|
1696
|
+
const packDir = resolve(flags.dir ?? '.');
|
|
1697
|
+
const t = resolveTarget(flags);
|
|
1698
|
+
const { url } = t;
|
|
1699
|
+
const reportPath = join(packDir, 'FEEDBACK.md');
|
|
1700
|
+
if (!existsSync(reportPath)) {
|
|
1701
|
+
die(`no FEEDBACK.md in ${packDir}\n`
|
|
1702
|
+
+ ' → the octwin-pack skill writes one in Step 4 (Report your authoring experience).\n'
|
|
1703
|
+
+ ' Group findings by owner — A · CLI, B · Platform, C · Skill/KB — then run this again.');
|
|
1704
|
+
}
|
|
1705
|
+
const report = readFileSync(reportPath, 'utf8');
|
|
1706
|
+
if (!report.trim())
|
|
1707
|
+
die('FEEDBACK.md is empty — nothing to submit');
|
|
1708
|
+
// Pack identity from the manifest, not from the report's prose: the metadata
|
|
1709
|
+
// block is a convention the skill owns and an author may reword it.
|
|
1710
|
+
const manifestPath = join(packDir, 'manifest.yaml');
|
|
1711
|
+
if (!existsSync(manifestPath))
|
|
1712
|
+
die('no manifest.yaml in the pack directory (run from your pack dir or pass --dir)');
|
|
1713
|
+
const doc = parseYaml(readFileSync(manifestPath, 'utf8'));
|
|
1714
|
+
const packId = typeof doc?.id === 'string' ? doc.id : die('manifest.yaml must declare a string `id`');
|
|
1715
|
+
const packVersion = typeof doc?.version === 'string' ? doc.version : undefined;
|
|
1716
|
+
const kbHash = readLocalKb(packDir)?.content_hash ?? undefined;
|
|
1717
|
+
console.log(`→ Submitting ${Math.round(Buffer.byteLength(report, 'utf8') / 1024)}KB of feedback on ${packId} to ${targetLabel(t)} …`);
|
|
1718
|
+
const { status, json } = await apiSend('POST', `${url}/api/self/p/packs/feedback`, {
|
|
1719
|
+
pack_id: packId,
|
|
1720
|
+
...(packVersion ? { pack_version: packVersion } : {}),
|
|
1721
|
+
report_md: report,
|
|
1722
|
+
cli_version: VERSION,
|
|
1723
|
+
...(kbHash ? { kb_content_hash: kbHash } : {}),
|
|
1724
|
+
}, t);
|
|
1725
|
+
if (status !== 201 && status !== 200)
|
|
1726
|
+
writeFail('submit feedback', status, json, url);
|
|
1727
|
+
console.log('✓ Thanks — your report reached the platform team.');
|
|
1728
|
+
if (!kbHash) {
|
|
1729
|
+
// Without it, triage cannot tell "the platform is wrong" from "you were
|
|
1730
|
+
// reading a stale reference", which is the single most common answer.
|
|
1731
|
+
console.log(' ⓘ no local capability reference found, so the report carries no KB version.');
|
|
1732
|
+
console.log(' Pull it before your next session: octwin platform-kb');
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1336
1735
|
/** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
|
|
1337
1736
|
* or show one's event timeline (full text + the renders each turn produced). */
|
|
1338
1737
|
async function cmdLogs(flags) {
|
|
@@ -1807,9 +2206,106 @@ async function cmdMedia(flags) {
|
|
|
1807
2206
|
console.log(` saved → ${out}`);
|
|
1808
2207
|
console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
|
|
1809
2208
|
}
|
|
2209
|
+
/** Reserved leading words on `octwin cases` — see `RECORD_VERBS` for the rule. */
|
|
2210
|
+
const CASE_VERBS = new Set(['assign', 'note', 'transition', 'decide']);
|
|
2211
|
+
/**
|
|
2212
|
+
* The write half of `octwin cases` — assign / note / transition / decide.
|
|
2213
|
+
*
|
|
2214
|
+
* `decide --dry-run` routes to the PREVIEW endpoint, which sits behind `cases:read`
|
|
2215
|
+
* rather than `cases:write`: it renders the customer-facing copy and the resulting
|
|
2216
|
+
* status without committing. That makes "show me what this disposition would do"
|
|
2217
|
+
* safe to run with a read-only token, which is exactly when an author wants it.
|
|
2218
|
+
*/
|
|
2219
|
+
async function cmdCasesWrite(flags) {
|
|
2220
|
+
const t = resolveTarget(flags);
|
|
2221
|
+
const { url } = t;
|
|
2222
|
+
const base = `${url}/api/self/p`;
|
|
2223
|
+
const verb = flags._[0];
|
|
2224
|
+
const id = flags._[1] ?? die(`usage: octwin cases ${verb} <caseId> …`);
|
|
2225
|
+
const readBack = () => console.log(`\nRead it back: octwin cases ${id}`);
|
|
2226
|
+
if (verb === 'assign') {
|
|
2227
|
+
// `--to none` unassigns (the route takes null); anything else must carry the
|
|
2228
|
+
// principal kind, because a bare uuid cannot say user-or-team.
|
|
2229
|
+
const to = typeof flags.to === 'string' ? flags.to
|
|
2230
|
+
: die('usage: octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none');
|
|
2231
|
+
const assignee = to === 'none' ? null : to;
|
|
2232
|
+
if (assignee !== null && !/^(user|team):/.test(assignee)) {
|
|
2233
|
+
die(`--to must be 'user:<uuid>', 'team:<uuid>' or 'none' (got '${to}')`);
|
|
2234
|
+
}
|
|
2235
|
+
console.log(`→ ${assignee === null ? 'Unassigning' : `Assigning to ${assignee}`} case ${id} …`);
|
|
2236
|
+
const { status, json } = await apiSend('PATCH', `${base}/cases/${encodeURIComponent(id)}/assign`, { assignee }, t);
|
|
2237
|
+
if (status !== 200)
|
|
2238
|
+
writeFail(`assign case ${id}`, status, json, url, true);
|
|
2239
|
+
console.log(assignee === null ? '✓ Unassigned.' : `✓ Assigned to ${json?.assignee ?? assignee}.`);
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
if (verb === 'note') {
|
|
2243
|
+
const note = flags._[2] ?? die('usage: octwin cases note <caseId> "the note text"');
|
|
2244
|
+
console.log(`→ Adding a note to case ${id} …`);
|
|
2245
|
+
const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/note`, { note }, t);
|
|
2246
|
+
if (status !== 200)
|
|
2247
|
+
writeFail(`note case ${id}`, status, json, url, true);
|
|
2248
|
+
console.log('✓ Note added to the case timeline.');
|
|
2249
|
+
readBack();
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
if (verb === 'transition') {
|
|
2253
|
+
const to = typeof flags.to === 'string' ? flags.to
|
|
2254
|
+
: die('usage: octwin cases transition <caseId> --to <status> [--note "..."]');
|
|
2255
|
+
const body = { to_status: to };
|
|
2256
|
+
if (typeof flags.note === 'string')
|
|
2257
|
+
body.note = flags.note;
|
|
2258
|
+
console.log(`→ Moving case ${id} to '${to}' …`);
|
|
2259
|
+
const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/transition`, body, t);
|
|
2260
|
+
if (status !== 200) {
|
|
2261
|
+
// The case detail read carries the legal targets; point at it rather than
|
|
2262
|
+
// leaving the author to guess the vocabulary.
|
|
2263
|
+
if (status === 400)
|
|
2264
|
+
console.error(` → legal targets for this case: octwin cases ${id} (see its workflow)`);
|
|
2265
|
+
writeFail(`move case ${id} to '${to}'`, status, json, url, true);
|
|
2266
|
+
}
|
|
2267
|
+
console.log(`✓ Case is now '${json?.case?.status ?? to}'.`);
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
// decide
|
|
2271
|
+
const action = typeof flags.action === 'string' ? flags.action
|
|
2272
|
+
: die('usage: octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]');
|
|
2273
|
+
const params = {};
|
|
2274
|
+
for (const pair of flagList(flags, 'param')) {
|
|
2275
|
+
const eq = pair.indexOf('=');
|
|
2276
|
+
if (eq <= 0)
|
|
2277
|
+
die(`--param expects key=value (got '${pair}')`);
|
|
2278
|
+
params[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
2279
|
+
}
|
|
2280
|
+
const dryRun = flags['dry-run'] === true;
|
|
2281
|
+
const body = { action, ...(Object.keys(params).length ? { params } : {}) };
|
|
2282
|
+
if (!dryRun && typeof flags.note === 'string')
|
|
2283
|
+
body.internal_note = flags.note;
|
|
2284
|
+
console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on case ${id} …`);
|
|
2285
|
+
const endpoint = `${base}/cases/${encodeURIComponent(id)}/decision${dryRun ? '/preview' : ''}`;
|
|
2286
|
+
const { status, json } = await apiSend('POST', endpoint, body, t);
|
|
2287
|
+
if (status === 404)
|
|
2288
|
+
die(`case '${id}' not found`);
|
|
2289
|
+
if (status !== 200) {
|
|
2290
|
+
console.error(` → the case's applicable actions are listed by: octwin cases ${id}`);
|
|
2291
|
+
writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on case ${id}`, status, json, url, true);
|
|
2292
|
+
}
|
|
2293
|
+
if (dryRun) {
|
|
2294
|
+
console.log('Preview (nothing was committed):');
|
|
2295
|
+
console.log(JSON.stringify(json, null, 2));
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
console.log(`✓ Applied '${action}' — case is now '${json?.case?.status ?? '?'}'.`);
|
|
2299
|
+
// `notified` is the customer-facing half; silence here usually means the
|
|
2300
|
+
// disposition had no message template, which is easy to mistake for a failure.
|
|
2301
|
+
console.log(json?.notified ? ' ✓ the customer was notified.' : ' ⓘ no customer notification was sent by this action.');
|
|
2302
|
+
readBack();
|
|
2303
|
+
}
|
|
1810
2304
|
/** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
|
|
1811
2305
|
* the aggregate inbox, one case + its timeline, or the queue list. */
|
|
1812
2306
|
async function cmdCases(flags) {
|
|
2307
|
+
if (typeof flags._[0] === 'string' && CASE_VERBS.has(flags._[0]))
|
|
2308
|
+
return cmdCasesWrite(flags);
|
|
1813
2309
|
const t = resolveTarget(flags);
|
|
1814
2310
|
const { url } = t;
|
|
1815
2311
|
const base = `${url}/api/self/p`;
|
|
@@ -1841,22 +2337,24 @@ async function cmdCases(flags) {
|
|
|
1841
2337
|
return;
|
|
1842
2338
|
}
|
|
1843
2339
|
if (!caseId) {
|
|
1844
|
-
const
|
|
1845
|
-
const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, t);
|
|
2340
|
+
const { status, json } = await apiGet(`${base}/cases?${pagingQs(flags)}`, t);
|
|
1846
2341
|
if (status !== 200)
|
|
1847
2342
|
caseFail('cases', status, json);
|
|
1848
2343
|
if (asJson) {
|
|
1849
2344
|
console.log(JSON.stringify(json, null, 2));
|
|
1850
2345
|
return;
|
|
1851
2346
|
}
|
|
1852
|
-
const
|
|
1853
|
-
console.log(`Cases in ${targetLabel(t)}: ${
|
|
1854
|
-
if (rows.length === 0)
|
|
2347
|
+
const page = readPage(json);
|
|
2348
|
+
console.log(`Cases in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
|
|
2349
|
+
if (page.rows.length === 0)
|
|
1855
2350
|
console.log(' (none)');
|
|
1856
|
-
for (const c of rows) {
|
|
2351
|
+
for (const c of page.rows) {
|
|
1857
2352
|
const sla = c.sla_due_at ? ` sla:${c.sla_due_at}` : '';
|
|
1858
2353
|
console.log(` #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}${c.queue_key ? ` q:${c.queue_key}` : ''}${sla} ${c.id}`);
|
|
1859
2354
|
}
|
|
2355
|
+
const more = morePageHint(page, 'octwin cases');
|
|
2356
|
+
if (more)
|
|
2357
|
+
console.log(more);
|
|
1860
2358
|
console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
|
|
1861
2359
|
return;
|
|
1862
2360
|
}
|
|
@@ -1951,7 +2449,119 @@ function printGoverned(label, g) {
|
|
|
1951
2449
|
/** `octwin agents [agentRef] [--prompt] [--json]` — the agent roster with the
|
|
1952
2450
|
* EFFECTIVE model/memory settings and which layer won, plus (`--prompt`) the exact
|
|
1953
2451
|
* system prompt the LLM sees for this project. Needs an `agents:read` token. */
|
|
2452
|
+
/**
|
|
2453
|
+
* `octwin agents set <ref> …` — the per-project agent override row.
|
|
2454
|
+
*
|
|
2455
|
+
* Writes only what was passed, so an unmentioned setting is left alone rather than
|
|
2456
|
+
* reset to a default. `--enable-tool` / `--disable-tool` edit `config_json.tools`,
|
|
2457
|
+
* a `{ toolId: boolean }` map where absent means ON — so disabling is the only
|
|
2458
|
+
* thing that needs recording, and the map is read-modify-written to avoid dropping
|
|
2459
|
+
* a sibling entry.
|
|
2460
|
+
*/
|
|
2461
|
+
async function cmdAgentsWrite(flags) {
|
|
2462
|
+
const t = resolveTarget(flags);
|
|
2463
|
+
const { url } = t;
|
|
2464
|
+
const base = `${url}/api/self/p/agents`;
|
|
2465
|
+
const ref = flags._[1]
|
|
2466
|
+
?? die('usage: octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t] [--enabled true|false]');
|
|
2467
|
+
const patch = {};
|
|
2468
|
+
if (typeof flags.model === 'string')
|
|
2469
|
+
patch.model = flags.model;
|
|
2470
|
+
if (typeof flags.overlay === 'string')
|
|
2471
|
+
patch.instructions_overlay = flags.overlay === 'none' ? null : flags.overlay;
|
|
2472
|
+
if (typeof flags.enabled === 'string') {
|
|
2473
|
+
if (flags.enabled !== 'true' && flags.enabled !== 'false')
|
|
2474
|
+
die("--enabled must be 'true' or 'false'");
|
|
2475
|
+
patch.enabled = flags.enabled === 'true';
|
|
2476
|
+
}
|
|
2477
|
+
const on = flagList(flags, 'enable-tool');
|
|
2478
|
+
const off = flagList(flags, 'disable-tool');
|
|
2479
|
+
if (on.length || off.length) {
|
|
2480
|
+
// Read first: `config_json` is replaced wholesale by the route, so a blind
|
|
2481
|
+
// write would drop every tool decision not named on this command line.
|
|
2482
|
+
const { status, json } = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
|
|
2483
|
+
if (status === 404)
|
|
2484
|
+
die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
|
|
2485
|
+
if (status !== 200)
|
|
2486
|
+
writeFail(`read agent ${ref}`, status, json, url);
|
|
2487
|
+
const cfg = { ...(json?.agent?.config_json ?? json?.config_json ?? {}) };
|
|
2488
|
+
const tools = { ...(cfg.tools ?? {}) };
|
|
2489
|
+
const known = (json?.agent?.available_tools ?? json?.available_tools);
|
|
2490
|
+
for (const id of [...on, ...off]) {
|
|
2491
|
+
if (Array.isArray(known) && known.length && !known.includes(id)) {
|
|
2492
|
+
die(`'${id}' is not a tool on ${ref} — available: ${known.join(', ')}`);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
for (const id of on)
|
|
2496
|
+
tools[id] = true;
|
|
2497
|
+
for (const id of off)
|
|
2498
|
+
tools[id] = false;
|
|
2499
|
+
cfg.tools = tools;
|
|
2500
|
+
patch.config_json = cfg;
|
|
2501
|
+
}
|
|
2502
|
+
if (Object.keys(patch).length === 0) {
|
|
2503
|
+
die('nothing to change — pass --model, --enabled, --overlay, --enable-tool or --disable-tool');
|
|
2504
|
+
}
|
|
2505
|
+
console.log(`→ Updating agent ${ref} in ${targetLabel(t)} …`);
|
|
2506
|
+
const { status, json } = await apiSend('PATCH', `${base}/${encodeURIComponent(ref)}`, patch, t);
|
|
2507
|
+
if (status === 404)
|
|
2508
|
+
die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
|
|
2509
|
+
if (status === 403 && patch.model !== undefined) {
|
|
2510
|
+
die(`this workspace does not expose model overrides — drop --model (the platform default governs)${errDetail(json)}`);
|
|
2511
|
+
}
|
|
2512
|
+
if (status !== 200)
|
|
2513
|
+
writeFail(`update agent ${ref}`, status, json, url);
|
|
2514
|
+
console.log(`✓ Updated ${ref}.`);
|
|
2515
|
+
console.log(`\nRead it back (and see WHICH layer won): octwin agents ${ref}`);
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* `octwin projects` — which `--project <slug>` values this token can actually name.
|
|
2519
|
+
*
|
|
2520
|
+
* Every project-scoped command takes a `--project` slug, and until now nothing
|
|
2521
|
+
* printed the list: an author whose token was not pinned had to guess, and a wrong
|
|
2522
|
+
* guess 404s identically to a project that exists but has no install. Tenant-scoped
|
|
2523
|
+
* (`/api/self/t/`), unlike `agents` — the list is a property of the workspace.
|
|
2524
|
+
*/
|
|
2525
|
+
async function cmdProjects(flags) {
|
|
2526
|
+
const t = resolveTarget(flags);
|
|
2527
|
+
const { url } = t;
|
|
2528
|
+
const asJson = flags.json === true;
|
|
2529
|
+
const archived = flags.archived === true;
|
|
2530
|
+
if (!asJson)
|
|
2531
|
+
console.log(`→ Reading projects from ${targetLabel(t)} …`);
|
|
2532
|
+
const qs = archived ? '?include_archived=1' : '';
|
|
2533
|
+
const { status, json } = await apiGet(`${url}/api/self/t/projects${qs}`, t);
|
|
2534
|
+
if (status !== 200)
|
|
2535
|
+
die(`could not read projects (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
2536
|
+
if (asJson) {
|
|
2537
|
+
console.log(JSON.stringify(json, null, 2));
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
const projects = (json?.projects ?? []);
|
|
2541
|
+
if (projects.length === 0) {
|
|
2542
|
+
console.log(archived
|
|
2543
|
+
? 'No projects at all — create one in the console (Workspace → Projects).'
|
|
2544
|
+
: 'No active projects. Try `octwin projects --archived` before creating one.');
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
// The route already computes the plan cap; inventing a second "N of M" here
|
|
2548
|
+
// would drift from the 402 the POST handler actually enforces.
|
|
2549
|
+
const max = json?.limits?.max_projects ?? null;
|
|
2550
|
+
const plan = json?.limits?.plan_label ? ` · ${json.limits.plan_label} plan` : '';
|
|
2551
|
+
console.log(`Projects in ${json?.tenant?.slug ?? targetLabel(t)} — ${projects.length}${max ? ` of ${max}` : ''}${plan}:`);
|
|
2552
|
+
for (const p of projects) {
|
|
2553
|
+
const flags_ = [];
|
|
2554
|
+
if (p.status && p.status !== 'active')
|
|
2555
|
+
flags_.push(p.status.toUpperCase());
|
|
2556
|
+
console.log(` ${p.slug}${flags_.length ? ` [${flags_.join(', ')}]` : ''}${p.name ? ` "${p.name}"` : ''}`);
|
|
2557
|
+
}
|
|
2558
|
+
console.log('\nUse one as: octwin deploy --project <slug>');
|
|
2559
|
+
if (!archived)
|
|
2560
|
+
console.log('Archived too: octwin projects --archived');
|
|
2561
|
+
}
|
|
1954
2562
|
async function cmdAgents(flags) {
|
|
2563
|
+
if (flags._[0] === 'set')
|
|
2564
|
+
return cmdAgentsWrite(flags);
|
|
1955
2565
|
const t = resolveTarget(flags);
|
|
1956
2566
|
const { url } = t;
|
|
1957
2567
|
const base = `${url}/api/self/p/agents`;
|
|
@@ -2048,7 +2658,77 @@ function printPaymentNote(paymentStatus) {
|
|
|
2048
2658
|
/** `octwin orders [referenceId] [--status s] [--payment p] [--limit n] [--json]` —
|
|
2049
2659
|
* the orders a conversation created: money breakdown, payment state, allowed
|
|
2050
2660
|
* transitions. Needs an `orders:read` token + the `orders` plan feature. */
|
|
2661
|
+
/** Reserved leading words on `octwin orders`. A reference_id is opaque but never these. */
|
|
2662
|
+
const ORDER_VERBS = new Set(['transition', 'refund']);
|
|
2663
|
+
/**
|
|
2664
|
+
* The write half of `octwin orders` — fulfilment transitions and refunds.
|
|
2665
|
+
*
|
|
2666
|
+
* `payment_status` is deliberately NOT settable: the forward payment lifecycle is
|
|
2667
|
+
* webhook-owned, which is why `pending` on a gateway-less workspace is expected
|
|
2668
|
+
* rather than a bug (`printPaymentNote`).
|
|
2669
|
+
*/
|
|
2670
|
+
async function cmdOrdersWrite(flags) {
|
|
2671
|
+
const t = resolveTarget(flags);
|
|
2672
|
+
const { url } = t;
|
|
2673
|
+
const base = `${url}/api/self/p/orders`;
|
|
2674
|
+
const verb = flags._[0];
|
|
2675
|
+
const ref = flags._[1]
|
|
2676
|
+
?? die(`usage: octwin orders ${verb} <reference_id> … (the opaque reference_id, not the #number)`);
|
|
2677
|
+
if (verb === 'transition') {
|
|
2678
|
+
const to = typeof flags.to === 'string' ? flags.to
|
|
2679
|
+
: die('usage: octwin orders transition <reference_id> --to <status>');
|
|
2680
|
+
console.log(`→ Moving order ${ref} to '${to}' …`);
|
|
2681
|
+
const { status, json } = await apiSend('POST', `${base}/${encodeURIComponent(ref)}/transition`, { to_status: to }, t);
|
|
2682
|
+
if (status === 404)
|
|
2683
|
+
die(`order '${ref}' not found (pass the opaque reference_id, not the #number)`);
|
|
2684
|
+
if (status === 409) {
|
|
2685
|
+
console.error(`✗ '${to}' is not a legal move for this order.`);
|
|
2686
|
+
if (Array.isArray(json?.transitions))
|
|
2687
|
+
console.error(` → allowed: ${json.transitions.join(', ') || '(none)'}`);
|
|
2688
|
+
else
|
|
2689
|
+
console.error(` → see the allowed set: octwin orders ${ref}`);
|
|
2690
|
+
process.exit(1);
|
|
2691
|
+
}
|
|
2692
|
+
if (status !== 200)
|
|
2693
|
+
writeFail(`move order ${ref} to '${to}'`, status, json, url);
|
|
2694
|
+
console.log(`✓ Order is now '${json?.order?.status ?? to}'.`);
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
// refund — irreversible, and money. `--force` rather than a prompt: the CLI is
|
|
2698
|
+
// non-interactive by construction (same reasoning as `octwin pull --force`).
|
|
2699
|
+
if (flags.force !== true) {
|
|
2700
|
+
die(`refunding is irreversible and moves money — re-run with --force:\n octwin orders refund ${ref} --force`);
|
|
2701
|
+
}
|
|
2702
|
+
const body = {};
|
|
2703
|
+
if (typeof flags.reason === 'string')
|
|
2704
|
+
body.reason = flags.reason;
|
|
2705
|
+
if (flags['mark-returned'] === true)
|
|
2706
|
+
body.mark_returned = true;
|
|
2707
|
+
console.log(`→ Refunding order ${ref} …`);
|
|
2708
|
+
const { status, json } = await apiSend('POST', `${base}/${encodeURIComponent(ref)}/refund`, body, t);
|
|
2709
|
+
if (status === 404)
|
|
2710
|
+
die(`order '${ref}' not found`);
|
|
2711
|
+
if (status === 409)
|
|
2712
|
+
die(`order '${ref}' cannot be refunded — only a payment in 'captured' state can be${errDetail(json)}`);
|
|
2713
|
+
if (status !== 200)
|
|
2714
|
+
writeFail(`refund order ${ref}`, status, json, url);
|
|
2715
|
+
// THE trap: the route answers 200 even when the gateway REFUSED — the verdict
|
|
2716
|
+
// is in `gateway`. Reporting the 200 as success would tell an operator money
|
|
2717
|
+
// moved when it did not, so read the gateway result and exit non-zero on refusal.
|
|
2718
|
+
const gw = json?.gateway;
|
|
2719
|
+
const refused = gw != null && (gw.ok === false || gw.status === 'failed' || gw.status === 'error');
|
|
2720
|
+
console.log(` order status : ${json?.order?.status ?? '?'} / ${json?.order?.payment_status ?? '?'}`);
|
|
2721
|
+
if (gw != null)
|
|
2722
|
+
console.log(` gateway : ${gw.status ?? (gw.ok === false ? 'failed' : 'ok')}${gw.error || gw.message ? ` — ${gw.error ?? gw.message}` : ''}`);
|
|
2723
|
+
if (refused) {
|
|
2724
|
+
console.error('\n✗ the PAYMENT GATEWAY refused the refund — the order was updated but no money moved.');
|
|
2725
|
+
process.exit(1);
|
|
2726
|
+
}
|
|
2727
|
+
console.log('✓ Refund accepted.');
|
|
2728
|
+
}
|
|
2051
2729
|
async function cmdOrders(flags) {
|
|
2730
|
+
if (typeof flags._[0] === 'string' && ORDER_VERBS.has(flags._[0]))
|
|
2731
|
+
return cmdOrdersWrite(flags);
|
|
2052
2732
|
const t = resolveTarget(flags);
|
|
2053
2733
|
const { url } = t;
|
|
2054
2734
|
const base = `${url}/api/self/p/orders`;
|
|
@@ -2058,6 +2738,8 @@ async function cmdOrders(flags) {
|
|
|
2058
2738
|
console.log(`→ Reading ${referenceId ? `order ${referenceId}` : 'orders'} from ${targetLabel(t)} …`);
|
|
2059
2739
|
if (!referenceId) {
|
|
2060
2740
|
const q = new URLSearchParams({ limit: flags.limit ?? '50' });
|
|
2741
|
+
if (typeof flags.offset === 'string')
|
|
2742
|
+
q.set('offset', flags.offset);
|
|
2061
2743
|
if (typeof flags.status === 'string')
|
|
2062
2744
|
q.set('status', flags.status);
|
|
2063
2745
|
if (typeof flags.payment === 'string')
|
|
@@ -2069,14 +2751,17 @@ async function cmdOrders(flags) {
|
|
|
2069
2751
|
console.log(JSON.stringify(json, null, 2));
|
|
2070
2752
|
return;
|
|
2071
2753
|
}
|
|
2072
|
-
const
|
|
2073
|
-
console.log(`Orders in ${targetLabel(t)}: ${
|
|
2074
|
-
if (rows.length === 0)
|
|
2754
|
+
const page = readPage(json);
|
|
2755
|
+
console.log(`Orders in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
|
|
2756
|
+
if (page.rows.length === 0)
|
|
2075
2757
|
console.log(' (none — drive a cart to `cart_submit` with `octwin chat`, or seed demo data)');
|
|
2076
|
-
for (const o of rows) {
|
|
2758
|
+
for (const o of page.rows) {
|
|
2077
2759
|
const who = o.contact?.channel_contact_handle ?? o.contact?.display_name ?? '—';
|
|
2078
2760
|
console.log(` #${o.record_number} ${o.status}/${o.payment_status} ${fmtMinor(o.total_minor, o.currency)} ${who} ${o.reference_id}`);
|
|
2079
2761
|
}
|
|
2762
|
+
const more = morePageHint(page, 'octwin orders');
|
|
2763
|
+
if (more)
|
|
2764
|
+
console.log(more);
|
|
2080
2765
|
console.log('\nOne order + its money breakdown: octwin orders <reference_id>');
|
|
2081
2766
|
return;
|
|
2082
2767
|
}
|
|
@@ -2160,8 +2845,7 @@ async function cmdAnalytics(flags) {
|
|
|
2160
2845
|
if (stage) {
|
|
2161
2846
|
if (!asJson)
|
|
2162
2847
|
console.log(`→ Reading ${entity} records at stage '${stage}' from ${targetLabel(t)} …`);
|
|
2163
|
-
const
|
|
2164
|
-
const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/stages/${encodeURIComponent(stage)}/records?limit=${limit}`, t);
|
|
2848
|
+
const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/stages/${encodeURIComponent(stage)}/records?${pagingQs(flags)}`, t);
|
|
2165
2849
|
if (status === 404)
|
|
2166
2850
|
die(`unknown stage '${stage}' for '${entity}'${errDetail(json)}`);
|
|
2167
2851
|
if (status !== 200)
|
|
@@ -2174,12 +2858,15 @@ async function cmdAnalytics(flags) {
|
|
|
2174
2858
|
printNoAnalyticsData(entity);
|
|
2175
2859
|
return;
|
|
2176
2860
|
}
|
|
2177
|
-
const
|
|
2178
|
-
console.log(`${entity} at '${stage}' (live snapshot): ${
|
|
2179
|
-
for (const r of rows) {
|
|
2861
|
+
const page = readPage(json);
|
|
2862
|
+
console.log(`${entity} at '${stage}' (live snapshot): ${page.total ?? page.rows.length} record(s)`);
|
|
2863
|
+
for (const r of page.rows) {
|
|
2180
2864
|
const who = r.channel_contact_handle ?? r.display_name ?? '—';
|
|
2181
2865
|
console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'} ${who}${r.completed ? ' [completed]' : ''} ${r.record_id}`);
|
|
2182
2866
|
}
|
|
2867
|
+
const more = morePageHint(page, `octwin analytics ${entity} --stage ${stage}`);
|
|
2868
|
+
if (more)
|
|
2869
|
+
console.log(more);
|
|
2183
2870
|
return;
|
|
2184
2871
|
}
|
|
2185
2872
|
if (!asJson)
|
|
@@ -2240,10 +2927,67 @@ async function cmdAnalytics(flags) {
|
|
|
2240
2927
|
}
|
|
2241
2928
|
}
|
|
2242
2929
|
// ── catalog: the commerce products + their WhatsApp binding ──────────────────
|
|
2930
|
+
/** Reserved leading words on `octwin catalog`. */
|
|
2931
|
+
const CATALOG_VERBS = new Set(['availability', 'stock']);
|
|
2932
|
+
/**
|
|
2933
|
+
* The write half of `octwin catalog` — the two per-SKU levers a pack author needs
|
|
2934
|
+
* to exercise a commerce flow (is it sellable, and how many are there).
|
|
2935
|
+
*
|
|
2936
|
+
* Creating/deleting products and the Meta Graph binding/sync/pull are deliberately
|
|
2937
|
+
* NOT here — see docs/BACKLOG.md. Those are catalog *operations*, need a bound
|
|
2938
|
+
* access token to be meaningful, and belong to the console.
|
|
2939
|
+
*/
|
|
2940
|
+
async function cmdCatalogWrite(flags) {
|
|
2941
|
+
const t = resolveTarget(flags);
|
|
2942
|
+
const { url } = t;
|
|
2943
|
+
const base = `${url}/api/self/p/catalog`;
|
|
2944
|
+
const verb = flags._[0];
|
|
2945
|
+
const sku = flags._[1] ?? die(`usage: octwin catalog ${verb} <retailerId> …`);
|
|
2946
|
+
if (verb === 'availability') {
|
|
2947
|
+
const to = typeof flags.to === 'string' ? flags.to
|
|
2948
|
+
: die("usage: octwin catalog availability <retailerId> --to 'in stock'|'out of stock'|…");
|
|
2949
|
+
console.log(`→ Setting ${sku} availability to '${to}' …`);
|
|
2950
|
+
const { status, json } = await apiSend('PATCH', `${base}/${encodeURIComponent(sku)}/availability`, { availability: to }, t);
|
|
2951
|
+
if (status !== 200)
|
|
2952
|
+
writeFail(`set availability for '${sku}'`, status, json, url);
|
|
2953
|
+
console.log(`✓ ${sku} is now '${to}'.`);
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
// stock — read when no --set-on-hand, write when there is.
|
|
2957
|
+
const raw = flags['set-on-hand'];
|
|
2958
|
+
if (raw === undefined) {
|
|
2959
|
+
const { status, json } = await apiGet(`${base}/${encodeURIComponent(sku)}/stock`, t);
|
|
2960
|
+
if (status === 404)
|
|
2961
|
+
die(`product '${sku}' not found`);
|
|
2962
|
+
if (status !== 200)
|
|
2963
|
+
writeFail(`read stock for '${sku}'`, status, json, url);
|
|
2964
|
+
// null is a real answer, and a different one from zero: the SKU is not
|
|
2965
|
+
// inventory-tracked, so it is always sellable.
|
|
2966
|
+
console.log(json?.stock == null
|
|
2967
|
+
? `${sku}: not inventory-tracked (always sellable)`
|
|
2968
|
+
: `${sku}: on_hand=${json.stock.on_hand ?? '?'} reserved=${json.stock.reserved ?? 0}`);
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
const onHand = Number(raw);
|
|
2972
|
+
if (!Number.isInteger(onHand) || onHand < 0)
|
|
2973
|
+
die(`--set-on-hand must be a non-negative integer (got '${String(raw)}')`);
|
|
2974
|
+
console.log(`→ Setting ${sku} on_hand to ${onHand} …`);
|
|
2975
|
+
const { status, json } = await apiSend('PUT', `${base}/${encodeURIComponent(sku)}/stock`, { on_hand: onHand }, t);
|
|
2976
|
+
if (status === 404)
|
|
2977
|
+
die(`product '${sku}' not found`);
|
|
2978
|
+
if (status === 409) {
|
|
2979
|
+
die(`refused: ${onHand} is below the units already RESERVED for open carts/orders${errDetail(json)}`);
|
|
2980
|
+
}
|
|
2981
|
+
if (status !== 200)
|
|
2982
|
+
writeFail(`set stock for '${sku}'`, status, json, url);
|
|
2983
|
+
console.log(`✓ ${sku}: on_hand=${json?.stock?.on_hand ?? onHand} reserved=${json?.stock?.reserved ?? 0}`);
|
|
2984
|
+
}
|
|
2243
2985
|
/** `octwin catalog [--readiness] [--json]` — the `product` records a commerce pack
|
|
2244
2986
|
* sells, their stock, and the WhatsApp catalog binding. Needs `catalog:read` + the
|
|
2245
2987
|
* `catalog` plan feature. */
|
|
2246
2988
|
async function cmdCatalog(flags) {
|
|
2989
|
+
if (typeof flags._[0] === 'string' && CATALOG_VERBS.has(flags._[0]))
|
|
2990
|
+
return cmdCatalogWrite(flags);
|
|
2247
2991
|
const t = resolveTarget(flags);
|
|
2248
2992
|
const { url } = t;
|
|
2249
2993
|
const base = `${url}/api/self/p/catalog`;
|
|
@@ -2274,15 +3018,17 @@ async function cmdCatalog(flags) {
|
|
|
2274
3018
|
}
|
|
2275
3019
|
if (!asJson)
|
|
2276
3020
|
console.log(`→ Reading the product catalog from ${targetLabel(t)} …`);
|
|
2277
|
-
const { status, json } = await apiGet(base
|
|
3021
|
+
const { status, json } = await apiGet(`${base}?${pagingQs(flags)}`, t);
|
|
2278
3022
|
if (status !== 200)
|
|
2279
3023
|
die(`could not read the catalog (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
2280
3024
|
if (asJson) {
|
|
2281
3025
|
console.log(JSON.stringify(json, null, 2));
|
|
2282
3026
|
return;
|
|
2283
3027
|
}
|
|
2284
|
-
const
|
|
2285
|
-
|
|
3028
|
+
const page = readPage(json);
|
|
3029
|
+
const products = page.rows;
|
|
3030
|
+
// The route reports the catalog-wide total; `products.length` is only this page.
|
|
3031
|
+
console.log(`Products in ${targetLabel(t)}: ${page.total ?? products.length}`);
|
|
2286
3032
|
if (products.length === 0)
|
|
2287
3033
|
console.log(' (none — a commerce pack seeds `product` records, or add them in the console Catalog)');
|
|
2288
3034
|
for (const p of products) {
|
|
@@ -2291,6 +3037,9 @@ async function cmdCatalog(flags) {
|
|
|
2291
3037
|
console.log(` ${String(p.retailer_id).padEnd(20)} ${String(p.name ?? '').padEnd(28)} ${fmtAmount(p.price, p.currency)}`
|
|
2292
3038
|
+ ` avail=${p.availability} stock=${stock} sync=${p.sync_status ?? '—'}`);
|
|
2293
3039
|
}
|
|
3040
|
+
const morePages = morePageHint(page, 'octwin catalog');
|
|
3041
|
+
if (morePages)
|
|
3042
|
+
console.log(morePages);
|
|
2294
3043
|
// A binding row can exist with no catalog_id yet (a WABA is configured but no Meta
|
|
2295
3044
|
// catalog picked) — that is "not bound" for selling purposes, so say so.
|
|
2296
3045
|
const b = json?.binding;
|
|
@@ -2300,10 +3049,121 @@ async function cmdCatalog(flags) {
|
|
|
2300
3049
|
+ ' — the catalog works web-only (`--readiness` explains what Meta needs).');
|
|
2301
3050
|
}
|
|
2302
3051
|
// ── scheduling: the availability engine + a slot preview ─────────────────────
|
|
3052
|
+
/** Reserved leading words on `octwin scheduling`. */
|
|
3053
|
+
const SCHEDULING_VERBS = new Set(['rules', 'rule', 'exception']);
|
|
3054
|
+
/**
|
|
3055
|
+
* The write half of `octwin scheduling` — the availability rules and exceptions
|
|
3056
|
+
* behind the slots `octwin scheduling --slots` computes.
|
|
3057
|
+
*
|
|
3058
|
+
* `rules` (the LIST) ships with them on purpose: the deletes take a rule id, and
|
|
3059
|
+
* without a way to see one there was no path from "a rule exists" to "remove it".
|
|
3060
|
+
*/
|
|
3061
|
+
async function cmdSchedulingWrite(flags) {
|
|
3062
|
+
const t = resolveTarget(flags);
|
|
3063
|
+
const { url } = t;
|
|
3064
|
+
const base = `${url}/api/self/p/scheduling`;
|
|
3065
|
+
const verb = flags._[0];
|
|
3066
|
+
const asJson = flags.json === true;
|
|
3067
|
+
if (verb === 'rules') {
|
|
3068
|
+
const resource = typeof flags.resource === 'string' ? flags.resource
|
|
3069
|
+
: die('usage: octwin scheduling rules --resource <resourceRecordId>');
|
|
3070
|
+
if (!asJson)
|
|
3071
|
+
console.log(`→ Reading availability for resource ${resource} …`);
|
|
3072
|
+
const { status, json } = await apiGet(`${base}/availability?resource_id=${encodeURIComponent(resource)}`, t);
|
|
3073
|
+
if (status !== 200)
|
|
3074
|
+
writeFail('read availability', status, json, url);
|
|
3075
|
+
if (asJson) {
|
|
3076
|
+
console.log(JSON.stringify(json, null, 2));
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
if (json?.has_scheduling === false) {
|
|
3080
|
+
console.log('This pack declares no scheduling.');
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
const rules = (json?.rules ?? []);
|
|
3084
|
+
const exceptions = (json?.exceptions ?? []);
|
|
3085
|
+
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
3086
|
+
console.log(`Rules: ${rules.length}`);
|
|
3087
|
+
for (const r of rules) {
|
|
3088
|
+
console.log(` ${DOW[r.dow] ?? `dow${r.dow}`} ${r.start_time}–${r.end_time}`
|
|
3089
|
+
+ ` slot=${r.slot_minutes ?? '—'}m cap=${r.capacity ?? '—'} ${r.id}`);
|
|
3090
|
+
}
|
|
3091
|
+
console.log(`Exceptions: ${exceptions.length}`);
|
|
3092
|
+
for (const e of exceptions) {
|
|
3093
|
+
console.log(` ${e.exception_date} ${e.kind}${e.start_time ? ` ${e.start_time}–${e.end_time}` : ''} ${e.id}`);
|
|
3094
|
+
}
|
|
3095
|
+
console.log('\nRemove one: octwin scheduling rule rm <ruleId> · octwin scheduling exception rm <exceptionId>');
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
const sub = flags._[1];
|
|
3099
|
+
const isRule = verb === 'rule';
|
|
3100
|
+
const noun = isRule ? 'rule' : 'exception';
|
|
3101
|
+
const path = isRule ? 'rules' : 'exceptions';
|
|
3102
|
+
if (sub === 'rm') {
|
|
3103
|
+
const id = flags._[2] ?? die(`usage: octwin scheduling ${noun} rm <${noun}Id>`);
|
|
3104
|
+
console.log(`→ Removing ${noun} ${id} …`);
|
|
3105
|
+
const { status, json } = await apiSend('DELETE', `${base}/availability/${path}/${encodeURIComponent(id)}`, undefined, t);
|
|
3106
|
+
if (status === 404)
|
|
3107
|
+
die(`${noun} '${id}' not found`);
|
|
3108
|
+
if (status !== 200)
|
|
3109
|
+
writeFail(`remove ${noun} ${id}`, status, json, url);
|
|
3110
|
+
console.log(`✓ ${noun[0].toUpperCase()}${noun.slice(1)} removed.`);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
if (sub !== 'add') {
|
|
3114
|
+
die(isRule
|
|
3115
|
+
? 'usage: octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00 [--slot-minutes 30] [--capacity 1]\n octwin scheduling rule rm <ruleId>'
|
|
3116
|
+
: 'usage: octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra [--start 09:00 --end 13:00]\n octwin scheduling exception rm <exceptionId>');
|
|
3117
|
+
}
|
|
3118
|
+
const resource = typeof flags.resource === 'string' ? flags.resource
|
|
3119
|
+
: die(`octwin scheduling ${noun} add needs --resource <resourceRecordId>`);
|
|
3120
|
+
const body = { resource_id: resource };
|
|
3121
|
+
if (typeof flags['slot-minutes'] === 'string')
|
|
3122
|
+
body.slot_minutes = Number(flags['slot-minutes']);
|
|
3123
|
+
if (typeof flags.capacity === 'string')
|
|
3124
|
+
body.capacity = Number(flags.capacity);
|
|
3125
|
+
if (typeof flags.start === 'string')
|
|
3126
|
+
body.start_time = flags.start;
|
|
3127
|
+
if (typeof flags.end === 'string')
|
|
3128
|
+
body.end_time = flags.end;
|
|
3129
|
+
if (isRule) {
|
|
3130
|
+
const dowRaw = flags.dow;
|
|
3131
|
+
if (typeof dowRaw !== 'string')
|
|
3132
|
+
die('octwin scheduling rule add needs --dow <0-6> (0 = Sunday)');
|
|
3133
|
+
const dow = Number(dowRaw);
|
|
3134
|
+
if (!Number.isInteger(dow) || dow < 0 || dow > 6)
|
|
3135
|
+
die(`--dow must be an integer 0-6, 0 = Sunday (got '${dowRaw}')`);
|
|
3136
|
+
body.dow = dow;
|
|
3137
|
+
if (!body.start_time || !body.end_time)
|
|
3138
|
+
die('octwin scheduling rule add needs --start HH:MM and --end HH:MM');
|
|
3139
|
+
}
|
|
3140
|
+
else {
|
|
3141
|
+
const date = typeof flags.date === 'string' ? flags.date
|
|
3142
|
+
: die('octwin scheduling exception add needs --date YYYY-MM-DD');
|
|
3143
|
+
const kind = typeof flags.kind === 'string' ? flags.kind
|
|
3144
|
+
: die("octwin scheduling exception add needs --kind closed|extra");
|
|
3145
|
+
if (kind !== 'closed' && kind !== 'extra')
|
|
3146
|
+
die(`--kind must be 'closed' or 'extra' (got '${kind}')`);
|
|
3147
|
+
body.exception_date = date;
|
|
3148
|
+
body.kind = kind;
|
|
3149
|
+
}
|
|
3150
|
+
console.log(`→ Adding a ${noun} for resource ${resource} …`);
|
|
3151
|
+
const { status, json } = await apiSend('POST', `${base}/availability/${path}`, body, t);
|
|
3152
|
+
if (status !== 201 && status !== 200)
|
|
3153
|
+
writeFail(`add the ${noun}`, status, json, url);
|
|
3154
|
+
if (json?.has_scheduling === false)
|
|
3155
|
+
die('this pack declares no scheduling');
|
|
3156
|
+
const created = json?.rule ?? json?.exception ?? {};
|
|
3157
|
+
console.log(`✓ ${noun[0].toUpperCase()}${noun.slice(1)} added — ${created.id ?? '(no id returned)'}`);
|
|
3158
|
+
console.log(`\nSee it: octwin scheduling rules --resource ${resource}`);
|
|
3159
|
+
console.log(`Verify the slots it produces: octwin scheduling --slots ${resource}`);
|
|
3160
|
+
}
|
|
2303
3161
|
/** `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]`
|
|
2304
3162
|
* — the scheduling engine's state, or the computed slots for one bookable resource
|
|
2305
3163
|
* (the verification the `--seed` availability fan-out was missing). `scheduling:read`. */
|
|
2306
3164
|
async function cmdScheduling(flags) {
|
|
3165
|
+
if (typeof flags._[0] === 'string' && SCHEDULING_VERBS.has(flags._[0]))
|
|
3166
|
+
return cmdSchedulingWrite(flags);
|
|
2307
3167
|
const t = resolveTarget(flags);
|
|
2308
3168
|
const { url } = t;
|
|
2309
3169
|
const base = `${url}/api/self/p/scheduling`;
|
|
@@ -2368,9 +3228,10 @@ function help() {
|
|
|
2368
3228
|
|
|
2369
3229
|
octwin --version # print the CLI version (+ any upgrade notice)
|
|
2370
3230
|
octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
|
|
2371
|
-
octwin validate [--dir .] [--remote]
|
|
3231
|
+
octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
|
|
2372
3232
|
octwin login --url <platformUrl> --token oct_… # a deploy token from the console
|
|
2373
3233
|
octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
|
|
3234
|
+
octwin projects [--archived] [--json] # the --project slugs this token can name
|
|
2374
3235
|
octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
2375
3236
|
octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
|
|
2376
3237
|
octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
|
|
@@ -2386,6 +3247,17 @@ function help() {
|
|
|
2386
3247
|
octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
|
|
2387
3248
|
octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
2388
3249
|
octwin test [--dir .] # = validate --remote (the full platform check)
|
|
3250
|
+
octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
|
|
3251
|
+
|
|
3252
|
+
Writes — exercise the state your pack creates (each needs the matching :write scope):
|
|
3253
|
+
octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
|
|
3254
|
+
octwin records tasks | task complete <taskId> [--outcome done|cancelled]
|
|
3255
|
+
octwin cases assign <id> --to user:<uuid>|none | note <id> "…" | transition <id> --to <status>
|
|
3256
|
+
octwin cases decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
|
|
3257
|
+
octwin orders transition <ref> --to <status> | refund <ref> --force
|
|
3258
|
+
octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
|
|
3259
|
+
octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
|
|
3260
|
+
octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
|
|
2389
3261
|
|
|
2390
3262
|
Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
|
|
2391
3263
|
\`octwin chat --as <h>\` calls continue the same conversation; press a rendered
|
|
@@ -2400,26 +3272,57 @@ Per-command usage: octwin <command> --help`);
|
|
|
2400
3272
|
const COMMAND_HELP = {
|
|
2401
3273
|
init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
|
|
2402
3274
|
Scaffold a pure-YAML starter pack into <dir>.`,
|
|
2403
|
-
validate: `octwin validate [--dir .] [--remote]
|
|
2404
|
-
Offline structural check
|
|
2405
|
-
|
|
3275
|
+
validate: `octwin validate [--dir .] [--remote] [--require-kb]
|
|
3276
|
+
Offline structural check, plus two checks driven by the pulled capability
|
|
3277
|
+
reference (render-intent fields, primitive arguments). Those two SKIP when the
|
|
3278
|
+
reference is missing — the run says so, and --require-kb turns the skip into a
|
|
3279
|
+
failure for CI. --remote additionally runs the platform's FULL manifest +
|
|
3280
|
+
flow-DSL validation and its flow lint (all errors at once) — same check as deploy.`,
|
|
2406
3281
|
login: `octwin login --url <platformUrl> --token oct_…
|
|
2407
3282
|
Save a deploy token (console → Settings → API tokens) for that platform url,
|
|
2408
3283
|
make that url the DEFAULT deploy target for every later command, and echo the
|
|
2409
3284
|
workspace + project pin + scopes the token reaches.`,
|
|
2410
3285
|
whoami: `octwin whoami [--url <url>] [--tenant <slug>]
|
|
2411
3286
|
Verify the resolved token authenticates against the tenant.`,
|
|
3287
|
+
projects: `octwin projects [--archived] [--json]
|
|
3288
|
+
List the workspace's projects — the slugs every --project flag takes, with the
|
|
3289
|
+
plan's project cap. --archived includes archived ones. A pack:deploy token
|
|
3290
|
+
reaches this (it names a project in every other command).`,
|
|
2412
3291
|
deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
2413
3292
|
Upload the pack bundle, validate server-side, install onto the project.
|
|
2414
3293
|
--seed additionally applies the pack's demo seed (streams progress).`,
|
|
2415
3294
|
status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
|
|
2416
3295
|
Show installed vs live version + the flow list for this pack.`,
|
|
2417
|
-
records: `octwin records [entity] [id] [--limit 50]
|
|
3296
|
+
records: `octwin records [entity] [id] [--limit 50] [--offset n]
|
|
2418
3297
|
Inspect the pack's XRM data. No args = list entities. Cases/tickets are
|
|
2419
|
-
casework, not XRM — use \`octwin cases\` for those
|
|
2420
|
-
|
|
3298
|
+
casework, not XRM — use \`octwin cases\` for those.
|
|
3299
|
+
|
|
3300
|
+
WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
|
|
3301
|
+
octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
|
|
3302
|
+
octwin records patch <recordId> --entity <entity> --set field=value
|
|
3303
|
+
octwin records stage <recordId> --to <stage> [--note "..."]
|
|
3304
|
+
octwin records note <recordId> "the note text"
|
|
3305
|
+
octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
|
|
3306
|
+
octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
|
|
3307
|
+
|
|
3308
|
+
--set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
|
|
3309
|
+
sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
|
|
3310
|
+
\`patch\` needs --entity even though it has an id: the route resolves the field
|
|
3311
|
+
validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
|
|
3312
|
+
VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
|
|
3313
|
+
cases: `octwin cases [caseId] [--queues] [--limit 50] [--offset n] [--json]
|
|
2421
3314
|
Inspect casework (support tickets): the inbox, one case + its timeline
|
|
2422
|
-
(+ applicable decisions), or --queues for queue keys + open counts
|
|
3315
|
+
(+ applicable decisions), or --queues for queue keys + open counts.
|
|
3316
|
+
|
|
3317
|
+
WRITES (need \`cases:write\`):
|
|
3318
|
+
octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none
|
|
3319
|
+
octwin cases note <caseId> "the note text"
|
|
3320
|
+
octwin cases transition <caseId> --to <status> [--note "..."]
|
|
3321
|
+
octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]
|
|
3322
|
+
|
|
3323
|
+
\`decide\` applies one of the case's declared dispositions — \`octwin cases <id>\`
|
|
3324
|
+
lists them with their params. --dry-run previews the customer-facing copy and the
|
|
3325
|
+
resulting status WITHOUT committing (that route needs only \`cases:read\`).`,
|
|
2423
3326
|
logs: `octwin logs [conversationId] [--as <handle>] [--json]
|
|
2424
3327
|
No id = recent conversations (handle, status, last activity; --as filters).
|
|
2425
3328
|
With id = the full event timeline including what each turn rendered.
|
|
@@ -2467,13 +3370,30 @@ const COMMAND_HELP = {
|
|
|
2467
3370
|
override what your manifest declares, and this is where you see that.
|
|
2468
3371
|
--prompt = the exact system prompt the LLM sees for this project (pack
|
|
2469
3372
|
instructions + platform protocol + any project overlay). Needs agents:read.
|
|
2470
|
-
The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID
|
|
3373
|
+
The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
|
|
3374
|
+
|
|
3375
|
+
WRITES (need \`agents:write\`):
|
|
3376
|
+
octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
|
|
3377
|
+
[--enable-tool <toolId>] [--disable-tool <toolId>]
|
|
3378
|
+
|
|
3379
|
+
Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
|
|
3380
|
+
so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
|
|
3381
|
+
ids refuses --model with a 403 — the platform default governs there.`,
|
|
2471
3382
|
orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
|
|
2472
3383
|
No args = the order list (#number, status/payment, total, contact). With a
|
|
2473
3384
|
reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
|
|
2474
3385
|
payment_ref, and the allowed status transitions. Needs orders:read + the
|
|
2475
3386
|
\`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
|
|
2476
|
-
so \`pending\` on a gateway-less workspace is expected, not a bug
|
|
3387
|
+
so \`pending\` on a gateway-less workspace is expected, not a bug.
|
|
3388
|
+
|
|
3389
|
+
WRITES (need \`orders:write\`):
|
|
3390
|
+
octwin orders transition <reference_id> --to <status>
|
|
3391
|
+
octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
|
|
3392
|
+
|
|
3393
|
+
Refund is irreversible and moves money, hence --force. The route answers 200 even
|
|
3394
|
+
when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
|
|
3395
|
+
on a refusal rather than reporting a refund that never happened. Only a payment in
|
|
3396
|
+
\`captured\` state can be refunded; \`payment_status\` is never settable directly.`,
|
|
2477
3397
|
analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
|
|
2478
3398
|
No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
|
|
2479
3399
|
With an entity = stage-by-stage conversion (default --funnel) over the last 30
|
|
@@ -2483,22 +3403,58 @@ const COMMAND_HELP = {
|
|
|
2483
3403
|
The commerce \`product\` records + price, availability, stock (null = not
|
|
2484
3404
|
inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
|
|
2485
3405
|
Graph checklist (LIVE Graph calls; needs a bound access token). Needs
|
|
2486
|
-
catalog:read + the \`catalog\` plan feature
|
|
3406
|
+
catalog:read + the \`catalog\` plan feature.
|
|
3407
|
+
|
|
3408
|
+
WRITES (need \`catalog:write\`):
|
|
3409
|
+
octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
|
|
3410
|
+
octwin catalog stock <retailerId> [--set-on-hand <n>]
|
|
3411
|
+
|
|
3412
|
+
\`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
|
|
3413
|
+
inventory-tracked (always sellable), which is different from 0. Lowering on_hand
|
|
3414
|
+
below the units already reserved for open carts is refused. Creating/deleting
|
|
3415
|
+
products and the Meta catalog binding/sync stay in the console.`,
|
|
2487
3416
|
scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
|
|
2488
3417
|
No args = the engine state (bookable resource types, upcoming slots, booked
|
|
2489
3418
|
seats). --slots <recordId> computes the slots for one bookable resource
|
|
2490
3419
|
(occupancy included; --days is clamped to 1-31 server-side) — the way to verify
|
|
2491
|
-
the availability rules a \`deploy --seed\` created. Needs scheduling:read
|
|
3420
|
+
the availability rules a \`deploy --seed\` created. Needs scheduling:read.
|
|
3421
|
+
|
|
3422
|
+
RULES (list needs scheduling:read; add/rm need scheduling:write):
|
|
3423
|
+
octwin scheduling rules --resource <resourceRecordId>
|
|
3424
|
+
octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
|
|
3425
|
+
[--slot-minutes 30] [--capacity 1]
|
|
3426
|
+
octwin scheduling rule rm <ruleId>
|
|
3427
|
+
octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
|
|
3428
|
+
[--start 09:00 --end 13:00]
|
|
3429
|
+
octwin scheduling exception rm <exceptionId>
|
|
3430
|
+
|
|
3431
|
+
--dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
|
|
3432
|
+
\`--slots\` is how you check what a rule actually produces.`,
|
|
2492
3433
|
'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
2493
3434
|
Pull the platform capability reference (markdown + JSON catalogs) into
|
|
2494
3435
|
.octwin/platform-kb/ for the octwin-pack authoring skill.`,
|
|
2495
3436
|
test: `octwin test [--dir .]
|
|
2496
3437
|
Alias for \`octwin validate --remote\` — the full platform check.`,
|
|
3438
|
+
feedback: `octwin feedback [--dir .]
|
|
3439
|
+
Submit this pack's FEEDBACK.md to the platform team.
|
|
3440
|
+
The octwin-pack skill writes that file in its last step — findings grouped by
|
|
3441
|
+
owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
|
|
3442
|
+
you to paste it into a chat.
|
|
3443
|
+
Attaches the pack id + version from manifest.yaml, this CLI's version, and the
|
|
3444
|
+
content_hash of the capability reference in .octwin/platform-kb/ — triage needs
|
|
3445
|
+
the last two to tell "the platform is wrong" from "that was already fixed" or
|
|
3446
|
+
"you were reading a stale reference". Needs the \`pack:deploy\` scope.`,
|
|
2497
3447
|
};
|
|
2498
3448
|
async function main() {
|
|
2499
3449
|
const [command, ...rest] = process.argv.slice(2);
|
|
2500
3450
|
const flags = parseFlags(rest);
|
|
2501
|
-
|
|
3451
|
+
// So an auth failure can name the scope THIS invocation needs. A leading write
|
|
3452
|
+
// verb changes the answer (`cases` reads, `cases note` writes), so it rides along
|
|
3453
|
+
// when the first positional is one — `VERB_REQUIREMENTS` is keyed that way.
|
|
3454
|
+
const leadingVerb = flags._[0];
|
|
3455
|
+
CURRENT_COMMAND = (typeof leadingVerb === 'string' && command && `${command} ${leadingVerb}` in VERB_REQUIREMENTS)
|
|
3456
|
+
? `${command} ${leadingVerb}`
|
|
3457
|
+
: command;
|
|
2502
3458
|
// Per-subcommand --help/-h — intercepted BEFORE the command runs, so help can
|
|
2503
3459
|
// never hit the network or die on auth (author-feedback A8).
|
|
2504
3460
|
if (command && command in COMMAND_HELP && (flags.help === true || flags._.includes('-h'))) {
|
|
@@ -2542,6 +3498,9 @@ async function main() {
|
|
|
2542
3498
|
case 'media':
|
|
2543
3499
|
await cmdMedia(flags);
|
|
2544
3500
|
break;
|
|
3501
|
+
case 'projects':
|
|
3502
|
+
await cmdProjects(flags);
|
|
3503
|
+
break;
|
|
2545
3504
|
case 'agents':
|
|
2546
3505
|
await cmdAgents(flags);
|
|
2547
3506
|
break;
|
|
@@ -2560,6 +3519,9 @@ async function main() {
|
|
|
2560
3519
|
case 'platform-kb':
|
|
2561
3520
|
await cmdPlatformKb(flags);
|
|
2562
3521
|
break;
|
|
3522
|
+
case 'feedback':
|
|
3523
|
+
await cmdFeedback(flags);
|
|
3524
|
+
break;
|
|
2563
3525
|
case 'test':
|
|
2564
3526
|
await cmdValidate({ ...flags, remote: true });
|
|
2565
3527
|
break; // A6: `test` = the full remote validate, not a validate-clone
|