octwin-cli 0.1.21 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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 key = a.slice(2);
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
- f[key] = true;
137
+ put(body, true);
88
138
  else {
89
- f[key] = next;
139
+ put(body, next);
90
140
  i++;
91
141
  }
92
142
  }
@@ -119,19 +169,43 @@ 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
- * The scope (and plan feature, where the route is entitlement-gated) each command's
123
- * endpoints require a client-side mirror of the platform's scope registry
124
- * (`src/platform/core/identity/scopes.ts` `SCOPE_REGISTRY`) narrowed to what the CLI
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
- * `pack:deploy` and `media:generate` have access `special`: they match by DIRECT
129
- * grant only, so even a `tenant:admin` preset token does NOT confer them
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
+ // Creating and destroying a project are the same scope as editing one. Worth
201
+ // spelling out because the natural token for the deploy loop is `pack:deploy`,
202
+ // which does NOT confer this — that 403 is otherwise baffling.
203
+ 'projects create': { scope: 'projects:write' },
204
+ 'projects rm': { scope: 'projects:write' },
205
+ };
133
206
  const COMMAND_REQUIREMENTS = {
134
207
  deploy: { scope: 'pack:deploy' },
208
+ seed: { scope: 'pack:deploy' },
135
209
  validate: { scope: 'pack:deploy' },
136
210
  status: { scope: 'pack:deploy' },
137
211
  test: { scope: 'pack:deploy' },
@@ -140,6 +214,7 @@ const COMMAND_REQUIREMENTS = {
140
214
  // only source copy printed the generic hint WITHOUT naming the scope to grant.
141
215
  pull: { scope: 'pack:deploy' },
142
216
  'platform-kb': { scope: 'pack:deploy' },
217
+ feedback: { scope: 'pack:deploy' },
143
218
  media: { scope: 'media:generate' },
144
219
  // The plan feature gates RECORD reads, not the entity list (`/xrm/entities` carries only
145
220
  // the scope guard) — so the hint says which half it applies to rather than blaming the
@@ -152,14 +227,22 @@ const COMMAND_REQUIREMENTS = {
152
227
  catalog: { scope: 'catalog:read', feature: 'catalog' },
153
228
  scheduling: { scope: 'scheduling:read' },
154
229
  agents: { scope: 'agents:read' },
230
+ // The route accepts `pack:deploy` too (that is the point of the command), but the
231
+ // hint names the scope a NON-deploy token would be missing — a `pack:deploy`
232
+ // holder never sees this line, because they never get the 403.
233
+ projects: { scope: 'projects:read' },
155
234
  };
156
235
  /** 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. */
236
+ * name the scope that command needs without threading it through every call.
237
+ * Carries the write VERB too (`cases note`), since that is what decides the scope. */
158
238
  let CURRENT_COMMAND;
159
239
  /** `→ needs the \`orders:read\` scope …` — the requirement line for the running
160
240
  * command, or '' when the command has no declared requirement. */
161
241
  function scopeRequirementHint() {
162
- const req = CURRENT_COMMAND ? COMMAND_REQUIREMENTS[CURRENT_COMMAND] : undefined;
242
+ // Most specific first: `<command> <verb>` beats the command's own (read) entry.
243
+ const req = CURRENT_COMMAND
244
+ ? (VERB_REQUIREMENTS[CURRENT_COMMAND] ?? COMMAND_REQUIREMENTS[CURRENT_COMMAND.split(' ')[0]])
245
+ : undefined;
163
246
  if (!req)
164
247
  return '';
165
248
  const special = req.scope === 'pack:deploy' || req.scope === 'media:generate';
@@ -404,8 +487,11 @@ async function notifyIfOutdated() {
404
487
  /** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
405
488
  * (content hash + per-entry index), or null if nothing has been pulled yet. */
406
489
  function readLocalKb(packDir) {
490
+ const kbDir = findPlatformKbDir(packDir); // walks up — a repo-root pull covers every pack under it
491
+ if (!kbDir)
492
+ return null;
407
493
  try {
408
- const idx = JSON.parse(readFileSync(join(packDir, '.octwin', 'platform-kb', 'index.json'), 'utf8'));
494
+ const idx = JSON.parse(readFileSync(join(kbDir, 'index.json'), 'utf8'));
409
495
  return {
410
496
  content_hash: typeof idx.content_hash === 'string' ? idx.content_hash : null,
411
497
  index: Array.isArray(idx.index) ? idx.index : [],
@@ -501,11 +587,14 @@ function commandTouchesPlatform(command, flags) {
501
587
  case 'cases':
502
588
  case 'logs':
503
589
  case 'whoami':
590
+ case 'feedback':
504
591
  case 'agents':
505
592
  case 'orders':
506
593
  case 'analytics':
507
594
  case 'catalog':
508
- case 'scheduling': return true;
595
+ case 'scheduling':
596
+ case 'projects':
597
+ case 'seed': return true;
509
598
  default: return false;
510
599
  }
511
600
  }
@@ -560,22 +649,28 @@ async function cmdValidate(flags) {
560
649
  const packDir = resolve(flags.dir ?? '.');
561
650
  const { id, version, files, blobs } = localValidate(packDir); // offline structural gate first (fast, no server/token)
562
651
  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
- });
652
+ /** Every YAML file in the bundle, parsed once. A syntax error is the structural gate's to report. */
653
+ const yamlDocs = () => Object.entries(files)
654
+ .filter(([p]) => /\.ya?ml$/i.test(p))
655
+ .flatMap(([p, body]) => {
656
+ try {
657
+ return [[p, parseYaml(body)]];
658
+ }
659
+ catch {
660
+ return [];
661
+ }
662
+ });
663
+ // Checks that need the pulled KB. Both DEGRADE when it is absent — the KB is a
664
+ // gitignored cache wiped by every pull, and `platform-kb pull` needs a
665
+ // `pack:deploy` scope a CI job may not have, so failing hard would break a fresh
666
+ // clone before the author could act. But a skip is now ANNOUNCED, and remembered:
667
+ // the ✓ used to print above these blocks unconditionally while the per-check ✓s
668
+ // lived inside the `if`s, so a KB-less run read as "one check, passed". An entire
669
+ // backlog batch reached production that way. The defect is the silence, not the skip.
670
+ const skipped = [];
671
+ const render = loadAllowedRenderKeys(packDir);
672
+ if (render.keys) {
673
+ const findings = yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys));
579
674
  if (findings.length) {
580
675
  console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
581
676
  for (const f of findings)
@@ -584,23 +679,15 @@ async function cmdValidate(flags) {
584
679
  }
585
680
  console.log('✓ render intents use only fields the platform renders');
586
681
  }
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
- });
682
+ else {
683
+ console.log(`⚠ ${describeKbLookup(render.lookup, 'render-intent fields')}`);
684
+ skipped.push('render-intent fields');
685
+ }
686
+ // Primitive `args:` keys, same source and same contract. Cannot see inside a
687
+ // `use:` template body (expansion is the platform's job); `--remote` covers that.
688
+ const args = loadPrimitiveArgSpecs(packDir);
689
+ if (args.specs) {
690
+ const findings = yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs));
604
691
  if (findings.length) {
605
692
  console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
606
693
  for (const f of findings)
@@ -609,9 +696,26 @@ async function cmdValidate(flags) {
609
696
  }
610
697
  console.log('✓ primitive arguments match their declared inputs');
611
698
  }
699
+ else {
700
+ console.log(`⚠ ${describeKbLookup(args.lookup, 'primitive arguments')}`);
701
+ skipped.push('primitive arguments');
702
+ }
703
+ // `--require-kb` is for CI, where a skip nobody reads is worse than a red build.
704
+ if (skipped.length && flags['require-kb'] === true) {
705
+ die(`--require-kb: ${skipped.length} check${skipped.length === 1 ? '' : 's'} could not run (${skipped.join(', ')})`);
706
+ }
612
707
  if (flags.remote !== true) {
613
- console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
614
- console.log(' (all errors at once) before you deploy.');
708
+ // The LAST line carries the skip. A reader who sees a and stops there is the
709
+ // failure mode; a caveat printed ABOVE the ✓ does not fix it.
710
+ if (skipped.length) {
711
+ console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.join(' and ')} ${skipped.length === 1 ? 'was' : 'were'} skipped.`);
712
+ console.log(' Run `octwin platform-kb pull` (once, at your repo root — it covers every pack under it),');
713
+ console.log(' or `octwin validate --remote` to have the platform run everything server-side.');
714
+ }
715
+ else {
716
+ console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
717
+ console.log(' (all errors at once) before you deploy.');
718
+ }
615
719
  return;
616
720
  }
617
721
  // Remote: the SAME validation the deploy route runs — manifest `.strict()` +
@@ -633,9 +737,25 @@ async function cmdValidate(flags) {
633
737
  json = text;
634
738
  }
635
739
  if (!res.ok) {
636
- // 404 older platform without the endpoint.
637
- if (res.status === 404)
638
- die('this platform has no /packs/validate endpoint yet (older version) deploy runs the full check');
740
+ // A 404 here is AMBIGUOUS and must not be collapsed. The route resolves the
741
+ // tenant and the project BEFORE it validates anything, so a 404 is usually an
742
+ // unknown `--tenant`/`--project` and a token's project PIN answers 404 by
743
+ // design (an out-of-pin project is deliberately indistinguishable from one that
744
+ // does not exist). Reporting all of those as "older platform" sends the author
745
+ // hunting for a version mismatch that does not exist.
746
+ //
747
+ // The two are told apart by the BODY, not the status: the platform has no
748
+ // custom not-found handler, so a missing route is Fastify's default
749
+ // `{ statusCode, error: 'Not Found', message: 'Route … not found' }`, whereas
750
+ // `resolveTenantOr404`/`resolveProjectOr404` send a bare `{ error: "<what> not
751
+ // found" }`. The server's own message already names the slug it tried, so the
752
+ // hint carries the fix rather than repeating the target.
753
+ if (res.status === 404) {
754
+ const routeMissing = typeof json !== 'object' || json === null || json.error === 'Not Found';
755
+ if (routeMissing)
756
+ die('this platform has no /packs/validate endpoint yet (older version) — deploy runs the full check');
757
+ die(`remote validate${errDetail(json)} — check --tenant/--project (or PACK_TENANT/PACK_PROJECT); \`octwin projects\` lists what this token can reach`);
758
+ }
639
759
  console.error(`✗ remote validate failed (HTTP ${res.status})`);
640
760
  printAuthHint(res.status, url);
641
761
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
@@ -918,6 +1038,71 @@ function printDeploySuccess(id, version, t, r) {
918
1038
  printPublicListing(r?.public_listing, r?.public_review_note);
919
1039
  console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
920
1040
  }
1041
+ /**
1042
+ * `octwin seed [--pack <id>]` — apply the pack's demo/reference data to the project it
1043
+ * is installed on, without redeploying.
1044
+ *
1045
+ * Exists because seeding used to be reachable only as `deploy --seed`: the platform's
1046
+ * seed endpoint was keyed on an install id, guarded `requirePlatformAdmin`, and carried
1047
+ * no tenant/project segments — so the `/api/self/**` rewrite could not reach it and a
1048
+ * `pack:deploy` token never could. Re-seeding meant a full redeploy, or asking an
1049
+ * operator.
1050
+ *
1051
+ * Reuses `readDeployProgress` verbatim: the platform emits ONE seed-progress vocabulary
1052
+ * now (`stage:'seed'` with a `kind`), so a second reader would only be a second thing to
1053
+ * keep in step.
1054
+ */
1055
+ async function cmdSeed(flags) {
1056
+ const t = resolveTarget(flags);
1057
+ const { url } = t;
1058
+ const packId = typeof flags.pack === 'string' ? flags.pack : undefined;
1059
+ console.log(`→ Seeding ${packId ?? 'the installed pack'} on ${targetLabel(t)} …`);
1060
+ const res = await fetchOrDie(`${url}/api/self/p/packs/seed`, {
1061
+ method: 'POST',
1062
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
1063
+ body: JSON.stringify(packId ? { pack_id: packId } : {}),
1064
+ }, 'seed');
1065
+ if (res.ok && (res.headers.get('content-type') ?? '').includes('text/event-stream') && res.body) {
1066
+ const { terminal: final, stepErrors } = await readDeployProgress(res.body);
1067
+ if (!final || final.stage === 'error')
1068
+ die(`seed failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
1069
+ console.log(`
1070
+ ✓ ${final.message ?? 'seed complete'}`);
1071
+ printSeedCounts(final.result?.seeded);
1072
+ if (stepErrors.length) {
1073
+ // A kind failed but the rest ran — the reconcile softens each step. Say which,
1074
+ // and exit non-zero so a scripted `seed && chat` doesn't read as clean.
1075
+ console.error(`
1076
+ ⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
1077
+ for (const e of stepErrors)
1078
+ console.error(` • ${e}`);
1079
+ process.exit(1);
1080
+ }
1081
+ return;
1082
+ }
1083
+ const text = await res.text();
1084
+ let json;
1085
+ try {
1086
+ json = JSON.parse(text);
1087
+ }
1088
+ catch {
1089
+ json = text;
1090
+ }
1091
+ if (!res.ok) {
1092
+ console.error(`✗ seed failed (HTTP ${res.status})${errDetail(json)}`);
1093
+ printAuthHint(res.status, url);
1094
+ process.exit(1);
1095
+ }
1096
+ console.log('✓ seed complete');
1097
+ printSeedCounts(json?.seeded);
1098
+ }
1099
+ /** Per-kind counts, one line each. Prints nothing when the pack declared nothing. */
1100
+ function printSeedCounts(seeded) {
1101
+ for (const [kind, counts] of Object.entries(seeded ?? {})) {
1102
+ const detail = Object.entries(counts).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(' · ');
1103
+ console.log(` ${kind.padEnd(11)} ${detail || '—'}`);
1104
+ }
1105
+ }
921
1106
  async function cmdDeploy(flags) {
922
1107
  const packDir = resolve(flags.dir ?? '.');
923
1108
  const t = resolveTarget(flags);
@@ -1011,7 +1196,17 @@ async function cmdStatus(flags) {
1011
1196
  console.log(` live on instance : registered=${json.registered} loaded=${shortSha(json.loaded_content_sha)}`);
1012
1197
  console.log(` catalog artifact : ${shortSha(json.catalog_content_sha)}${json.origin ? ` origin=${json.origin}` : ''}`);
1013
1198
  console.log(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
1014
- if (!json.registered) {
1199
+ // Every line above is process-global — `registered` is true for a pack ANY
1200
+ // project on this instance loaded. `dispatches` is the project-scoped answer to
1201
+ // the question status is actually asked: can this pack receive a message here?
1202
+ // Dispatch takes the OLDEST active install and ignores the rest, so a second
1203
+ // install is not a warning, it is a pack that will never run.
1204
+ if (json.dispatches === false) {
1205
+ console.log(`\n✗ installed, but project '${t.project ?? '(pinned)'}' dispatches to `
1206
+ + `'${json.dispatches_to ?? '(nothing)'}' — this pack CANNOT receive a message.`);
1207
+ console.log(' One pack per project: the oldest active install wins. Archive the other install to switch.');
1208
+ }
1209
+ else if (!json.registered) {
1015
1210
  console.log('\n… not warm on the instance you hit yet — it loads on the next inbound (chat once, then re-check).');
1016
1211
  }
1017
1212
  else if (json.up_to_date === false) {
@@ -1238,6 +1433,8 @@ async function cmdPlatformKb(flags) {
1238
1433
  console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
1239
1434
  console.log(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`);
1240
1435
  console.log(' Start at INDEX.md — it maps every doc and every catalog entry to its file.');
1436
+ console.log(' Every pack UNDER this directory finds it — `octwin validate` walks up to locate it,');
1437
+ console.log(' so one pull at a repo root covers a whole monorepo of packs.');
1241
1438
  // Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
1242
1439
  // moved (a schema shape being replaced shows as a `~ changed`), not just a count.
1243
1440
  if (prior?.content_hash) {
@@ -1273,17 +1470,125 @@ async function apiGet(endpoint, t) {
1273
1470
  }
1274
1471
  return { status: res.status, json };
1275
1472
  }
1473
+ /**
1474
+ * Send a WRITE to an admin endpoint with the deploy token; returns `{ status, json }`.
1475
+ *
1476
+ * The mirror of `apiGet`, and the reason every write command is three lines: the
1477
+ * `content-type` + `authHeaders` block was inlined at each of the four original
1478
+ * write sites, and fifteen more copies is how one of them ends up subtly different.
1479
+ * A `204` (media delete) has no body to parse, hence the empty-text guard.
1480
+ */
1481
+ async function apiSend(method, endpoint, body, t) {
1482
+ const res = await fetchOrDie(endpoint, {
1483
+ method,
1484
+ headers: { 'content-type': 'application/json', ...authHeaders(t) },
1485
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
1486
+ }, 'request');
1487
+ const text = await res.text();
1488
+ if (!text)
1489
+ return { status: res.status, json: null };
1490
+ let json;
1491
+ try {
1492
+ json = JSON.parse(text);
1493
+ }
1494
+ catch {
1495
+ json = text;
1496
+ }
1497
+ return { status: res.status, json };
1498
+ }
1499
+ /**
1500
+ * Fail a write with the server's own reason, the auth explanation, and — where it
1501
+ * applies — the RBAC caveat a scope hint structurally cannot cover.
1502
+ *
1503
+ * Record and case writes are re-checked against the SPECIFIC row, so a 403 there
1504
+ * can mean "your token has the scope but your role has no grant on this record",
1505
+ * which is invisible to `COMMAND_REQUIREMENTS`. Saying so is the difference
1506
+ * between a two-minute fix and re-minting a token that was never the problem.
1507
+ */
1508
+ function writeFail(what, status, json, url, rbacScoped = false) {
1509
+ if (status === 403 && rbacScoped) {
1510
+ console.error(' → a 403 here can also be an RBAC grant gap: the scope is checked on the token,');
1511
+ console.error(' then the verb is re-checked against THIS record. Check the pack\'s roles.yaml grants.');
1512
+ }
1513
+ die(`could not ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1514
+ }
1515
+ /**
1516
+ * Build a fields object from repeated `--set k=v`, plus an optional
1517
+ * `--fields-json` escape hatch for anything nested.
1518
+ *
1519
+ * Values are coerced as JSON scalars (`true` / `false` / `null` / a number),
1520
+ * falling back to the raw string — so `--set price=9.99` sends a number and
1521
+ * `--set name=9 Bakery` sends a string. Anything richer than a scalar belongs in
1522
+ * `--fields-json`, rather than inventing a mini-syntax here.
1523
+ */
1524
+ function fieldsFromFlags(flags) {
1525
+ const out = {};
1526
+ const raw = flags['fields-json'];
1527
+ if (typeof raw === 'string') {
1528
+ let parsed;
1529
+ try {
1530
+ parsed = JSON.parse(raw);
1531
+ }
1532
+ catch {
1533
+ die('--fields-json is not valid JSON');
1534
+ }
1535
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1536
+ die('--fields-json must be a JSON object');
1537
+ }
1538
+ Object.assign(out, parsed);
1539
+ }
1540
+ for (const pair of flagList(flags, 'set')) {
1541
+ const eq = pair.indexOf('=');
1542
+ if (eq <= 0)
1543
+ die(`--set expects key=value (got '${pair}')`);
1544
+ const key = pair.slice(0, eq);
1545
+ const val = pair.slice(eq + 1);
1546
+ if (val === 'true')
1547
+ out[key] = true;
1548
+ else if (val === 'false')
1549
+ out[key] = false;
1550
+ else if (val === 'null')
1551
+ out[key] = null;
1552
+ else if (val !== '' && !Number.isNaN(Number(val)) && /^-?\d+(\.\d+)?$/.test(val))
1553
+ out[key] = Number(val);
1554
+ else
1555
+ out[key] = val;
1556
+ }
1557
+ return out;
1558
+ }
1276
1559
  /** A progress-line label for the target workspace. The token names the tenant, so
1277
1560
  * we surface at most the project (when pinned or overridden by `--project`). */
1278
1561
  function targetLabel(t) {
1279
1562
  return t.project ? `project '${t.project}'` : 'your workspace';
1280
1563
  }
1564
+ /** `limit` + `offset` as a query string, from the two universal list flags.
1565
+ * `--offset` exists so `morePageHint`'s "next page" advice is a command the
1566
+ * author can actually run — every list route already accepted the parameter
1567
+ * (`parsePaging`), the CLI just never sent it. */
1568
+ function pagingQs(flags, defaultLimit = 50) {
1569
+ const limit = flags.limit ?? String(defaultLimit);
1570
+ const offset = flags.offset ?? '';
1571
+ return `limit=${encodeURIComponent(limit)}${offset ? `&offset=${encodeURIComponent(offset)}` : ''}`;
1572
+ }
1573
+ /**
1574
+ * Verbs that mean "write", not "an entity named this".
1575
+ *
1576
+ * `octwin records <entity>` and `octwin records note <id>` both land in `_[0]`, so
1577
+ * the two shapes genuinely collide. Reserved words win, and they are listed (not
1578
+ * guessed) so the ambiguity is documented rather than emergent — a pack that
1579
+ * declares an entity actually called `note` reaches it via `--entity note`.
1580
+ */
1581
+ const RECORD_VERBS = new Set(['create', 'patch', 'stage', 'note', 'tasks', 'task']);
1281
1582
  /** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
1282
1583
  async function cmdRecords(flags) {
1584
+ // A leading reserved word is a write. To READ an entity whose name collides
1585
+ // with one, name it with the flag and pass no positional: `octwin records --entity note`.
1586
+ if (typeof flags._[0] === 'string' && RECORD_VERBS.has(flags._[0]))
1587
+ return cmdRecordsWrite(flags);
1283
1588
  const t = resolveTarget(flags);
1284
1589
  const { url } = t;
1285
1590
  const base = `${url}/api/self/p`;
1286
- const entity = flags._[0];
1591
+ const entity = (typeof flags.entity === 'string' ? flags.entity : flags._[0]);
1287
1592
  const recordId = flags._[1];
1288
1593
  console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${targetLabel(t)} …`);
1289
1594
  if (!entity) {
@@ -1308,8 +1613,7 @@ async function cmdRecords(flags) {
1308
1613
  return;
1309
1614
  }
1310
1615
  if (!recordId) {
1311
- const limit = flags.limit ?? '50';
1312
- const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, t);
1616
+ const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&${pagingQs(flags)}`, t);
1313
1617
  if (status !== 200) {
1314
1618
  // Always show the server's reason (it names the unknown entity). Cases are
1315
1619
  // casework (worklist), not pack-declared XRM — point at the right command.
@@ -1318,12 +1622,15 @@ async function cmdRecords(flags) {
1318
1622
  }
1319
1623
  die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1320
1624
  }
1321
- const rows = (json?.records ?? []);
1322
- console.log(`${entity}: ${json?.total ?? rows.length} record(s)`);
1323
- if (rows.length === 0)
1625
+ const page = readPage(json);
1626
+ console.log(`${entity}: ${page.total ?? page.rows.length} record(s)`);
1627
+ if (page.rows.length === 0)
1324
1628
  console.log(' (none — if you expected data, mint a `records:read` token and check `octwin deploy --seed`)');
1325
- for (const r of rows)
1629
+ for (const r of page.rows)
1326
1630
  console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'}${r.stage ? ` [${r.stage}]` : ''} ${r.id}`);
1631
+ const more = morePageHint(page, `octwin records ${entity}`);
1632
+ if (more)
1633
+ console.log(more);
1327
1634
  return;
1328
1635
  }
1329
1636
  const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`, t);
@@ -1333,6 +1640,186 @@ async function cmdRecords(flags) {
1333
1640
  die(`could not read record (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1334
1641
  console.log(JSON.stringify(json?.record ?? json, null, 2));
1335
1642
  }
1643
+ /**
1644
+ * The write half of `octwin records` — create / patch / stage / note, plus tasks.
1645
+ *
1646
+ * Every verb here needs `records:write` (tasks need the `tasks` plan feature, not
1647
+ * `records`), and every one is re-checked by RBAC against the specific record, so
1648
+ * failures route through `writeFail(..., rbacScoped)`.
1649
+ */
1650
+ async function cmdRecordsWrite(flags) {
1651
+ const t = resolveTarget(flags);
1652
+ const { url } = t;
1653
+ const base = `${url}/api/self/p`;
1654
+ const verb = flags._[0];
1655
+ const arg = flags._[1];
1656
+ const readBack = (id) => console.log(`\nRead it back: octwin records --entity <entity> ${id}`);
1657
+ if (verb === 'create') {
1658
+ const entity = arg ?? die('usage: octwin records create <entity> --set field=value …');
1659
+ const fields = fieldsFromFlags(flags);
1660
+ const body = { entity, fields };
1661
+ if (typeof flags.stage === 'string')
1662
+ body.stage = flags.stage;
1663
+ if (typeof flags.contact === 'string')
1664
+ body.contact_id = flags.contact;
1665
+ console.log(`→ Creating a ${entity} record in ${targetLabel(t)} …`);
1666
+ const { status, json } = await apiSend('POST', `${base}/xrm/records`, body, t);
1667
+ if (status !== 200 && status !== 201)
1668
+ writeFail(`create the ${entity} record`, status, json, url, true);
1669
+ if (json?.has_xrm === false)
1670
+ die('this pack declares no XRM entities');
1671
+ const rec = json?.record ?? {};
1672
+ // 200 = the dedupe key matched an existing row; 201 = genuinely new. Saying
1673
+ // "created" for a match would misreport what the pack's `dedupe_by` did.
1674
+ console.log(status === 201
1675
+ ? `✓ Created #${rec.record_number ?? '?'} ${rec.id ?? ''}`
1676
+ : `✓ Matched an EXISTING record (the entity's dedupe key hit) — #${rec.record_number ?? '?'} ${rec.id ?? ''}`);
1677
+ if (rec.id)
1678
+ readBack(rec.id);
1679
+ return;
1680
+ }
1681
+ if (verb === 'patch') {
1682
+ const id = arg ?? die('usage: octwin records patch <recordId> --entity <entity> --set field=value …');
1683
+ // The route requires `entity` even on an update — it resolves the validator
1684
+ // from it. A patch without it 400s server-side, so say it here instead.
1685
+ const entity = typeof flags.entity === 'string' ? flags.entity
1686
+ : die('octwin records patch needs --entity <entity> (the route resolves the field validator from it)');
1687
+ const fields = fieldsFromFlags(flags);
1688
+ if (Object.keys(fields).length === 0)
1689
+ die('nothing to patch — pass --set field=value (or --fields-json)');
1690
+ console.log(`→ Patching ${entity} ${id} in ${targetLabel(t)} …`);
1691
+ const { status, json } = await apiSend('PATCH', `${base}/xrm/records/${encodeURIComponent(id)}`, { entity, fields }, t);
1692
+ if (status !== 200)
1693
+ writeFail(`patch record ${id}`, status, json, url, true);
1694
+ console.log(`✓ Patched #${json?.record?.record_number ?? '?'}`);
1695
+ readBack(id);
1696
+ return;
1697
+ }
1698
+ if (verb === 'stage') {
1699
+ const id = arg ?? die('usage: octwin records stage <recordId> --to <stage> [--note "..."]');
1700
+ const to = typeof flags.to === 'string' ? flags.to : die('octwin records stage needs --to <stage>');
1701
+ const body = { to_stage: to };
1702
+ if (typeof flags.note === 'string')
1703
+ body.note = flags.note;
1704
+ console.log(`→ Moving ${id} to '${to}' in ${targetLabel(t)} …`);
1705
+ const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/stage`, body, t);
1706
+ if (status === 400 && Array.isArray(json?.allowed)) {
1707
+ // The route returns the legal targets on an illegal move — the single most
1708
+ // useful thing to show, so don't bury it in the generic error line.
1709
+ console.error(`✗ '${to}' is not a legal move from this record's stage.`);
1710
+ console.error(` → allowed: ${json.allowed.join(', ') || '(none — terminal stage)'}`);
1711
+ process.exit(1);
1712
+ }
1713
+ if (status !== 200)
1714
+ writeFail(`move record ${id} to '${to}'`, status, json, url, true);
1715
+ console.log(`✓ #${json?.record?.record_number ?? '?'} is now at '${json?.record?.stage ?? to}'`);
1716
+ return;
1717
+ }
1718
+ if (verb === 'note') {
1719
+ const id = arg ?? die('usage: octwin records note <recordId> "the note text"');
1720
+ const note = flags._[2] ?? die('octwin records note needs the note text as the last argument');
1721
+ console.log(`→ Adding a note to ${id} in ${targetLabel(t)} …`);
1722
+ const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/note`, { note }, t);
1723
+ if (status !== 200)
1724
+ writeFail(`note record ${id}`, status, json, url, true);
1725
+ console.log('✓ Note added to the record timeline.');
1726
+ return;
1727
+ }
1728
+ if (verb === 'tasks') {
1729
+ console.log(`→ Reading open tasks from ${targetLabel(t)} …`);
1730
+ const { status, json } = await apiGet(`${base}/xrm/tasks?${pagingQs(flags)}`, t);
1731
+ if (status !== 200)
1732
+ writeFail('read tasks', status, json, url);
1733
+ if (json?.has_xrm === false) {
1734
+ console.log('This pack declares no XRM entities.');
1735
+ return;
1736
+ }
1737
+ const page = readPage(json);
1738
+ console.log(`Tasks: ${page.total ?? page.rows.length}`);
1739
+ if (page.rows.length === 0)
1740
+ console.log(' (none open)');
1741
+ for (const k of page.rows) {
1742
+ console.log(` ${k.title ?? '(untitled)'}${k.due_at ? ` due:${k.due_at}` : ''}${k.status ? ` [${k.status}]` : ''} ${k.id}`);
1743
+ }
1744
+ const more = morePageHint(page, 'octwin records tasks');
1745
+ if (more)
1746
+ console.log(more);
1747
+ console.log('\nClose one: octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]');
1748
+ return;
1749
+ }
1750
+ if (verb === 'task') {
1751
+ if (arg !== 'complete')
1752
+ die('usage: octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]');
1753
+ const id = flags._[2] ?? die('octwin records task complete needs a <taskId>');
1754
+ const outcome = typeof flags.outcome === 'string' ? flags.outcome : 'done';
1755
+ if (outcome !== 'done' && outcome !== 'cancelled')
1756
+ die(`--outcome must be 'done' or 'cancelled' (got '${outcome}')`);
1757
+ const body = { outcome };
1758
+ if (typeof flags.note === 'string')
1759
+ body.note = flags.note;
1760
+ console.log(`→ Closing task ${id} as '${outcome}' …`);
1761
+ const { status, json } = await apiSend('POST', `${base}/xrm/tasks/${encodeURIComponent(id)}/complete`, body, t);
1762
+ if (status === 404)
1763
+ die(`task '${id}' not found, or already closed`);
1764
+ if (status !== 200)
1765
+ writeFail(`complete task ${id}`, status, json, url, true);
1766
+ console.log(`✓ Task closed (${outcome}).`);
1767
+ return;
1768
+ }
1769
+ die(`unknown records verb '${verb}' — one of: ${[...RECORD_VERBS].join(', ')}`);
1770
+ }
1771
+ /**
1772
+ * `octwin feedback [--dir .]` — submit the pack's `FEEDBACK.md` to the platform.
1773
+ *
1774
+ * The authoring skill's last step asks for a report bucketed by owner (CLI /
1775
+ * platform / KB). It used to end there: delivery was copy-paste into a chat, so a
1776
+ * report only counted if the author happened to hand it over.
1777
+ *
1778
+ * Attaches the two facts that decide triage and that nobody remembers to state —
1779
+ * the CLI version, and the `content_hash` of the capability reference the author
1780
+ * actually pulled. Most field reports so far were either already fixed in a newer
1781
+ * CLI or written against a stale KB, and both are one line each here.
1782
+ */
1783
+ async function cmdFeedback(flags) {
1784
+ const packDir = resolve(flags.dir ?? '.');
1785
+ const t = resolveTarget(flags);
1786
+ const { url } = t;
1787
+ const reportPath = join(packDir, 'FEEDBACK.md');
1788
+ if (!existsSync(reportPath)) {
1789
+ die(`no FEEDBACK.md in ${packDir}\n`
1790
+ + ' → the octwin-pack skill writes one in Step 4 (Report your authoring experience).\n'
1791
+ + ' Group findings by owner — A · CLI, B · Platform, C · Skill/KB — then run this again.');
1792
+ }
1793
+ const report = readFileSync(reportPath, 'utf8');
1794
+ if (!report.trim())
1795
+ die('FEEDBACK.md is empty — nothing to submit');
1796
+ // Pack identity from the manifest, not from the report's prose: the metadata
1797
+ // block is a convention the skill owns and an author may reword it.
1798
+ const manifestPath = join(packDir, 'manifest.yaml');
1799
+ if (!existsSync(manifestPath))
1800
+ die('no manifest.yaml in the pack directory (run from your pack dir or pass --dir)');
1801
+ const doc = parseYaml(readFileSync(manifestPath, 'utf8'));
1802
+ const packId = typeof doc?.id === 'string' ? doc.id : die('manifest.yaml must declare a string `id`');
1803
+ const packVersion = typeof doc?.version === 'string' ? doc.version : undefined;
1804
+ const kbHash = readLocalKb(packDir)?.content_hash ?? undefined;
1805
+ console.log(`→ Submitting ${Math.round(Buffer.byteLength(report, 'utf8') / 1024)}KB of feedback on ${packId} to ${targetLabel(t)} …`);
1806
+ const { status, json } = await apiSend('POST', `${url}/api/self/p/packs/feedback`, {
1807
+ pack_id: packId,
1808
+ ...(packVersion ? { pack_version: packVersion } : {}),
1809
+ report_md: report,
1810
+ cli_version: VERSION,
1811
+ ...(kbHash ? { kb_content_hash: kbHash } : {}),
1812
+ }, t);
1813
+ if (status !== 201 && status !== 200)
1814
+ writeFail('submit feedback', status, json, url);
1815
+ console.log('✓ Thanks — your report reached the platform team.');
1816
+ if (!kbHash) {
1817
+ // Without it, triage cannot tell "the platform is wrong" from "you were
1818
+ // reading a stale reference", which is the single most common answer.
1819
+ console.log(' ⓘ no local capability reference found, so the report carries no KB version.');
1820
+ console.log(' Pull it before your next session: octwin platform-kb');
1821
+ }
1822
+ }
1336
1823
  /** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
1337
1824
  * or show one's event timeline (full text + the renders each turn produced). */
1338
1825
  async function cmdLogs(flags) {
@@ -1807,9 +2294,106 @@ async function cmdMedia(flags) {
1807
2294
  console.log(` saved → ${out}`);
1808
2295
  console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
1809
2296
  }
2297
+ /** Reserved leading words on `octwin cases` — see `RECORD_VERBS` for the rule. */
2298
+ const CASE_VERBS = new Set(['assign', 'note', 'transition', 'decide']);
2299
+ /**
2300
+ * The write half of `octwin cases` — assign / note / transition / decide.
2301
+ *
2302
+ * `decide --dry-run` routes to the PREVIEW endpoint, which sits behind `cases:read`
2303
+ * rather than `cases:write`: it renders the customer-facing copy and the resulting
2304
+ * status without committing. That makes "show me what this disposition would do"
2305
+ * safe to run with a read-only token, which is exactly when an author wants it.
2306
+ */
2307
+ async function cmdCasesWrite(flags) {
2308
+ const t = resolveTarget(flags);
2309
+ const { url } = t;
2310
+ const base = `${url}/api/self/p`;
2311
+ const verb = flags._[0];
2312
+ const id = flags._[1] ?? die(`usage: octwin cases ${verb} <caseId> …`);
2313
+ const readBack = () => console.log(`\nRead it back: octwin cases ${id}`);
2314
+ if (verb === 'assign') {
2315
+ // `--to none` unassigns (the route takes null); anything else must carry the
2316
+ // principal kind, because a bare uuid cannot say user-or-team.
2317
+ const to = typeof flags.to === 'string' ? flags.to
2318
+ : die('usage: octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none');
2319
+ const assignee = to === 'none' ? null : to;
2320
+ if (assignee !== null && !/^(user|team):/.test(assignee)) {
2321
+ die(`--to must be 'user:<uuid>', 'team:<uuid>' or 'none' (got '${to}')`);
2322
+ }
2323
+ console.log(`→ ${assignee === null ? 'Unassigning' : `Assigning to ${assignee}`} case ${id} …`);
2324
+ const { status, json } = await apiSend('PATCH', `${base}/cases/${encodeURIComponent(id)}/assign`, { assignee }, t);
2325
+ if (status !== 200)
2326
+ writeFail(`assign case ${id}`, status, json, url, true);
2327
+ console.log(assignee === null ? '✓ Unassigned.' : `✓ Assigned to ${json?.assignee ?? assignee}.`);
2328
+ return;
2329
+ }
2330
+ if (verb === 'note') {
2331
+ const note = flags._[2] ?? die('usage: octwin cases note <caseId> "the note text"');
2332
+ console.log(`→ Adding a note to case ${id} …`);
2333
+ const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/note`, { note }, t);
2334
+ if (status !== 200)
2335
+ writeFail(`note case ${id}`, status, json, url, true);
2336
+ console.log('✓ Note added to the case timeline.');
2337
+ readBack();
2338
+ return;
2339
+ }
2340
+ if (verb === 'transition') {
2341
+ const to = typeof flags.to === 'string' ? flags.to
2342
+ : die('usage: octwin cases transition <caseId> --to <status> [--note "..."]');
2343
+ const body = { to_status: to };
2344
+ if (typeof flags.note === 'string')
2345
+ body.note = flags.note;
2346
+ console.log(`→ Moving case ${id} to '${to}' …`);
2347
+ const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/transition`, body, t);
2348
+ if (status !== 200) {
2349
+ // The case detail read carries the legal targets; point at it rather than
2350
+ // leaving the author to guess the vocabulary.
2351
+ if (status === 400)
2352
+ console.error(` → legal targets for this case: octwin cases ${id} (see its workflow)`);
2353
+ writeFail(`move case ${id} to '${to}'`, status, json, url, true);
2354
+ }
2355
+ console.log(`✓ Case is now '${json?.case?.status ?? to}'.`);
2356
+ return;
2357
+ }
2358
+ // decide
2359
+ const action = typeof flags.action === 'string' ? flags.action
2360
+ : die('usage: octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]');
2361
+ const params = {};
2362
+ for (const pair of flagList(flags, 'param')) {
2363
+ const eq = pair.indexOf('=');
2364
+ if (eq <= 0)
2365
+ die(`--param expects key=value (got '${pair}')`);
2366
+ params[pair.slice(0, eq)] = pair.slice(eq + 1);
2367
+ }
2368
+ const dryRun = flags['dry-run'] === true;
2369
+ const body = { action, ...(Object.keys(params).length ? { params } : {}) };
2370
+ if (!dryRun && typeof flags.note === 'string')
2371
+ body.internal_note = flags.note;
2372
+ console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on case ${id} …`);
2373
+ const endpoint = `${base}/cases/${encodeURIComponent(id)}/decision${dryRun ? '/preview' : ''}`;
2374
+ const { status, json } = await apiSend('POST', endpoint, body, t);
2375
+ if (status === 404)
2376
+ die(`case '${id}' not found`);
2377
+ if (status !== 200) {
2378
+ console.error(` → the case's applicable actions are listed by: octwin cases ${id}`);
2379
+ writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on case ${id}`, status, json, url, true);
2380
+ }
2381
+ if (dryRun) {
2382
+ console.log('Preview (nothing was committed):');
2383
+ console.log(JSON.stringify(json, null, 2));
2384
+ return;
2385
+ }
2386
+ console.log(`✓ Applied '${action}' — case is now '${json?.case?.status ?? '?'}'.`);
2387
+ // `notified` is the customer-facing half; silence here usually means the
2388
+ // disposition had no message template, which is easy to mistake for a failure.
2389
+ console.log(json?.notified ? ' ✓ the customer was notified.' : ' ⓘ no customer notification was sent by this action.');
2390
+ readBack();
2391
+ }
1810
2392
  /** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
1811
2393
  * the aggregate inbox, one case + its timeline, or the queue list. */
1812
2394
  async function cmdCases(flags) {
2395
+ if (typeof flags._[0] === 'string' && CASE_VERBS.has(flags._[0]))
2396
+ return cmdCasesWrite(flags);
1813
2397
  const t = resolveTarget(flags);
1814
2398
  const { url } = t;
1815
2399
  const base = `${url}/api/self/p`;
@@ -1841,22 +2425,24 @@ async function cmdCases(flags) {
1841
2425
  return;
1842
2426
  }
1843
2427
  if (!caseId) {
1844
- const limit = flags.limit ?? '50';
1845
- const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, t);
2428
+ const { status, json } = await apiGet(`${base}/cases?${pagingQs(flags)}`, t);
1846
2429
  if (status !== 200)
1847
2430
  caseFail('cases', status, json);
1848
2431
  if (asJson) {
1849
2432
  console.log(JSON.stringify(json, null, 2));
1850
2433
  return;
1851
2434
  }
1852
- const rows = (json?.cases ?? []);
1853
- console.log(`Cases in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
1854
- if (rows.length === 0)
2435
+ const page = readPage(json);
2436
+ console.log(`Cases in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
2437
+ if (page.rows.length === 0)
1855
2438
  console.log(' (none)');
1856
- for (const c of rows) {
2439
+ for (const c of page.rows) {
1857
2440
  const sla = c.sla_due_at ? ` sla:${c.sla_due_at}` : '';
1858
2441
  console.log(` #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}${c.queue_key ? ` q:${c.queue_key}` : ''}${sla} ${c.id}`);
1859
2442
  }
2443
+ const more = morePageHint(page, 'octwin cases');
2444
+ if (more)
2445
+ console.log(more);
1860
2446
  console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
1861
2447
  return;
1862
2448
  }
@@ -1951,7 +2537,245 @@ function printGoverned(label, g) {
1951
2537
  /** `octwin agents [agentRef] [--prompt] [--json]` — the agent roster with the
1952
2538
  * EFFECTIVE model/memory settings and which layer won, plus (`--prompt`) the exact
1953
2539
  * system prompt the LLM sees for this project. Needs an `agents:read` token. */
2540
+ /**
2541
+ * `octwin agents set <ref> …` — the per-project agent override row.
2542
+ *
2543
+ * Writes only what was passed, so an unmentioned setting is left alone rather than
2544
+ * reset to a default. `--enable-tool` / `--disable-tool` edit `config_json.tools`,
2545
+ * a `{ toolId: boolean }` map where absent means ON — so disabling is the only
2546
+ * thing that needs recording, and the map is read-modify-written to avoid dropping
2547
+ * a sibling entry.
2548
+ */
2549
+ async function cmdAgentsWrite(flags) {
2550
+ const t = resolveTarget(flags);
2551
+ const { url } = t;
2552
+ const base = `${url}/api/self/p/agents`;
2553
+ const ref = flags._[1]
2554
+ ?? die('usage: octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t] [--enabled true|false]');
2555
+ const patch = {};
2556
+ if (typeof flags.model === 'string')
2557
+ patch.model = flags.model;
2558
+ if (typeof flags.overlay === 'string')
2559
+ patch.instructions_overlay = flags.overlay === 'none' ? null : flags.overlay;
2560
+ if (typeof flags.enabled === 'string') {
2561
+ if (flags.enabled !== 'true' && flags.enabled !== 'false')
2562
+ die("--enabled must be 'true' or 'false'");
2563
+ patch.enabled = flags.enabled === 'true';
2564
+ }
2565
+ const on = flagList(flags, 'enable-tool');
2566
+ const off = flagList(flags, 'disable-tool');
2567
+ if (on.length || off.length) {
2568
+ // Read first: `config_json` is replaced wholesale by the route, so a blind
2569
+ // write would drop every tool decision not named on this command line.
2570
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
2571
+ if (status === 404)
2572
+ die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
2573
+ if (status !== 200)
2574
+ writeFail(`read agent ${ref}`, status, json, url);
2575
+ const cfg = { ...(json?.agent?.config_json ?? json?.config_json ?? {}) };
2576
+ const tools = { ...(cfg.tools ?? {}) };
2577
+ const known = (json?.agent?.available_tools ?? json?.available_tools);
2578
+ for (const id of [...on, ...off]) {
2579
+ if (Array.isArray(known) && known.length && !known.includes(id)) {
2580
+ die(`'${id}' is not a tool on ${ref} — available: ${known.join(', ')}`);
2581
+ }
2582
+ }
2583
+ for (const id of on)
2584
+ tools[id] = true;
2585
+ for (const id of off)
2586
+ tools[id] = false;
2587
+ cfg.tools = tools;
2588
+ patch.config_json = cfg;
2589
+ }
2590
+ if (Object.keys(patch).length === 0) {
2591
+ die('nothing to change — pass --model, --enabled, --overlay, --enable-tool or --disable-tool');
2592
+ }
2593
+ console.log(`→ Updating agent ${ref} in ${targetLabel(t)} …`);
2594
+ const { status, json } = await apiSend('PATCH', `${base}/${encodeURIComponent(ref)}`, patch, t);
2595
+ if (status === 404)
2596
+ die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
2597
+ if (status === 403 && patch.model !== undefined) {
2598
+ die(`this workspace does not expose model overrides — drop --model (the platform default governs)${errDetail(json)}`);
2599
+ }
2600
+ if (status !== 200)
2601
+ writeFail(`update agent ${ref}`, status, json, url);
2602
+ console.log(`✓ Updated ${ref}.`);
2603
+ console.log(`\nRead it back (and see WHICH layer won): octwin agents ${ref}`);
2604
+ }
2605
+ /**
2606
+ * `octwin projects` — which `--project <slug>` values this token can actually name.
2607
+ *
2608
+ * Every project-scoped command takes a `--project` slug, and until now nothing
2609
+ * printed the list: an author whose token was not pinned had to guess, and a wrong
2610
+ * guess 404s identically to a project that exists but has no install. Tenant-scoped
2611
+ * (`/api/self/t/`), unlike `agents` — the list is a property of the workspace.
2612
+ */
2613
+ async function cmdProjects(flags) {
2614
+ if (flags._[0] === 'create')
2615
+ return cmdProjectsCreate(flags);
2616
+ if (flags._[0] === 'rm')
2617
+ return cmdProjectsRm(flags);
2618
+ const t = resolveTarget(flags);
2619
+ const { url } = t;
2620
+ const asJson = flags.json === true;
2621
+ const archived = flags.archived === true;
2622
+ if (!asJson)
2623
+ console.log(`→ Reading projects from ${targetLabel(t)} …`);
2624
+ const qs = archived ? '?include_archived=1' : '';
2625
+ const { status, json } = await apiGet(`${url}/api/self/t/projects${qs}`, t);
2626
+ if (status !== 200)
2627
+ die(`could not read projects (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
2628
+ if (asJson) {
2629
+ console.log(JSON.stringify(json, null, 2));
2630
+ return;
2631
+ }
2632
+ const projects = (json?.projects ?? []);
2633
+ if (projects.length === 0) {
2634
+ console.log(archived
2635
+ ? 'No projects at all — create one in the console (Workspace → Projects).'
2636
+ : 'No active projects. Try `octwin projects --archived` before creating one.');
2637
+ return;
2638
+ }
2639
+ // The route already computes the plan cap; inventing a second "N of M" here
2640
+ // would drift from the 402 the POST handler actually enforces.
2641
+ const max = json?.limits?.max_projects ?? null;
2642
+ const plan = json?.limits?.plan_label ? ` · ${json.limits.plan_label} plan` : '';
2643
+ console.log(`Projects in ${json?.tenant?.slug ?? targetLabel(t)} — ${projects.length}${max ? ` of ${max}` : ''}${plan}:`);
2644
+ for (const p of projects) {
2645
+ const flags_ = [];
2646
+ if (p.status && p.status !== 'active')
2647
+ flags_.push(p.status.toUpperCase());
2648
+ console.log(` ${p.slug}${flags_.length ? ` [${flags_.join(', ')}]` : ''}${p.name ? ` "${p.name}"` : ''}`);
2649
+ }
2650
+ console.log('\nUse one as: octwin deploy --project <slug>');
2651
+ if (!archived)
2652
+ console.log('Archived too: octwin projects --archived');
2653
+ console.log('New one: octwin projects create "<name>"');
2654
+ }
2655
+ /**
2656
+ * `octwin projects create "<name>" [--slug <slug>] [--pack <packId>]`
2657
+ *
2658
+ * The missing half of the deploy loop. `octwin deploy` has always needed a project
2659
+ * that already exists, and the CLI could only LIST them — so standing up a throwaway
2660
+ * end-to-end deployment meant opening the console or asking an operator. With this,
2661
+ * a full disposable environment is two commands:
2662
+ *
2663
+ * octwin projects create "Scratch" # → slug `scratch`
2664
+ * octwin deploy --project scratch --seed # publish + install + demo data
2665
+ * octwin chat "hi" --project scratch # talk to it
2666
+ * octwin projects rm scratch --yes # throw it away
2667
+ *
2668
+ * A demo is deliberately NOT a special kind of thing — it is an ordinary project in
2669
+ * the developer's own workspace, so it inherits their plan, entitlements, RBAC and
2670
+ * teardown with no bespoke lifecycle to keep honest.
2671
+ *
2672
+ * `packs: []` is the default because the very next step is normally `octwin deploy`,
2673
+ * which publishes the working tree AND installs it. `--pack` is for an ALREADY
2674
+ * published pack (it resolves through `pack_registry` and fails fast if absent).
2675
+ */
2676
+ async function cmdProjectsCreate(flags) {
2677
+ // Argument check BEFORE `resolveTarget`, so a missing name reports the usage line
2678
+ // rather than "no platform url" — an argument mistake must not be masked by a
2679
+ // config one the author may not even have.
2680
+ const name = flags._[1];
2681
+ if (!name)
2682
+ die('usage: octwin projects create "<name>" [--slug <slug>] [--pack <packId>]');
2683
+ const t = resolveTarget(flags);
2684
+ const { url } = t;
2685
+ // The Project URL is DERIVED from the name and uniquified server-side unless the
2686
+ // caller pins one — same contract the console's create form uses, so the two
2687
+ // cannot disagree about what slug a given name produces.
2688
+ const body = { name, packs: flags.pack ? [flags.pack] : [] };
2689
+ if (typeof flags.slug === 'string')
2690
+ body.slug = flags.slug;
2691
+ console.log(`→ Creating project "${name}" in ${targetLabel(t)} …`);
2692
+ const { status, json } = await apiSend('POST', `${url}/api/self/t/projects`, body, t);
2693
+ // 402 is the plan cap, and it is the ONE failure here with a non-obvious fix, so it
2694
+ // gets the server's own sentence rather than a generic write failure.
2695
+ if (status === 402)
2696
+ die(`${json?.error ?? 'project limit reached'} — free the slot with \`octwin projects rm <slug> --yes\`, or upgrade the plan.`);
2697
+ if (status !== 200 && status !== 201)
2698
+ writeFail(`create project "${name}"`, status, json, url);
2699
+ if (flags.json === true) {
2700
+ console.log(JSON.stringify(json, null, 2));
2701
+ return;
2702
+ }
2703
+ const slug = json?.slug ?? flags.slug ?? '(unknown)';
2704
+ const installed = (json?.installed_packs ?? []);
2705
+ console.log(`✓ Project created — ${slug}`);
2706
+ for (const p of installed)
2707
+ console.log(` installed ${p.pack_id}@${p.version}`);
2708
+ console.log('\nNext:');
2709
+ console.log(` octwin deploy --project ${slug} --seed`);
2710
+ console.log(` octwin chat "hi" --project ${slug}`);
2711
+ }
2712
+ /**
2713
+ * `octwin projects rm <slug> --yes`
2714
+ *
2715
+ * HARD delete — the row and everything the FK graph cascades from it (conversations,
2716
+ * contacts, records, installs, webhooks). Not the archive verb; there is no undo.
2717
+ *
2718
+ * `--yes` is required rather than prompted because the CLI is non-interactive by
2719
+ * design (it runs under `npx`, in scripts and in CI, where a prompt reads EOF and a
2720
+ * "safe" default would be a lie). Without it this prints the same impact preview the
2721
+ * console's confirm dialog shows — derived from `pg_constraint`, not a hand-written
2722
+ * list — and stops. That makes the dry run the DEFAULT, which is the right way round
2723
+ * for an irreversible verb.
2724
+ */
2725
+ async function cmdProjectsRm(flags) {
2726
+ const slug = flags._[1];
2727
+ if (!slug)
2728
+ die('usage: octwin projects rm <slug> --yes (omit --yes to preview what it destroys)');
2729
+ const t = resolveTarget(flags);
2730
+ const { url } = t;
2731
+ const preview = await apiGet(`${url}/api/self/t/projects/${encodeURIComponent(slug)}/preview-hard-delete`, t);
2732
+ if (preview.status === 404)
2733
+ die(`no project '${slug}' in ${targetLabel(t)} — \`octwin projects\` lists them`);
2734
+ if (preview.status !== 200)
2735
+ die(`could not preview the delete (HTTP ${preview.status})${errDetail(preview.json)}${authFailureDetail(preview.status, url)}`);
2736
+ if (flags.json === true && flags.yes !== true) {
2737
+ console.log(JSON.stringify(preview.json, null, 2));
2738
+ return;
2739
+ }
2740
+ // Shapes come from `HardDeletePreview` (routes/_hard-delete-preview.ts) — the same
2741
+ // payload the console's confirm dialog renders, so the two can't disagree about
2742
+ // what a delete costs.
2743
+ const tables = (preview.json?.tables ?? []);
2744
+ const hits = tables.filter(r => r.count > 0);
2745
+ const totals = preview.json?.totals ?? {};
2746
+ console.log(`Deleting project ${slug} from ${targetLabel(t)} destroys:`);
2747
+ if (hits.length === 0)
2748
+ console.log(' (nothing — the project has no rows yet)');
2749
+ for (const r of hits) {
2750
+ const mark = r.disposition === 'cascade' ? '' : ` [${r.disposition}]`;
2751
+ console.log(` ${r.count}${r.capped ? '+' : ''}\t${r.schema}.${r.table}${mark}`);
2752
+ }
2753
+ if (hits.length > 0) {
2754
+ console.log(` — ${totals.rows_deleted}${totals.rows_deleted_capped ? '+' : ''} rows across ${totals.tables_affected} tables`);
2755
+ }
2756
+ // Side effects no FK walk can see (storage blobs, agent memory, Meta registrations).
2757
+ // Anything not `deleted` is what SURVIVES the delete — the part worth reading.
2758
+ const residue = (preview.json?.residue ?? []);
2759
+ const surviving = residue.filter(r => r.disposition !== 'deleted');
2760
+ if (surviving.length > 0) {
2761
+ console.log('\nNot removed by the cascade:');
2762
+ for (const r of surviving)
2763
+ console.log(` [${r.disposition}] ${r.label}${r.count != null ? ` (${r.count})` : ''} — ${r.detail}`);
2764
+ }
2765
+ if (totals.blocked > 0)
2766
+ console.log(`\n! ${totals.blocked} table(s) would BLOCK this delete.`);
2767
+ if (flags.yes !== true) {
2768
+ console.log('\nNothing was deleted. Re-run with --yes to go through with it.');
2769
+ return;
2770
+ }
2771
+ const { status, json } = await apiSend('DELETE', `${url}/api/self/t/projects/${encodeURIComponent(slug)}/hard`, undefined, t);
2772
+ if (status !== 200 && status !== 204)
2773
+ writeFail(`delete project '${slug}'`, status, json, url);
2774
+ console.log(`\n✓ Deleted ${slug}.`);
2775
+ }
1954
2776
  async function cmdAgents(flags) {
2777
+ if (flags._[0] === 'set')
2778
+ return cmdAgentsWrite(flags);
1955
2779
  const t = resolveTarget(flags);
1956
2780
  const { url } = t;
1957
2781
  const base = `${url}/api/self/p/agents`;
@@ -2048,7 +2872,77 @@ function printPaymentNote(paymentStatus) {
2048
2872
  /** `octwin orders [referenceId] [--status s] [--payment p] [--limit n] [--json]` —
2049
2873
  * the orders a conversation created: money breakdown, payment state, allowed
2050
2874
  * transitions. Needs an `orders:read` token + the `orders` plan feature. */
2875
+ /** Reserved leading words on `octwin orders`. A reference_id is opaque but never these. */
2876
+ const ORDER_VERBS = new Set(['transition', 'refund']);
2877
+ /**
2878
+ * The write half of `octwin orders` — fulfilment transitions and refunds.
2879
+ *
2880
+ * `payment_status` is deliberately NOT settable: the forward payment lifecycle is
2881
+ * webhook-owned, which is why `pending` on a gateway-less workspace is expected
2882
+ * rather than a bug (`printPaymentNote`).
2883
+ */
2884
+ async function cmdOrdersWrite(flags) {
2885
+ const t = resolveTarget(flags);
2886
+ const { url } = t;
2887
+ const base = `${url}/api/self/p/orders`;
2888
+ const verb = flags._[0];
2889
+ const ref = flags._[1]
2890
+ ?? die(`usage: octwin orders ${verb} <reference_id> … (the opaque reference_id, not the #number)`);
2891
+ if (verb === 'transition') {
2892
+ const to = typeof flags.to === 'string' ? flags.to
2893
+ : die('usage: octwin orders transition <reference_id> --to <status>');
2894
+ console.log(`→ Moving order ${ref} to '${to}' …`);
2895
+ const { status, json } = await apiSend('POST', `${base}/${encodeURIComponent(ref)}/transition`, { to_status: to }, t);
2896
+ if (status === 404)
2897
+ die(`order '${ref}' not found (pass the opaque reference_id, not the #number)`);
2898
+ if (status === 409) {
2899
+ console.error(`✗ '${to}' is not a legal move for this order.`);
2900
+ if (Array.isArray(json?.transitions))
2901
+ console.error(` → allowed: ${json.transitions.join(', ') || '(none)'}`);
2902
+ else
2903
+ console.error(` → see the allowed set: octwin orders ${ref}`);
2904
+ process.exit(1);
2905
+ }
2906
+ if (status !== 200)
2907
+ writeFail(`move order ${ref} to '${to}'`, status, json, url);
2908
+ console.log(`✓ Order is now '${json?.order?.status ?? to}'.`);
2909
+ return;
2910
+ }
2911
+ // refund — irreversible, and money. `--force` rather than a prompt: the CLI is
2912
+ // non-interactive by construction (same reasoning as `octwin pull --force`).
2913
+ if (flags.force !== true) {
2914
+ die(`refunding is irreversible and moves money — re-run with --force:\n octwin orders refund ${ref} --force`);
2915
+ }
2916
+ const body = {};
2917
+ if (typeof flags.reason === 'string')
2918
+ body.reason = flags.reason;
2919
+ if (flags['mark-returned'] === true)
2920
+ body.mark_returned = true;
2921
+ console.log(`→ Refunding order ${ref} …`);
2922
+ const { status, json } = await apiSend('POST', `${base}/${encodeURIComponent(ref)}/refund`, body, t);
2923
+ if (status === 404)
2924
+ die(`order '${ref}' not found`);
2925
+ if (status === 409)
2926
+ die(`order '${ref}' cannot be refunded — only a payment in 'captured' state can be${errDetail(json)}`);
2927
+ if (status !== 200)
2928
+ writeFail(`refund order ${ref}`, status, json, url);
2929
+ // THE trap: the route answers 200 even when the gateway REFUSED — the verdict
2930
+ // is in `gateway`. Reporting the 200 as success would tell an operator money
2931
+ // moved when it did not, so read the gateway result and exit non-zero on refusal.
2932
+ const gw = json?.gateway;
2933
+ const refused = gw != null && (gw.ok === false || gw.status === 'failed' || gw.status === 'error');
2934
+ console.log(` order status : ${json?.order?.status ?? '?'} / ${json?.order?.payment_status ?? '?'}`);
2935
+ if (gw != null)
2936
+ console.log(` gateway : ${gw.status ?? (gw.ok === false ? 'failed' : 'ok')}${gw.error || gw.message ? ` — ${gw.error ?? gw.message}` : ''}`);
2937
+ if (refused) {
2938
+ console.error('\n✗ the PAYMENT GATEWAY refused the refund — the order was updated but no money moved.');
2939
+ process.exit(1);
2940
+ }
2941
+ console.log('✓ Refund accepted.');
2942
+ }
2051
2943
  async function cmdOrders(flags) {
2944
+ if (typeof flags._[0] === 'string' && ORDER_VERBS.has(flags._[0]))
2945
+ return cmdOrdersWrite(flags);
2052
2946
  const t = resolveTarget(flags);
2053
2947
  const { url } = t;
2054
2948
  const base = `${url}/api/self/p/orders`;
@@ -2058,6 +2952,8 @@ async function cmdOrders(flags) {
2058
2952
  console.log(`→ Reading ${referenceId ? `order ${referenceId}` : 'orders'} from ${targetLabel(t)} …`);
2059
2953
  if (!referenceId) {
2060
2954
  const q = new URLSearchParams({ limit: flags.limit ?? '50' });
2955
+ if (typeof flags.offset === 'string')
2956
+ q.set('offset', flags.offset);
2061
2957
  if (typeof flags.status === 'string')
2062
2958
  q.set('status', flags.status);
2063
2959
  if (typeof flags.payment === 'string')
@@ -2069,14 +2965,17 @@ async function cmdOrders(flags) {
2069
2965
  console.log(JSON.stringify(json, null, 2));
2070
2966
  return;
2071
2967
  }
2072
- const rows = (json?.orders ?? []);
2073
- console.log(`Orders in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
2074
- if (rows.length === 0)
2968
+ const page = readPage(json);
2969
+ console.log(`Orders in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
2970
+ if (page.rows.length === 0)
2075
2971
  console.log(' (none — drive a cart to `cart_submit` with `octwin chat`, or seed demo data)');
2076
- for (const o of rows) {
2972
+ for (const o of page.rows) {
2077
2973
  const who = o.contact?.channel_contact_handle ?? o.contact?.display_name ?? '—';
2078
2974
  console.log(` #${o.record_number} ${o.status}/${o.payment_status} ${fmtMinor(o.total_minor, o.currency)} ${who} ${o.reference_id}`);
2079
2975
  }
2976
+ const more = morePageHint(page, 'octwin orders');
2977
+ if (more)
2978
+ console.log(more);
2080
2979
  console.log('\nOne order + its money breakdown: octwin orders <reference_id>');
2081
2980
  return;
2082
2981
  }
@@ -2160,8 +3059,7 @@ async function cmdAnalytics(flags) {
2160
3059
  if (stage) {
2161
3060
  if (!asJson)
2162
3061
  console.log(`→ Reading ${entity} records at stage '${stage}' from ${targetLabel(t)} …`);
2163
- const limit = flags.limit ?? '50';
2164
- const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/stages/${encodeURIComponent(stage)}/records?limit=${limit}`, t);
3062
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/stages/${encodeURIComponent(stage)}/records?${pagingQs(flags)}`, t);
2165
3063
  if (status === 404)
2166
3064
  die(`unknown stage '${stage}' for '${entity}'${errDetail(json)}`);
2167
3065
  if (status !== 200)
@@ -2174,12 +3072,15 @@ async function cmdAnalytics(flags) {
2174
3072
  printNoAnalyticsData(entity);
2175
3073
  return;
2176
3074
  }
2177
- const rows = (json?.records ?? []);
2178
- console.log(`${entity} at '${stage}' (live snapshot): ${json?.total ?? rows.length} record(s)`);
2179
- for (const r of rows) {
3075
+ const page = readPage(json);
3076
+ console.log(`${entity} at '${stage}' (live snapshot): ${page.total ?? page.rows.length} record(s)`);
3077
+ for (const r of page.rows) {
2180
3078
  const who = r.channel_contact_handle ?? r.display_name ?? '—';
2181
3079
  console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'} ${who}${r.completed ? ' [completed]' : ''} ${r.record_id}`);
2182
3080
  }
3081
+ const more = morePageHint(page, `octwin analytics ${entity} --stage ${stage}`);
3082
+ if (more)
3083
+ console.log(more);
2183
3084
  return;
2184
3085
  }
2185
3086
  if (!asJson)
@@ -2240,10 +3141,67 @@ async function cmdAnalytics(flags) {
2240
3141
  }
2241
3142
  }
2242
3143
  // ── catalog: the commerce products + their WhatsApp binding ──────────────────
3144
+ /** Reserved leading words on `octwin catalog`. */
3145
+ const CATALOG_VERBS = new Set(['availability', 'stock']);
3146
+ /**
3147
+ * The write half of `octwin catalog` — the two per-SKU levers a pack author needs
3148
+ * to exercise a commerce flow (is it sellable, and how many are there).
3149
+ *
3150
+ * Creating/deleting products and the Meta Graph binding/sync/pull are deliberately
3151
+ * NOT here — see docs/BACKLOG.md. Those are catalog *operations*, need a bound
3152
+ * access token to be meaningful, and belong to the console.
3153
+ */
3154
+ async function cmdCatalogWrite(flags) {
3155
+ const t = resolveTarget(flags);
3156
+ const { url } = t;
3157
+ const base = `${url}/api/self/p/catalog`;
3158
+ const verb = flags._[0];
3159
+ const sku = flags._[1] ?? die(`usage: octwin catalog ${verb} <retailerId> …`);
3160
+ if (verb === 'availability') {
3161
+ const to = typeof flags.to === 'string' ? flags.to
3162
+ : die("usage: octwin catalog availability <retailerId> --to 'in stock'|'out of stock'|…");
3163
+ console.log(`→ Setting ${sku} availability to '${to}' …`);
3164
+ const { status, json } = await apiSend('PATCH', `${base}/${encodeURIComponent(sku)}/availability`, { availability: to }, t);
3165
+ if (status !== 200)
3166
+ writeFail(`set availability for '${sku}'`, status, json, url);
3167
+ console.log(`✓ ${sku} is now '${to}'.`);
3168
+ return;
3169
+ }
3170
+ // stock — read when no --set-on-hand, write when there is.
3171
+ const raw = flags['set-on-hand'];
3172
+ if (raw === undefined) {
3173
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(sku)}/stock`, t);
3174
+ if (status === 404)
3175
+ die(`product '${sku}' not found`);
3176
+ if (status !== 200)
3177
+ writeFail(`read stock for '${sku}'`, status, json, url);
3178
+ // null is a real answer, and a different one from zero: the SKU is not
3179
+ // inventory-tracked, so it is always sellable.
3180
+ console.log(json?.stock == null
3181
+ ? `${sku}: not inventory-tracked (always sellable)`
3182
+ : `${sku}: on_hand=${json.stock.on_hand ?? '?'} reserved=${json.stock.reserved ?? 0}`);
3183
+ return;
3184
+ }
3185
+ const onHand = Number(raw);
3186
+ if (!Number.isInteger(onHand) || onHand < 0)
3187
+ die(`--set-on-hand must be a non-negative integer (got '${String(raw)}')`);
3188
+ console.log(`→ Setting ${sku} on_hand to ${onHand} …`);
3189
+ const { status, json } = await apiSend('PUT', `${base}/${encodeURIComponent(sku)}/stock`, { on_hand: onHand }, t);
3190
+ if (status === 404)
3191
+ die(`product '${sku}' not found`);
3192
+ if (status === 409) {
3193
+ die(`refused: ${onHand} is below the units already RESERVED for open carts/orders${errDetail(json)}`);
3194
+ }
3195
+ if (status !== 200)
3196
+ writeFail(`set stock for '${sku}'`, status, json, url);
3197
+ console.log(`✓ ${sku}: on_hand=${json?.stock?.on_hand ?? onHand} reserved=${json?.stock?.reserved ?? 0}`);
3198
+ }
2243
3199
  /** `octwin catalog [--readiness] [--json]` — the `product` records a commerce pack
2244
3200
  * sells, their stock, and the WhatsApp catalog binding. Needs `catalog:read` + the
2245
3201
  * `catalog` plan feature. */
2246
3202
  async function cmdCatalog(flags) {
3203
+ if (typeof flags._[0] === 'string' && CATALOG_VERBS.has(flags._[0]))
3204
+ return cmdCatalogWrite(flags);
2247
3205
  const t = resolveTarget(flags);
2248
3206
  const { url } = t;
2249
3207
  const base = `${url}/api/self/p/catalog`;
@@ -2274,15 +3232,17 @@ async function cmdCatalog(flags) {
2274
3232
  }
2275
3233
  if (!asJson)
2276
3234
  console.log(`→ Reading the product catalog from ${targetLabel(t)} …`);
2277
- const { status, json } = await apiGet(base, t);
3235
+ const { status, json } = await apiGet(`${base}?${pagingQs(flags)}`, t);
2278
3236
  if (status !== 200)
2279
3237
  die(`could not read the catalog (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
2280
3238
  if (asJson) {
2281
3239
  console.log(JSON.stringify(json, null, 2));
2282
3240
  return;
2283
3241
  }
2284
- const products = (json?.products ?? []);
2285
- console.log(`Products in ${targetLabel(t)}: ${products.length}`);
3242
+ const page = readPage(json);
3243
+ const products = page.rows;
3244
+ // The route reports the catalog-wide total; `products.length` is only this page.
3245
+ console.log(`Products in ${targetLabel(t)}: ${page.total ?? products.length}`);
2286
3246
  if (products.length === 0)
2287
3247
  console.log(' (none — a commerce pack seeds `product` records, or add them in the console Catalog)');
2288
3248
  for (const p of products) {
@@ -2291,6 +3251,9 @@ async function cmdCatalog(flags) {
2291
3251
  console.log(` ${String(p.retailer_id).padEnd(20)} ${String(p.name ?? '').padEnd(28)} ${fmtAmount(p.price, p.currency)}`
2292
3252
  + ` avail=${p.availability} stock=${stock} sync=${p.sync_status ?? '—'}`);
2293
3253
  }
3254
+ const morePages = morePageHint(page, 'octwin catalog');
3255
+ if (morePages)
3256
+ console.log(morePages);
2294
3257
  // A binding row can exist with no catalog_id yet (a WABA is configured but no Meta
2295
3258
  // catalog picked) — that is "not bound" for selling purposes, so say so.
2296
3259
  const b = json?.binding;
@@ -2300,10 +3263,121 @@ async function cmdCatalog(flags) {
2300
3263
  + ' — the catalog works web-only (`--readiness` explains what Meta needs).');
2301
3264
  }
2302
3265
  // ── scheduling: the availability engine + a slot preview ─────────────────────
3266
+ /** Reserved leading words on `octwin scheduling`. */
3267
+ const SCHEDULING_VERBS = new Set(['rules', 'rule', 'exception']);
3268
+ /**
3269
+ * The write half of `octwin scheduling` — the availability rules and exceptions
3270
+ * behind the slots `octwin scheduling --slots` computes.
3271
+ *
3272
+ * `rules` (the LIST) ships with them on purpose: the deletes take a rule id, and
3273
+ * without a way to see one there was no path from "a rule exists" to "remove it".
3274
+ */
3275
+ async function cmdSchedulingWrite(flags) {
3276
+ const t = resolveTarget(flags);
3277
+ const { url } = t;
3278
+ const base = `${url}/api/self/p/scheduling`;
3279
+ const verb = flags._[0];
3280
+ const asJson = flags.json === true;
3281
+ if (verb === 'rules') {
3282
+ const resource = typeof flags.resource === 'string' ? flags.resource
3283
+ : die('usage: octwin scheduling rules --resource <resourceRecordId>');
3284
+ if (!asJson)
3285
+ console.log(`→ Reading availability for resource ${resource} …`);
3286
+ const { status, json } = await apiGet(`${base}/availability?resource_id=${encodeURIComponent(resource)}`, t);
3287
+ if (status !== 200)
3288
+ writeFail('read availability', status, json, url);
3289
+ if (asJson) {
3290
+ console.log(JSON.stringify(json, null, 2));
3291
+ return;
3292
+ }
3293
+ if (json?.has_scheduling === false) {
3294
+ console.log('This pack declares no scheduling.');
3295
+ return;
3296
+ }
3297
+ const rules = (json?.rules ?? []);
3298
+ const exceptions = (json?.exceptions ?? []);
3299
+ const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
3300
+ console.log(`Rules: ${rules.length}`);
3301
+ for (const r of rules) {
3302
+ console.log(` ${DOW[r.dow] ?? `dow${r.dow}`} ${r.start_time}–${r.end_time}`
3303
+ + ` slot=${r.slot_minutes ?? '—'}m cap=${r.capacity ?? '—'} ${r.id}`);
3304
+ }
3305
+ console.log(`Exceptions: ${exceptions.length}`);
3306
+ for (const e of exceptions) {
3307
+ console.log(` ${e.exception_date} ${e.kind}${e.start_time ? ` ${e.start_time}–${e.end_time}` : ''} ${e.id}`);
3308
+ }
3309
+ console.log('\nRemove one: octwin scheduling rule rm <ruleId> · octwin scheduling exception rm <exceptionId>');
3310
+ return;
3311
+ }
3312
+ const sub = flags._[1];
3313
+ const isRule = verb === 'rule';
3314
+ const noun = isRule ? 'rule' : 'exception';
3315
+ const path = isRule ? 'rules' : 'exceptions';
3316
+ if (sub === 'rm') {
3317
+ const id = flags._[2] ?? die(`usage: octwin scheduling ${noun} rm <${noun}Id>`);
3318
+ console.log(`→ Removing ${noun} ${id} …`);
3319
+ const { status, json } = await apiSend('DELETE', `${base}/availability/${path}/${encodeURIComponent(id)}`, undefined, t);
3320
+ if (status === 404)
3321
+ die(`${noun} '${id}' not found`);
3322
+ if (status !== 200)
3323
+ writeFail(`remove ${noun} ${id}`, status, json, url);
3324
+ console.log(`✓ ${noun[0].toUpperCase()}${noun.slice(1)} removed.`);
3325
+ return;
3326
+ }
3327
+ if (sub !== 'add') {
3328
+ die(isRule
3329
+ ? '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>'
3330
+ : '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>');
3331
+ }
3332
+ const resource = typeof flags.resource === 'string' ? flags.resource
3333
+ : die(`octwin scheduling ${noun} add needs --resource <resourceRecordId>`);
3334
+ const body = { resource_id: resource };
3335
+ if (typeof flags['slot-minutes'] === 'string')
3336
+ body.slot_minutes = Number(flags['slot-minutes']);
3337
+ if (typeof flags.capacity === 'string')
3338
+ body.capacity = Number(flags.capacity);
3339
+ if (typeof flags.start === 'string')
3340
+ body.start_time = flags.start;
3341
+ if (typeof flags.end === 'string')
3342
+ body.end_time = flags.end;
3343
+ if (isRule) {
3344
+ const dowRaw = flags.dow;
3345
+ if (typeof dowRaw !== 'string')
3346
+ die('octwin scheduling rule add needs --dow <0-6> (0 = Sunday)');
3347
+ const dow = Number(dowRaw);
3348
+ if (!Number.isInteger(dow) || dow < 0 || dow > 6)
3349
+ die(`--dow must be an integer 0-6, 0 = Sunday (got '${dowRaw}')`);
3350
+ body.dow = dow;
3351
+ if (!body.start_time || !body.end_time)
3352
+ die('octwin scheduling rule add needs --start HH:MM and --end HH:MM');
3353
+ }
3354
+ else {
3355
+ const date = typeof flags.date === 'string' ? flags.date
3356
+ : die('octwin scheduling exception add needs --date YYYY-MM-DD');
3357
+ const kind = typeof flags.kind === 'string' ? flags.kind
3358
+ : die("octwin scheduling exception add needs --kind closed|extra");
3359
+ if (kind !== 'closed' && kind !== 'extra')
3360
+ die(`--kind must be 'closed' or 'extra' (got '${kind}')`);
3361
+ body.exception_date = date;
3362
+ body.kind = kind;
3363
+ }
3364
+ console.log(`→ Adding a ${noun} for resource ${resource} …`);
3365
+ const { status, json } = await apiSend('POST', `${base}/availability/${path}`, body, t);
3366
+ if (status !== 201 && status !== 200)
3367
+ writeFail(`add the ${noun}`, status, json, url);
3368
+ if (json?.has_scheduling === false)
3369
+ die('this pack declares no scheduling');
3370
+ const created = json?.rule ?? json?.exception ?? {};
3371
+ console.log(`✓ ${noun[0].toUpperCase()}${noun.slice(1)} added — ${created.id ?? '(no id returned)'}`);
3372
+ console.log(`\nSee it: octwin scheduling rules --resource ${resource}`);
3373
+ console.log(`Verify the slots it produces: octwin scheduling --slots ${resource}`);
3374
+ }
2303
3375
  /** `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]`
2304
3376
  * — the scheduling engine's state, or the computed slots for one bookable resource
2305
3377
  * (the verification the `--seed` availability fan-out was missing). `scheduling:read`. */
2306
3378
  async function cmdScheduling(flags) {
3379
+ if (typeof flags._[0] === 'string' && SCHEDULING_VERBS.has(flags._[0]))
3380
+ return cmdSchedulingWrite(flags);
2307
3381
  const t = resolveTarget(flags);
2308
3382
  const { url } = t;
2309
3383
  const base = `${url}/api/self/p/scheduling`;
@@ -2368,9 +3442,10 @@ function help() {
2368
3442
 
2369
3443
  octwin --version # print the CLI version (+ any upgrade notice)
2370
3444
  octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
2371
- octwin validate [--dir .] [--remote] # --remote runs the platform's FULL schema check (all errors at once)
3445
+ octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
2372
3446
  octwin login --url <platformUrl> --token oct_… # a deploy token from the console
2373
3447
  octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
3448
+ octwin projects [--archived] [--json] # the --project slugs this token can name
2374
3449
  octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
2375
3450
  octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
2376
3451
  octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
@@ -2386,6 +3461,17 @@ function help() {
2386
3461
  octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
2387
3462
  octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
2388
3463
  octwin test [--dir .] # = validate --remote (the full platform check)
3464
+ octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
3465
+
3466
+ Writes — exercise the state your pack creates (each needs the matching :write scope):
3467
+ octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
3468
+ octwin records tasks | task complete <taskId> [--outcome done|cancelled]
3469
+ octwin cases assign <id> --to user:<uuid>|none | note <id> "…" | transition <id> --to <status>
3470
+ octwin cases decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
3471
+ octwin orders transition <ref> --to <status> | refund <ref> --force
3472
+ octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
3473
+ octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
3474
+ octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
2389
3475
 
2390
3476
  Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
2391
3477
  \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
@@ -2400,26 +3486,79 @@ Per-command usage: octwin <command> --help`);
2400
3486
  const COMMAND_HELP = {
2401
3487
  init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
2402
3488
  Scaffold a pure-YAML starter pack into <dir>.`,
2403
- validate: `octwin validate [--dir .] [--remote]
2404
- Offline structural check; --remote additionally runs the platform's FULL
2405
- manifest + flow-DSL validation (all errors at once) same check as deploy.`,
3489
+ validate: `octwin validate [--dir .] [--remote] [--require-kb]
3490
+ Offline structural check, plus two checks driven by the pulled capability
3491
+ reference (render-intent fields, primitive arguments). Those two SKIP when the
3492
+ reference is missing — the run says so, and --require-kb turns the skip into a
3493
+ failure for CI. --remote additionally runs the platform's FULL manifest +
3494
+ flow-DSL validation and its flow lint (all errors at once) — same check as deploy.`,
2406
3495
  login: `octwin login --url <platformUrl> --token oct_…
2407
3496
  Save a deploy token (console → Settings → API tokens) for that platform url,
2408
3497
  make that url the DEFAULT deploy target for every later command, and echo the
2409
3498
  workspace + project pin + scopes the token reaches.`,
2410
3499
  whoami: `octwin whoami [--url <url>] [--tenant <slug>]
2411
3500
  Verify the resolved token authenticates against the tenant.`,
3501
+ projects: `octwin projects [--archived] [--json]
3502
+ List the workspace's projects — the slugs every --project flag takes, with the
3503
+ plan's project cap. --archived includes archived ones. A pack:deploy token
3504
+ reaches this (it names a project in every other command).
3505
+
3506
+ octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
3507
+ Create a project. The URL slug is derived from the name unless --slug pins one.
3508
+ --pack installs an ALREADY-published pack; the usual next step is instead
3509
+ \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
3510
+
3511
+ octwin projects rm <slug> [--yes]
3512
+ HARD delete — the project and everything cascading from it (conversations,
3513
+ contacts, records, installs). No undo, and not the same as archiving.
3514
+ WITHOUT --yes it only previews what would be destroyed, so the dry run is the
3515
+ default. Together these make a disposable end-to-end environment:
3516
+ octwin projects create "Scratch" && octwin deploy --project scratch --seed
3517
+ octwin chat "hi" --project scratch
3518
+ octwin projects rm scratch --yes
3519
+ Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
2412
3520
  deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
2413
3521
  Upload the pack bundle, validate server-side, install onto the project.
2414
3522
  --seed additionally applies the pack's demo seed (streams progress).`,
3523
+ seed: `octwin seed [--pack <packId>]
3524
+ Apply the pack's demo/reference data to the project it is installed on, without
3525
+ redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
3526
+ and the demo operator topology. Reports what each kind produced.
3527
+ Idempotent and safe to re-run — records upsert, and existing media is REUSED rather
3528
+ than regenerated, so a second pass costs nothing. --pack is only needed when a
3529
+ project somehow runs more than one.`,
2415
3530
  status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
2416
3531
  Show installed vs live version + the flow list for this pack.`,
2417
- records: `octwin records [entity] [id] [--limit 50]
3532
+ records: `octwin records [entity] [id] [--limit 50] [--offset n]
2418
3533
  Inspect the pack's XRM data. No args = list entities. Cases/tickets are
2419
- casework, not XRM — use \`octwin cases\` for those.`,
2420
- cases: `octwin cases [caseId] [--queues] [--limit 50] [--json]
3534
+ casework, not XRM — use \`octwin cases\` for those.
3535
+
3536
+ WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
3537
+ octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
3538
+ octwin records patch <recordId> --entity <entity> --set field=value
3539
+ octwin records stage <recordId> --to <stage> [--note "..."]
3540
+ octwin records note <recordId> "the note text"
3541
+ octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
3542
+ octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
3543
+
3544
+ --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
3545
+ sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
3546
+ \`patch\` needs --entity even though it has an id: the route resolves the field
3547
+ validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
3548
+ VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
3549
+ cases: `octwin cases [caseId] [--queues] [--limit 50] [--offset n] [--json]
2421
3550
  Inspect casework (support tickets): the inbox, one case + its timeline
2422
- (+ applicable decisions), or --queues for queue keys + open counts.`,
3551
+ (+ applicable decisions), or --queues for queue keys + open counts.
3552
+
3553
+ WRITES (need \`cases:write\`):
3554
+ octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none
3555
+ octwin cases note <caseId> "the note text"
3556
+ octwin cases transition <caseId> --to <status> [--note "..."]
3557
+ octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]
3558
+
3559
+ \`decide\` applies one of the case's declared dispositions — \`octwin cases <id>\`
3560
+ lists them with their params. --dry-run previews the customer-facing copy and the
3561
+ resulting status WITHOUT committing (that route needs only \`cases:read\`).`,
2423
3562
  logs: `octwin logs [conversationId] [--as <handle>] [--json]
2424
3563
  No id = recent conversations (handle, status, last activity; --as filters).
2425
3564
  With id = the full event timeline including what each turn rendered.
@@ -2467,13 +3606,30 @@ const COMMAND_HELP = {
2467
3606
  override what your manifest declares, and this is where you see that.
2468
3607
  --prompt = the exact system prompt the LLM sees for this project (pack
2469
3608
  instructions + platform protocol + any project overlay). Needs agents:read.
2470
- The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.`,
3609
+ The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
3610
+
3611
+ WRITES (need \`agents:write\`):
3612
+ octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
3613
+ [--enable-tool <toolId>] [--disable-tool <toolId>]
3614
+
3615
+ Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
3616
+ so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
3617
+ ids refuses --model with a 403 — the platform default governs there.`,
2471
3618
  orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
2472
3619
  No args = the order list (#number, status/payment, total, contact). With a
2473
3620
  reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
2474
3621
  payment_ref, and the allowed status transitions. Needs orders:read + the
2475
3622
  \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
2476
- so \`pending\` on a gateway-less workspace is expected, not a bug.`,
3623
+ so \`pending\` on a gateway-less workspace is expected, not a bug.
3624
+
3625
+ WRITES (need \`orders:write\`):
3626
+ octwin orders transition <reference_id> --to <status>
3627
+ octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
3628
+
3629
+ Refund is irreversible and moves money, hence --force. The route answers 200 even
3630
+ when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
3631
+ on a refusal rather than reporting a refund that never happened. Only a payment in
3632
+ \`captured\` state can be refunded; \`payment_status\` is never settable directly.`,
2477
3633
  analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
2478
3634
  No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
2479
3635
  With an entity = stage-by-stage conversion (default --funnel) over the last 30
@@ -2483,22 +3639,58 @@ const COMMAND_HELP = {
2483
3639
  The commerce \`product\` records + price, availability, stock (null = not
2484
3640
  inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
2485
3641
  Graph checklist (LIVE Graph calls; needs a bound access token). Needs
2486
- catalog:read + the \`catalog\` plan feature.`,
3642
+ catalog:read + the \`catalog\` plan feature.
3643
+
3644
+ WRITES (need \`catalog:write\`):
3645
+ octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
3646
+ octwin catalog stock <retailerId> [--set-on-hand <n>]
3647
+
3648
+ \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
3649
+ inventory-tracked (always sellable), which is different from 0. Lowering on_hand
3650
+ below the units already reserved for open carts is refused. Creating/deleting
3651
+ products and the Meta catalog binding/sync stay in the console.`,
2487
3652
  scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
2488
3653
  No args = the engine state (bookable resource types, upcoming slots, booked
2489
3654
  seats). --slots <recordId> computes the slots for one bookable resource
2490
3655
  (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.`,
3656
+ the availability rules a \`deploy --seed\` created. Needs scheduling:read.
3657
+
3658
+ RULES (list needs scheduling:read; add/rm need scheduling:write):
3659
+ octwin scheduling rules --resource <resourceRecordId>
3660
+ octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
3661
+ [--slot-minutes 30] [--capacity 1]
3662
+ octwin scheduling rule rm <ruleId>
3663
+ octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
3664
+ [--start 09:00 --end 13:00]
3665
+ octwin scheduling exception rm <exceptionId>
3666
+
3667
+ --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
3668
+ \`--slots\` is how you check what a rule actually produces.`,
2492
3669
  'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
2493
3670
  Pull the platform capability reference (markdown + JSON catalogs) into
2494
3671
  .octwin/platform-kb/ for the octwin-pack authoring skill.`,
2495
3672
  test: `octwin test [--dir .]
2496
3673
  Alias for \`octwin validate --remote\` — the full platform check.`,
3674
+ feedback: `octwin feedback [--dir .]
3675
+ Submit this pack's FEEDBACK.md to the platform team.
3676
+ The octwin-pack skill writes that file in its last step — findings grouped by
3677
+ owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
3678
+ you to paste it into a chat.
3679
+ Attaches the pack id + version from manifest.yaml, this CLI's version, and the
3680
+ content_hash of the capability reference in .octwin/platform-kb/ — triage needs
3681
+ the last two to tell "the platform is wrong" from "that was already fixed" or
3682
+ "you were reading a stale reference". Needs the \`pack:deploy\` scope.`,
2497
3683
  };
2498
3684
  async function main() {
2499
3685
  const [command, ...rest] = process.argv.slice(2);
2500
3686
  const flags = parseFlags(rest);
2501
- CURRENT_COMMAND = command; // so an auth failure can name the scope THIS command needs
3687
+ // So an auth failure can name the scope THIS invocation needs. A leading write
3688
+ // verb changes the answer (`cases` reads, `cases note` writes), so it rides along
3689
+ // when the first positional is one — `VERB_REQUIREMENTS` is keyed that way.
3690
+ const leadingVerb = flags._[0];
3691
+ CURRENT_COMMAND = (typeof leadingVerb === 'string' && command && `${command} ${leadingVerb}` in VERB_REQUIREMENTS)
3692
+ ? `${command} ${leadingVerb}`
3693
+ : command;
2502
3694
  // Per-subcommand --help/-h — intercepted BEFORE the command runs, so help can
2503
3695
  // never hit the network or die on auth (author-feedback A8).
2504
3696
  if (command && command in COMMAND_HELP && (flags.help === true || flags._.includes('-h'))) {
@@ -2542,6 +3734,12 @@ async function main() {
2542
3734
  case 'media':
2543
3735
  await cmdMedia(flags);
2544
3736
  break;
3737
+ case 'seed':
3738
+ await cmdSeed(flags);
3739
+ break;
3740
+ case 'projects':
3741
+ await cmdProjects(flags);
3742
+ break;
2545
3743
  case 'agents':
2546
3744
  await cmdAgents(flags);
2547
3745
  break;
@@ -2560,6 +3758,9 @@ async function main() {
2560
3758
  case 'platform-kb':
2561
3759
  await cmdPlatformKb(flags);
2562
3760
  break;
3761
+ case 'feedback':
3762
+ await cmdFeedback(flags);
3763
+ break;
2563
3764
  case 'test':
2564
3765
  await cmdValidate({ ...flags, remote: true });
2565
3766
  break; // A6: `test` = the full remote validate, not a validate-clone