octwin-cli 0.1.20 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,25 +169,51 @@ 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
+ };
133
201
  const COMMAND_REQUIREMENTS = {
134
202
  deploy: { scope: 'pack:deploy' },
135
203
  validate: { scope: 'pack:deploy' },
136
204
  status: { scope: 'pack:deploy' },
137
205
  test: { scope: 'pack:deploy' },
206
+ // `pull` reads a stored artifact through the deploy route's sibling, so it needs the
207
+ // same scope. Omitting it here meant a 403 on the one command that recovers a pack's
208
+ // only source copy printed the generic hint WITHOUT naming the scope to grant.
209
+ pull: { scope: 'pack:deploy' },
138
210
  'platform-kb': { scope: 'pack:deploy' },
211
+ feedback: { scope: 'pack:deploy' },
139
212
  media: { scope: 'media:generate' },
140
- records: { scope: 'records:read', feature: 'records' },
213
+ // The plan feature gates RECORD reads, not the entity list (`/xrm/entities` carries only
214
+ // the scope guard) — so the hint says which half it applies to rather than blaming the
215
+ // plan for a 403 the plan did not cause.
216
+ records: { scope: 'records:read', feature: 'records', featureAppliesTo: 'reading records (listing entities needs only the scope)' },
141
217
  analytics: { scope: 'records:read', feature: 'records' },
142
218
  cases: { scope: 'cases:read', feature: 'cases' },
143
219
  logs: { scope: 'conversations:read' },
@@ -145,20 +221,31 @@ const COMMAND_REQUIREMENTS = {
145
221
  catalog: { scope: 'catalog:read', feature: 'catalog' },
146
222
  scheduling: { scope: 'scheduling:read' },
147
223
  agents: { scope: 'agents:read' },
224
+ // The route accepts `pack:deploy` too (that is the point of the command), but the
225
+ // hint names the scope a NON-deploy token would be missing — a `pack:deploy`
226
+ // holder never sees this line, because they never get the 403.
227
+ projects: { scope: 'projects:read' },
148
228
  };
149
229
  /** The command currently running — set once in `main()` so any failure printer can
150
- * name the scope that command needs without threading it through every call. */
230
+ * name the scope that command needs without threading it through every call.
231
+ * Carries the write VERB too (`cases note`), since that is what decides the scope. */
151
232
  let CURRENT_COMMAND;
152
233
  /** `→ needs the \`orders:read\` scope …` — the requirement line for the running
153
234
  * command, or '' when the command has no declared requirement. */
154
235
  function scopeRequirementHint() {
155
- const req = CURRENT_COMMAND ? COMMAND_REQUIREMENTS[CURRENT_COMMAND] : undefined;
236
+ // Most specific first: `<command> <verb>` beats the command's own (read) entry.
237
+ const req = CURRENT_COMMAND
238
+ ? (VERB_REQUIREMENTS[CURRENT_COMMAND] ?? COMMAND_REQUIREMENTS[CURRENT_COMMAND.split(' ')[0]])
239
+ : undefined;
156
240
  if (!req)
157
241
  return '';
158
242
  const special = req.scope === 'pack:deploy' || req.scope === 'media:generate';
159
243
  return `needs the \`${req.scope}\` scope`
160
244
  + (special ? ' (granted DIRECTLY only — a `tenant:admin` token does not confer it)' : '')
161
- + (req.feature ? `, and the \`${req.feature}\` plan feature on this workspace` : '');
245
+ + (req.feature
246
+ ? `, and the \`${req.feature}\` plan feature on this workspace`
247
+ + (req.featureAppliesTo ? ` for ${req.featureAppliesTo}` : '')
248
+ : '');
162
249
  }
163
250
  /** Print the auth hints below an HTTP-failure line when it's a 401/403 — so every
164
251
  * command explains a token problem, not just the inspect family (author-feedback A7):
@@ -394,8 +481,11 @@ async function notifyIfOutdated() {
394
481
  /** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
395
482
  * (content hash + per-entry index), or null if nothing has been pulled yet. */
396
483
  function readLocalKb(packDir) {
484
+ const kbDir = findPlatformKbDir(packDir); // walks up — a repo-root pull covers every pack under it
485
+ if (!kbDir)
486
+ return null;
397
487
  try {
398
- const idx = JSON.parse(readFileSync(join(packDir, '.octwin', 'platform-kb', 'index.json'), 'utf8'));
488
+ const idx = JSON.parse(readFileSync(join(kbDir, 'index.json'), 'utf8'));
399
489
  return {
400
490
  content_hash: typeof idx.content_hash === 'string' ? idx.content_hash : null,
401
491
  index: Array.isArray(idx.index) ? idx.index : [],
@@ -442,8 +532,18 @@ async function notifyIfKbStale(flags) {
442
532
  headers: authHeaders(t), signal: ctrl.signal,
443
533
  });
444
534
  clearTimeout(timer);
445
- if (!res.ok)
535
+ if (!res.ok) {
536
+ // The meta poll needs `pack:deploy`, but this nudge rides on every networked
537
+ // command — so an author inspecting data with a narrow (`records:read`-only) token
538
+ // got NO drift signal at all, silently, and a stale reference is exactly what makes
539
+ // an author invent a primitive from memory. Say so once; stay silent for every other
540
+ // failure (offline, timeout, a platform without the route).
541
+ if (res.status === 401 || res.status === 403) {
542
+ console.error('\nⓘ can\'t check whether the platform capability reference drifted — that needs a `pack:deploy` token.');
543
+ console.error(' Refresh it directly with a deploy token: octwin platform-kb --token oct_…');
544
+ }
446
545
  return;
546
+ }
447
547
  const meta = await res.json();
448
548
  if (meta.content_hash && meta.content_hash !== local.content_hash) {
449
549
  // Per-entry summary (now that the index carries per-entry hashes) — the
@@ -481,11 +581,13 @@ function commandTouchesPlatform(command, flags) {
481
581
  case 'cases':
482
582
  case 'logs':
483
583
  case 'whoami':
584
+ case 'feedback':
484
585
  case 'agents':
485
586
  case 'orders':
486
587
  case 'analytics':
487
588
  case 'catalog':
488
- case 'scheduling': return true;
589
+ case 'scheduling':
590
+ case 'projects': return true;
489
591
  default: return false;
490
592
  }
491
593
  }
@@ -505,8 +607,6 @@ function cmdInit(flags) {
505
607
  cpSync(TEMPLATE_DIR, dir, { recursive: true });
506
608
  applyRenames(dir, {
507
609
  packId: id,
508
- flowId: 'main',
509
- agentId: 'assistant',
510
610
  description: flags.description ?? undefined,
511
611
  displayName: flags['display-name'] ?? undefined,
512
612
  });
@@ -542,22 +642,28 @@ async function cmdValidate(flags) {
542
642
  const packDir = resolve(flags.dir ?? '.');
543
643
  const { id, version, files, blobs } = localValidate(packDir); // offline structural gate first (fast, no server/token)
544
644
  console.log(`✓ ${id}@${version} passes the offline structural check (${Object.keys(files).length} files, ${Object.keys(blobs).length} image(s))`);
545
- // Render-intent fields, checked against the pulled KB. Skipped (silently) when
546
- // the author hasn't pulled the reference yet — Step 0.5 of the skill says to.
547
- const allowedRenderKeys = loadAllowedRenderKeys(packDir);
548
- if (allowedRenderKeys) {
549
- const findings = Object.entries(files)
550
- .filter(([p]) => /\.ya?ml$/i.test(p))
551
- .flatMap(([p, body]) => {
552
- let doc;
553
- try {
554
- doc = parseYaml(body);
555
- }
556
- catch {
557
- return [];
558
- } // a YAML syntax error is the structural gate's to report
559
- return findRenderKeyViolations(doc, p, allowedRenderKeys);
560
- });
645
+ /** Every YAML file in the bundle, parsed once. A syntax error is the structural gate's to report. */
646
+ const yamlDocs = () => Object.entries(files)
647
+ .filter(([p]) => /\.ya?ml$/i.test(p))
648
+ .flatMap(([p, body]) => {
649
+ try {
650
+ return [[p, parseYaml(body)]];
651
+ }
652
+ catch {
653
+ return [];
654
+ }
655
+ });
656
+ // Checks that need the pulled KB. Both DEGRADE when it is absent — the KB is a
657
+ // gitignored cache wiped by every pull, and `platform-kb pull` needs a
658
+ // `pack:deploy` scope a CI job may not have, so failing hard would break a fresh
659
+ // clone before the author could act. But a skip is now ANNOUNCED, and remembered:
660
+ // the ✓ used to print above these blocks unconditionally while the per-check ✓s
661
+ // lived inside the `if`s, so a KB-less run read as "one check, passed". An entire
662
+ // backlog batch reached production that way. The defect is the silence, not the skip.
663
+ const skipped = [];
664
+ const render = loadAllowedRenderKeys(packDir);
665
+ if (render.keys) {
666
+ const findings = yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys));
561
667
  if (findings.length) {
562
668
  console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
563
669
  for (const f of findings)
@@ -566,23 +672,15 @@ async function cmdValidate(flags) {
566
672
  }
567
673
  console.log('✓ render intents use only fields the platform renders');
568
674
  }
569
- // Primitive `args:` keys, same source and same degrade-to-no-op contract.
570
- // Cannot see inside a `use:` template body (expansion is the platform's job);
571
- // `--remote` covers that.
572
- const argSpecs = loadPrimitiveArgSpecs(packDir);
573
- if (argSpecs) {
574
- const findings = Object.entries(files)
575
- .filter(([p]) => /\.ya?ml$/i.test(p))
576
- .flatMap(([p, body]) => {
577
- let doc;
578
- try {
579
- doc = parseYaml(body);
580
- }
581
- catch {
582
- return [];
583
- }
584
- return findArgViolations(doc, p, argSpecs);
585
- });
675
+ else {
676
+ console.log(`⚠ ${describeKbLookup(render.lookup, 'render-intent fields')}`);
677
+ skipped.push('render-intent fields');
678
+ }
679
+ // Primitive `args:` keys, same source and same contract. Cannot see inside a
680
+ // `use:` template body (expansion is the platform's job); `--remote` covers that.
681
+ const args = loadPrimitiveArgSpecs(packDir);
682
+ if (args.specs) {
683
+ const findings = yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs));
586
684
  if (findings.length) {
587
685
  console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
588
686
  for (const f of findings)
@@ -591,9 +689,26 @@ async function cmdValidate(flags) {
591
689
  }
592
690
  console.log('✓ primitive arguments match their declared inputs');
593
691
  }
692
+ else {
693
+ console.log(`⚠ ${describeKbLookup(args.lookup, 'primitive arguments')}`);
694
+ skipped.push('primitive arguments');
695
+ }
696
+ // `--require-kb` is for CI, where a skip nobody reads is worse than a red build.
697
+ if (skipped.length && flags['require-kb'] === true) {
698
+ die(`--require-kb: ${skipped.length} check${skipped.length === 1 ? '' : 's'} could not run (${skipped.join(', ')})`);
699
+ }
594
700
  if (flags.remote !== true) {
595
- console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
596
- console.log(' (all errors at once) before you deploy.');
701
+ // The LAST line carries the skip. A reader who sees a and stops there is the
702
+ // failure mode; a caveat printed ABOVE the ✓ does not fix it.
703
+ if (skipped.length) {
704
+ console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.join(' and ')} ${skipped.length === 1 ? 'was' : 'were'} skipped.`);
705
+ console.log(' Run `octwin platform-kb pull` (once, at your repo root — it covers every pack under it),');
706
+ console.log(' or `octwin validate --remote` to have the platform run everything server-side.');
707
+ }
708
+ else {
709
+ console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
710
+ console.log(' (all errors at once) before you deploy.');
711
+ }
597
712
  return;
598
713
  }
599
714
  // Remote: the SAME validation the deploy route runs — manifest `.strict()` +
@@ -842,6 +957,36 @@ async function readDeployProgress(body) {
842
957
  }
843
958
  return { terminal, stepErrors };
844
959
  }
960
+ /**
961
+ * The anonymous-marketplace verdict, for `deploy` and `status` alike.
962
+ *
963
+ * `listing.public: true` in the manifest puts a pack into an operator review queue, and the
964
+ * state lived only in the console — so an author working from the CLI got no acknowledgement
965
+ * that the request had registered, and no sight of a rejection note (which the platform
966
+ * REQUIRES precisely because it is their only feedback). Silent for `none`/absent, so a pack
967
+ * that never asked prints nothing.
968
+ *
969
+ * `pending` after a redeploy is normal rather than a regression: an approval pins the sha it
970
+ * reviewed, so any content edit returns the pack to the queue on its own.
971
+ */
972
+ function printPublicListing(state, note, live) {
973
+ const n = typeof note === 'string' && note ? ` — operator note: ${note}` : '';
974
+ switch (state) {
975
+ case 'pending':
976
+ console.log(' ⓘ marketplace listing: PENDING operator review (any content edit re-queues it — the approval pins a content sha).');
977
+ break;
978
+ case 'approved':
979
+ console.log(live === false
980
+ ? ' ⓘ marketplace listing: approved, but NOT currently public — the approved content sha no longer matches what you publish. It is back in the review queue.'
981
+ : ' ✓ marketplace listing: live on the public marketplace.');
982
+ break;
983
+ case 'rejected':
984
+ console.log(` ⚠ marketplace listing: REJECTED${n}`);
985
+ break;
986
+ default: // 'none' | null | absent → never asked
987
+ break;
988
+ }
989
+ }
845
990
  function printDeploySuccess(id, version, t, r) {
846
991
  console.log(`✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
847
992
  if (r?.warning)
@@ -867,6 +1012,7 @@ function printDeploySuccess(id, version, t, r) {
867
1012
  if (dropped > 0) {
868
1013
  console.log(` ⓘ ${dropped} suspended run(s) invalidated — a tap on any card rendered before this deploy will report a stale run. Start those conversations again.`);
869
1014
  }
1015
+ printPublicListing(r?.public_listing, r?.public_review_note);
870
1016
  console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
871
1017
  }
872
1018
  async function cmdDeploy(flags) {
@@ -962,7 +1108,17 @@ async function cmdStatus(flags) {
962
1108
  console.log(` live on instance : registered=${json.registered} loaded=${shortSha(json.loaded_content_sha)}`);
963
1109
  console.log(` catalog artifact : ${shortSha(json.catalog_content_sha)}${json.origin ? ` origin=${json.origin}` : ''}`);
964
1110
  console.log(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
965
- if (!json.registered) {
1111
+ // Every line above is process-global — `registered` is true for a pack ANY
1112
+ // project on this instance loaded. `dispatches` is the project-scoped answer to
1113
+ // the question status is actually asked: can this pack receive a message here?
1114
+ // Dispatch takes the OLDEST active install and ignores the rest, so a second
1115
+ // install is not a warning, it is a pack that will never run.
1116
+ if (json.dispatches === false) {
1117
+ console.log(`\n✗ installed, but project '${t.project ?? '(pinned)'}' dispatches to `
1118
+ + `'${json.dispatches_to ?? '(nothing)'}' — this pack CANNOT receive a message.`);
1119
+ console.log(' One pack per project: the oldest active install wins. Archive the other install to switch.');
1120
+ }
1121
+ else if (!json.registered) {
966
1122
  console.log('\n… not warm on the instance you hit yet — it loads on the next inbound (chat once, then re-check).');
967
1123
  }
968
1124
  else if (json.up_to_date === false) {
@@ -974,6 +1130,10 @@ async function cmdStatus(flags) {
974
1130
  else {
975
1131
  console.log('\n✓ live and current.');
976
1132
  }
1133
+ // Independent of the runtime verdict above: a listing decision controls anonymous
1134
+ // visibility only and never touches a running install, so it is reported alongside
1135
+ // rather than folded into that ladder.
1136
+ printPublicListing(json.public_listing, json.public_review_note, json.publicly_listed);
977
1137
  if (localVersion !== '?' && localVersion !== json.installed_version) {
978
1138
  console.log(` (local manifest is ${localVersion}; deployed is ${json.installed_version} — \`octwin deploy\` to push local edits.)`);
979
1139
  }
@@ -1185,6 +1345,8 @@ async function cmdPlatformKb(flags) {
1185
1345
  console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
1186
1346
  console.log(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`);
1187
1347
  console.log(' Start at INDEX.md — it maps every doc and every catalog entry to its file.');
1348
+ console.log(' Every pack UNDER this directory finds it — `octwin validate` walks up to locate it,');
1349
+ console.log(' so one pull at a repo root covers a whole monorepo of packs.');
1188
1350
  // Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
1189
1351
  // moved (a schema shape being replaced shows as a `~ changed`), not just a count.
1190
1352
  if (prior?.content_hash) {
@@ -1220,17 +1382,125 @@ async function apiGet(endpoint, t) {
1220
1382
  }
1221
1383
  return { status: res.status, json };
1222
1384
  }
1385
+ /**
1386
+ * Send a WRITE to an admin endpoint with the deploy token; returns `{ status, json }`.
1387
+ *
1388
+ * The mirror of `apiGet`, and the reason every write command is three lines: the
1389
+ * `content-type` + `authHeaders` block was inlined at each of the four original
1390
+ * write sites, and fifteen more copies is how one of them ends up subtly different.
1391
+ * A `204` (media delete) has no body to parse, hence the empty-text guard.
1392
+ */
1393
+ async function apiSend(method, endpoint, body, t) {
1394
+ const res = await fetchOrDie(endpoint, {
1395
+ method,
1396
+ headers: { 'content-type': 'application/json', ...authHeaders(t) },
1397
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
1398
+ }, 'request');
1399
+ const text = await res.text();
1400
+ if (!text)
1401
+ return { status: res.status, json: null };
1402
+ let json;
1403
+ try {
1404
+ json = JSON.parse(text);
1405
+ }
1406
+ catch {
1407
+ json = text;
1408
+ }
1409
+ return { status: res.status, json };
1410
+ }
1411
+ /**
1412
+ * Fail a write with the server's own reason, the auth explanation, and — where it
1413
+ * applies — the RBAC caveat a scope hint structurally cannot cover.
1414
+ *
1415
+ * Record and case writes are re-checked against the SPECIFIC row, so a 403 there
1416
+ * can mean "your token has the scope but your role has no grant on this record",
1417
+ * which is invisible to `COMMAND_REQUIREMENTS`. Saying so is the difference
1418
+ * between a two-minute fix and re-minting a token that was never the problem.
1419
+ */
1420
+ function writeFail(what, status, json, url, rbacScoped = false) {
1421
+ if (status === 403 && rbacScoped) {
1422
+ console.error(' → a 403 here can also be an RBAC grant gap: the scope is checked on the token,');
1423
+ console.error(' then the verb is re-checked against THIS record. Check the pack\'s roles.yaml grants.');
1424
+ }
1425
+ die(`could not ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1426
+ }
1427
+ /**
1428
+ * Build a fields object from repeated `--set k=v`, plus an optional
1429
+ * `--fields-json` escape hatch for anything nested.
1430
+ *
1431
+ * Values are coerced as JSON scalars (`true` / `false` / `null` / a number),
1432
+ * falling back to the raw string — so `--set price=9.99` sends a number and
1433
+ * `--set name=9 Bakery` sends a string. Anything richer than a scalar belongs in
1434
+ * `--fields-json`, rather than inventing a mini-syntax here.
1435
+ */
1436
+ function fieldsFromFlags(flags) {
1437
+ const out = {};
1438
+ const raw = flags['fields-json'];
1439
+ if (typeof raw === 'string') {
1440
+ let parsed;
1441
+ try {
1442
+ parsed = JSON.parse(raw);
1443
+ }
1444
+ catch {
1445
+ die('--fields-json is not valid JSON');
1446
+ }
1447
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1448
+ die('--fields-json must be a JSON object');
1449
+ }
1450
+ Object.assign(out, parsed);
1451
+ }
1452
+ for (const pair of flagList(flags, 'set')) {
1453
+ const eq = pair.indexOf('=');
1454
+ if (eq <= 0)
1455
+ die(`--set expects key=value (got '${pair}')`);
1456
+ const key = pair.slice(0, eq);
1457
+ const val = pair.slice(eq + 1);
1458
+ if (val === 'true')
1459
+ out[key] = true;
1460
+ else if (val === 'false')
1461
+ out[key] = false;
1462
+ else if (val === 'null')
1463
+ out[key] = null;
1464
+ else if (val !== '' && !Number.isNaN(Number(val)) && /^-?\d+(\.\d+)?$/.test(val))
1465
+ out[key] = Number(val);
1466
+ else
1467
+ out[key] = val;
1468
+ }
1469
+ return out;
1470
+ }
1223
1471
  /** A progress-line label for the target workspace. The token names the tenant, so
1224
1472
  * we surface at most the project (when pinned or overridden by `--project`). */
1225
1473
  function targetLabel(t) {
1226
1474
  return t.project ? `project '${t.project}'` : 'your workspace';
1227
1475
  }
1476
+ /** `limit` + `offset` as a query string, from the two universal list flags.
1477
+ * `--offset` exists so `morePageHint`'s "next page" advice is a command the
1478
+ * author can actually run — every list route already accepted the parameter
1479
+ * (`parsePaging`), the CLI just never sent it. */
1480
+ function pagingQs(flags, defaultLimit = 50) {
1481
+ const limit = flags.limit ?? String(defaultLimit);
1482
+ const offset = flags.offset ?? '';
1483
+ return `limit=${encodeURIComponent(limit)}${offset ? `&offset=${encodeURIComponent(offset)}` : ''}`;
1484
+ }
1485
+ /**
1486
+ * Verbs that mean "write", not "an entity named this".
1487
+ *
1488
+ * `octwin records <entity>` and `octwin records note <id>` both land in `_[0]`, so
1489
+ * the two shapes genuinely collide. Reserved words win, and they are listed (not
1490
+ * guessed) so the ambiguity is documented rather than emergent — a pack that
1491
+ * declares an entity actually called `note` reaches it via `--entity note`.
1492
+ */
1493
+ const RECORD_VERBS = new Set(['create', 'patch', 'stage', 'note', 'tasks', 'task']);
1228
1494
  /** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
1229
1495
  async function cmdRecords(flags) {
1496
+ // A leading reserved word is a write. To READ an entity whose name collides
1497
+ // with one, name it with the flag and pass no positional: `octwin records --entity note`.
1498
+ if (typeof flags._[0] === 'string' && RECORD_VERBS.has(flags._[0]))
1499
+ return cmdRecordsWrite(flags);
1230
1500
  const t = resolveTarget(flags);
1231
1501
  const { url } = t;
1232
1502
  const base = `${url}/api/self/p`;
1233
- const entity = flags._[0];
1503
+ const entity = (typeof flags.entity === 'string' ? flags.entity : flags._[0]);
1234
1504
  const recordId = flags._[1];
1235
1505
  console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${targetLabel(t)} …`);
1236
1506
  if (!entity) {
@@ -1255,8 +1525,7 @@ async function cmdRecords(flags) {
1255
1525
  return;
1256
1526
  }
1257
1527
  if (!recordId) {
1258
- const limit = flags.limit ?? '50';
1259
- const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, t);
1528
+ const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&${pagingQs(flags)}`, t);
1260
1529
  if (status !== 200) {
1261
1530
  // Always show the server's reason (it names the unknown entity). Cases are
1262
1531
  // casework (worklist), not pack-declared XRM — point at the right command.
@@ -1265,12 +1534,15 @@ async function cmdRecords(flags) {
1265
1534
  }
1266
1535
  die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1267
1536
  }
1268
- const rows = (json?.records ?? []);
1269
- console.log(`${entity}: ${json?.total ?? rows.length} record(s)`);
1270
- if (rows.length === 0)
1537
+ const page = readPage(json);
1538
+ console.log(`${entity}: ${page.total ?? page.rows.length} record(s)`);
1539
+ if (page.rows.length === 0)
1271
1540
  console.log(' (none — if you expected data, mint a `records:read` token and check `octwin deploy --seed`)');
1272
- for (const r of rows)
1541
+ for (const r of page.rows)
1273
1542
  console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'}${r.stage ? ` [${r.stage}]` : ''} ${r.id}`);
1543
+ const more = morePageHint(page, `octwin records ${entity}`);
1544
+ if (more)
1545
+ console.log(more);
1274
1546
  return;
1275
1547
  }
1276
1548
  const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`, t);
@@ -1280,6 +1552,186 @@ async function cmdRecords(flags) {
1280
1552
  die(`could not read record (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1281
1553
  console.log(JSON.stringify(json?.record ?? json, null, 2));
1282
1554
  }
1555
+ /**
1556
+ * The write half of `octwin records` — create / patch / stage / note, plus tasks.
1557
+ *
1558
+ * Every verb here needs `records:write` (tasks need the `tasks` plan feature, not
1559
+ * `records`), and every one is re-checked by RBAC against the specific record, so
1560
+ * failures route through `writeFail(..., rbacScoped)`.
1561
+ */
1562
+ async function cmdRecordsWrite(flags) {
1563
+ const t = resolveTarget(flags);
1564
+ const { url } = t;
1565
+ const base = `${url}/api/self/p`;
1566
+ const verb = flags._[0];
1567
+ const arg = flags._[1];
1568
+ const readBack = (id) => console.log(`\nRead it back: octwin records --entity <entity> ${id}`);
1569
+ if (verb === 'create') {
1570
+ const entity = arg ?? die('usage: octwin records create <entity> --set field=value …');
1571
+ const fields = fieldsFromFlags(flags);
1572
+ const body = { entity, fields };
1573
+ if (typeof flags.stage === 'string')
1574
+ body.stage = flags.stage;
1575
+ if (typeof flags.contact === 'string')
1576
+ body.contact_id = flags.contact;
1577
+ console.log(`→ Creating a ${entity} record in ${targetLabel(t)} …`);
1578
+ const { status, json } = await apiSend('POST', `${base}/xrm/records`, body, t);
1579
+ if (status !== 200 && status !== 201)
1580
+ writeFail(`create the ${entity} record`, status, json, url, true);
1581
+ if (json?.has_xrm === false)
1582
+ die('this pack declares no XRM entities');
1583
+ const rec = json?.record ?? {};
1584
+ // 200 = the dedupe key matched an existing row; 201 = genuinely new. Saying
1585
+ // "created" for a match would misreport what the pack's `dedupe_by` did.
1586
+ console.log(status === 201
1587
+ ? `✓ Created #${rec.record_number ?? '?'} ${rec.id ?? ''}`
1588
+ : `✓ Matched an EXISTING record (the entity's dedupe key hit) — #${rec.record_number ?? '?'} ${rec.id ?? ''}`);
1589
+ if (rec.id)
1590
+ readBack(rec.id);
1591
+ return;
1592
+ }
1593
+ if (verb === 'patch') {
1594
+ const id = arg ?? die('usage: octwin records patch <recordId> --entity <entity> --set field=value …');
1595
+ // The route requires `entity` even on an update — it resolves the validator
1596
+ // from it. A patch without it 400s server-side, so say it here instead.
1597
+ const entity = typeof flags.entity === 'string' ? flags.entity
1598
+ : die('octwin records patch needs --entity <entity> (the route resolves the field validator from it)');
1599
+ const fields = fieldsFromFlags(flags);
1600
+ if (Object.keys(fields).length === 0)
1601
+ die('nothing to patch — pass --set field=value (or --fields-json)');
1602
+ console.log(`→ Patching ${entity} ${id} in ${targetLabel(t)} …`);
1603
+ const { status, json } = await apiSend('PATCH', `${base}/xrm/records/${encodeURIComponent(id)}`, { entity, fields }, t);
1604
+ if (status !== 200)
1605
+ writeFail(`patch record ${id}`, status, json, url, true);
1606
+ console.log(`✓ Patched #${json?.record?.record_number ?? '?'}`);
1607
+ readBack(id);
1608
+ return;
1609
+ }
1610
+ if (verb === 'stage') {
1611
+ const id = arg ?? die('usage: octwin records stage <recordId> --to <stage> [--note "..."]');
1612
+ const to = typeof flags.to === 'string' ? flags.to : die('octwin records stage needs --to <stage>');
1613
+ const body = { to_stage: to };
1614
+ if (typeof flags.note === 'string')
1615
+ body.note = flags.note;
1616
+ console.log(`→ Moving ${id} to '${to}' in ${targetLabel(t)} …`);
1617
+ const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/stage`, body, t);
1618
+ if (status === 400 && Array.isArray(json?.allowed)) {
1619
+ // The route returns the legal targets on an illegal move — the single most
1620
+ // useful thing to show, so don't bury it in the generic error line.
1621
+ console.error(`✗ '${to}' is not a legal move from this record's stage.`);
1622
+ console.error(` → allowed: ${json.allowed.join(', ') || '(none — terminal stage)'}`);
1623
+ process.exit(1);
1624
+ }
1625
+ if (status !== 200)
1626
+ writeFail(`move record ${id} to '${to}'`, status, json, url, true);
1627
+ console.log(`✓ #${json?.record?.record_number ?? '?'} is now at '${json?.record?.stage ?? to}'`);
1628
+ return;
1629
+ }
1630
+ if (verb === 'note') {
1631
+ const id = arg ?? die('usage: octwin records note <recordId> "the note text"');
1632
+ const note = flags._[2] ?? die('octwin records note needs the note text as the last argument');
1633
+ console.log(`→ Adding a note to ${id} in ${targetLabel(t)} …`);
1634
+ const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/note`, { note }, t);
1635
+ if (status !== 200)
1636
+ writeFail(`note record ${id}`, status, json, url, true);
1637
+ console.log('✓ Note added to the record timeline.');
1638
+ return;
1639
+ }
1640
+ if (verb === 'tasks') {
1641
+ console.log(`→ Reading open tasks from ${targetLabel(t)} …`);
1642
+ const { status, json } = await apiGet(`${base}/xrm/tasks?${pagingQs(flags)}`, t);
1643
+ if (status !== 200)
1644
+ writeFail('read tasks', status, json, url);
1645
+ if (json?.has_xrm === false) {
1646
+ console.log('This pack declares no XRM entities.');
1647
+ return;
1648
+ }
1649
+ const page = readPage(json);
1650
+ console.log(`Tasks: ${page.total ?? page.rows.length}`);
1651
+ if (page.rows.length === 0)
1652
+ console.log(' (none open)');
1653
+ for (const k of page.rows) {
1654
+ console.log(` ${k.title ?? '(untitled)'}${k.due_at ? ` due:${k.due_at}` : ''}${k.status ? ` [${k.status}]` : ''} ${k.id}`);
1655
+ }
1656
+ const more = morePageHint(page, 'octwin records tasks');
1657
+ if (more)
1658
+ console.log(more);
1659
+ console.log('\nClose one: octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]');
1660
+ return;
1661
+ }
1662
+ if (verb === 'task') {
1663
+ if (arg !== 'complete')
1664
+ die('usage: octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]');
1665
+ const id = flags._[2] ?? die('octwin records task complete needs a <taskId>');
1666
+ const outcome = typeof flags.outcome === 'string' ? flags.outcome : 'done';
1667
+ if (outcome !== 'done' && outcome !== 'cancelled')
1668
+ die(`--outcome must be 'done' or 'cancelled' (got '${outcome}')`);
1669
+ const body = { outcome };
1670
+ if (typeof flags.note === 'string')
1671
+ body.note = flags.note;
1672
+ console.log(`→ Closing task ${id} as '${outcome}' …`);
1673
+ const { status, json } = await apiSend('POST', `${base}/xrm/tasks/${encodeURIComponent(id)}/complete`, body, t);
1674
+ if (status === 404)
1675
+ die(`task '${id}' not found, or already closed`);
1676
+ if (status !== 200)
1677
+ writeFail(`complete task ${id}`, status, json, url, true);
1678
+ console.log(`✓ Task closed (${outcome}).`);
1679
+ return;
1680
+ }
1681
+ die(`unknown records verb '${verb}' — one of: ${[...RECORD_VERBS].join(', ')}`);
1682
+ }
1683
+ /**
1684
+ * `octwin feedback [--dir .]` — submit the pack's `FEEDBACK.md` to the platform.
1685
+ *
1686
+ * The authoring skill's last step asks for a report bucketed by owner (CLI /
1687
+ * platform / KB). It used to end there: delivery was copy-paste into a chat, so a
1688
+ * report only counted if the author happened to hand it over.
1689
+ *
1690
+ * Attaches the two facts that decide triage and that nobody remembers to state —
1691
+ * the CLI version, and the `content_hash` of the capability reference the author
1692
+ * actually pulled. Most field reports so far were either already fixed in a newer
1693
+ * CLI or written against a stale KB, and both are one line each here.
1694
+ */
1695
+ async function cmdFeedback(flags) {
1696
+ const packDir = resolve(flags.dir ?? '.');
1697
+ const t = resolveTarget(flags);
1698
+ const { url } = t;
1699
+ const reportPath = join(packDir, 'FEEDBACK.md');
1700
+ if (!existsSync(reportPath)) {
1701
+ die(`no FEEDBACK.md in ${packDir}\n`
1702
+ + ' → the octwin-pack skill writes one in Step 4 (Report your authoring experience).\n'
1703
+ + ' Group findings by owner — A · CLI, B · Platform, C · Skill/KB — then run this again.');
1704
+ }
1705
+ const report = readFileSync(reportPath, 'utf8');
1706
+ if (!report.trim())
1707
+ die('FEEDBACK.md is empty — nothing to submit');
1708
+ // Pack identity from the manifest, not from the report's prose: the metadata
1709
+ // block is a convention the skill owns and an author may reword it.
1710
+ const manifestPath = join(packDir, 'manifest.yaml');
1711
+ if (!existsSync(manifestPath))
1712
+ die('no manifest.yaml in the pack directory (run from your pack dir or pass --dir)');
1713
+ const doc = parseYaml(readFileSync(manifestPath, 'utf8'));
1714
+ const packId = typeof doc?.id === 'string' ? doc.id : die('manifest.yaml must declare a string `id`');
1715
+ const packVersion = typeof doc?.version === 'string' ? doc.version : undefined;
1716
+ const kbHash = readLocalKb(packDir)?.content_hash ?? undefined;
1717
+ console.log(`→ Submitting ${Math.round(Buffer.byteLength(report, 'utf8') / 1024)}KB of feedback on ${packId} to ${targetLabel(t)} …`);
1718
+ const { status, json } = await apiSend('POST', `${url}/api/self/p/packs/feedback`, {
1719
+ pack_id: packId,
1720
+ ...(packVersion ? { pack_version: packVersion } : {}),
1721
+ report_md: report,
1722
+ cli_version: VERSION,
1723
+ ...(kbHash ? { kb_content_hash: kbHash } : {}),
1724
+ }, t);
1725
+ if (status !== 201 && status !== 200)
1726
+ writeFail('submit feedback', status, json, url);
1727
+ console.log('✓ Thanks — your report reached the platform team.');
1728
+ if (!kbHash) {
1729
+ // Without it, triage cannot tell "the platform is wrong" from "you were
1730
+ // reading a stale reference", which is the single most common answer.
1731
+ console.log(' ⓘ no local capability reference found, so the report carries no KB version.');
1732
+ console.log(' Pull it before your next session: octwin platform-kb');
1733
+ }
1734
+ }
1283
1735
  /** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
1284
1736
  * or show one's event timeline (full text + the renders each turn produced). */
1285
1737
  async function cmdLogs(flags) {
@@ -1754,9 +2206,106 @@ async function cmdMedia(flags) {
1754
2206
  console.log(` saved → ${out}`);
1755
2207
  console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
1756
2208
  }
2209
+ /** Reserved leading words on `octwin cases` — see `RECORD_VERBS` for the rule. */
2210
+ const CASE_VERBS = new Set(['assign', 'note', 'transition', 'decide']);
2211
+ /**
2212
+ * The write half of `octwin cases` — assign / note / transition / decide.
2213
+ *
2214
+ * `decide --dry-run` routes to the PREVIEW endpoint, which sits behind `cases:read`
2215
+ * rather than `cases:write`: it renders the customer-facing copy and the resulting
2216
+ * status without committing. That makes "show me what this disposition would do"
2217
+ * safe to run with a read-only token, which is exactly when an author wants it.
2218
+ */
2219
+ async function cmdCasesWrite(flags) {
2220
+ const t = resolveTarget(flags);
2221
+ const { url } = t;
2222
+ const base = `${url}/api/self/p`;
2223
+ const verb = flags._[0];
2224
+ const id = flags._[1] ?? die(`usage: octwin cases ${verb} <caseId> …`);
2225
+ const readBack = () => console.log(`\nRead it back: octwin cases ${id}`);
2226
+ if (verb === 'assign') {
2227
+ // `--to none` unassigns (the route takes null); anything else must carry the
2228
+ // principal kind, because a bare uuid cannot say user-or-team.
2229
+ const to = typeof flags.to === 'string' ? flags.to
2230
+ : die('usage: octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none');
2231
+ const assignee = to === 'none' ? null : to;
2232
+ if (assignee !== null && !/^(user|team):/.test(assignee)) {
2233
+ die(`--to must be 'user:<uuid>', 'team:<uuid>' or 'none' (got '${to}')`);
2234
+ }
2235
+ console.log(`→ ${assignee === null ? 'Unassigning' : `Assigning to ${assignee}`} case ${id} …`);
2236
+ const { status, json } = await apiSend('PATCH', `${base}/cases/${encodeURIComponent(id)}/assign`, { assignee }, t);
2237
+ if (status !== 200)
2238
+ writeFail(`assign case ${id}`, status, json, url, true);
2239
+ console.log(assignee === null ? '✓ Unassigned.' : `✓ Assigned to ${json?.assignee ?? assignee}.`);
2240
+ return;
2241
+ }
2242
+ if (verb === 'note') {
2243
+ const note = flags._[2] ?? die('usage: octwin cases note <caseId> "the note text"');
2244
+ console.log(`→ Adding a note to case ${id} …`);
2245
+ const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/note`, { note }, t);
2246
+ if (status !== 200)
2247
+ writeFail(`note case ${id}`, status, json, url, true);
2248
+ console.log('✓ Note added to the case timeline.');
2249
+ readBack();
2250
+ return;
2251
+ }
2252
+ if (verb === 'transition') {
2253
+ const to = typeof flags.to === 'string' ? flags.to
2254
+ : die('usage: octwin cases transition <caseId> --to <status> [--note "..."]');
2255
+ const body = { to_status: to };
2256
+ if (typeof flags.note === 'string')
2257
+ body.note = flags.note;
2258
+ console.log(`→ Moving case ${id} to '${to}' …`);
2259
+ const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/transition`, body, t);
2260
+ if (status !== 200) {
2261
+ // The case detail read carries the legal targets; point at it rather than
2262
+ // leaving the author to guess the vocabulary.
2263
+ if (status === 400)
2264
+ console.error(` → legal targets for this case: octwin cases ${id} (see its workflow)`);
2265
+ writeFail(`move case ${id} to '${to}'`, status, json, url, true);
2266
+ }
2267
+ console.log(`✓ Case is now '${json?.case?.status ?? to}'.`);
2268
+ return;
2269
+ }
2270
+ // decide
2271
+ const action = typeof flags.action === 'string' ? flags.action
2272
+ : die('usage: octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]');
2273
+ const params = {};
2274
+ for (const pair of flagList(flags, 'param')) {
2275
+ const eq = pair.indexOf('=');
2276
+ if (eq <= 0)
2277
+ die(`--param expects key=value (got '${pair}')`);
2278
+ params[pair.slice(0, eq)] = pair.slice(eq + 1);
2279
+ }
2280
+ const dryRun = flags['dry-run'] === true;
2281
+ const body = { action, ...(Object.keys(params).length ? { params } : {}) };
2282
+ if (!dryRun && typeof flags.note === 'string')
2283
+ body.internal_note = flags.note;
2284
+ console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on case ${id} …`);
2285
+ const endpoint = `${base}/cases/${encodeURIComponent(id)}/decision${dryRun ? '/preview' : ''}`;
2286
+ const { status, json } = await apiSend('POST', endpoint, body, t);
2287
+ if (status === 404)
2288
+ die(`case '${id}' not found`);
2289
+ if (status !== 200) {
2290
+ console.error(` → the case's applicable actions are listed by: octwin cases ${id}`);
2291
+ writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on case ${id}`, status, json, url, true);
2292
+ }
2293
+ if (dryRun) {
2294
+ console.log('Preview (nothing was committed):');
2295
+ console.log(JSON.stringify(json, null, 2));
2296
+ return;
2297
+ }
2298
+ console.log(`✓ Applied '${action}' — case is now '${json?.case?.status ?? '?'}'.`);
2299
+ // `notified` is the customer-facing half; silence here usually means the
2300
+ // disposition had no message template, which is easy to mistake for a failure.
2301
+ console.log(json?.notified ? ' ✓ the customer was notified.' : ' ⓘ no customer notification was sent by this action.');
2302
+ readBack();
2303
+ }
1757
2304
  /** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
1758
2305
  * the aggregate inbox, one case + its timeline, or the queue list. */
1759
2306
  async function cmdCases(flags) {
2307
+ if (typeof flags._[0] === 'string' && CASE_VERBS.has(flags._[0]))
2308
+ return cmdCasesWrite(flags);
1760
2309
  const t = resolveTarget(flags);
1761
2310
  const { url } = t;
1762
2311
  const base = `${url}/api/self/p`;
@@ -1788,22 +2337,24 @@ async function cmdCases(flags) {
1788
2337
  return;
1789
2338
  }
1790
2339
  if (!caseId) {
1791
- const limit = flags.limit ?? '50';
1792
- const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, t);
2340
+ const { status, json } = await apiGet(`${base}/cases?${pagingQs(flags)}`, t);
1793
2341
  if (status !== 200)
1794
2342
  caseFail('cases', status, json);
1795
2343
  if (asJson) {
1796
2344
  console.log(JSON.stringify(json, null, 2));
1797
2345
  return;
1798
2346
  }
1799
- const rows = (json?.cases ?? []);
1800
- console.log(`Cases in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
1801
- if (rows.length === 0)
2347
+ const page = readPage(json);
2348
+ console.log(`Cases in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
2349
+ if (page.rows.length === 0)
1802
2350
  console.log(' (none)');
1803
- for (const c of rows) {
2351
+ for (const c of page.rows) {
1804
2352
  const sla = c.sla_due_at ? ` sla:${c.sla_due_at}` : '';
1805
2353
  console.log(` #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}${c.queue_key ? ` q:${c.queue_key}` : ''}${sla} ${c.id}`);
1806
2354
  }
2355
+ const more = morePageHint(page, 'octwin cases');
2356
+ if (more)
2357
+ console.log(more);
1807
2358
  console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
1808
2359
  return;
1809
2360
  }
@@ -1898,7 +2449,119 @@ function printGoverned(label, g) {
1898
2449
  /** `octwin agents [agentRef] [--prompt] [--json]` — the agent roster with the
1899
2450
  * EFFECTIVE model/memory settings and which layer won, plus (`--prompt`) the exact
1900
2451
  * system prompt the LLM sees for this project. Needs an `agents:read` token. */
2452
+ /**
2453
+ * `octwin agents set <ref> …` — the per-project agent override row.
2454
+ *
2455
+ * Writes only what was passed, so an unmentioned setting is left alone rather than
2456
+ * reset to a default. `--enable-tool` / `--disable-tool` edit `config_json.tools`,
2457
+ * a `{ toolId: boolean }` map where absent means ON — so disabling is the only
2458
+ * thing that needs recording, and the map is read-modify-written to avoid dropping
2459
+ * a sibling entry.
2460
+ */
2461
+ async function cmdAgentsWrite(flags) {
2462
+ const t = resolveTarget(flags);
2463
+ const { url } = t;
2464
+ const base = `${url}/api/self/p/agents`;
2465
+ const ref = flags._[1]
2466
+ ?? die('usage: octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t] [--enabled true|false]');
2467
+ const patch = {};
2468
+ if (typeof flags.model === 'string')
2469
+ patch.model = flags.model;
2470
+ if (typeof flags.overlay === 'string')
2471
+ patch.instructions_overlay = flags.overlay === 'none' ? null : flags.overlay;
2472
+ if (typeof flags.enabled === 'string') {
2473
+ if (flags.enabled !== 'true' && flags.enabled !== 'false')
2474
+ die("--enabled must be 'true' or 'false'");
2475
+ patch.enabled = flags.enabled === 'true';
2476
+ }
2477
+ const on = flagList(flags, 'enable-tool');
2478
+ const off = flagList(flags, 'disable-tool');
2479
+ if (on.length || off.length) {
2480
+ // Read first: `config_json` is replaced wholesale by the route, so a blind
2481
+ // write would drop every tool decision not named on this command line.
2482
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
2483
+ if (status === 404)
2484
+ die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
2485
+ if (status !== 200)
2486
+ writeFail(`read agent ${ref}`, status, json, url);
2487
+ const cfg = { ...(json?.agent?.config_json ?? json?.config_json ?? {}) };
2488
+ const tools = { ...(cfg.tools ?? {}) };
2489
+ const known = (json?.agent?.available_tools ?? json?.available_tools);
2490
+ for (const id of [...on, ...off]) {
2491
+ if (Array.isArray(known) && known.length && !known.includes(id)) {
2492
+ die(`'${id}' is not a tool on ${ref} — available: ${known.join(', ')}`);
2493
+ }
2494
+ }
2495
+ for (const id of on)
2496
+ tools[id] = true;
2497
+ for (const id of off)
2498
+ tools[id] = false;
2499
+ cfg.tools = tools;
2500
+ patch.config_json = cfg;
2501
+ }
2502
+ if (Object.keys(patch).length === 0) {
2503
+ die('nothing to change — pass --model, --enabled, --overlay, --enable-tool or --disable-tool');
2504
+ }
2505
+ console.log(`→ Updating agent ${ref} in ${targetLabel(t)} …`);
2506
+ const { status, json } = await apiSend('PATCH', `${base}/${encodeURIComponent(ref)}`, patch, t);
2507
+ if (status === 404)
2508
+ die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
2509
+ if (status === 403 && patch.model !== undefined) {
2510
+ die(`this workspace does not expose model overrides — drop --model (the platform default governs)${errDetail(json)}`);
2511
+ }
2512
+ if (status !== 200)
2513
+ writeFail(`update agent ${ref}`, status, json, url);
2514
+ console.log(`✓ Updated ${ref}.`);
2515
+ console.log(`\nRead it back (and see WHICH layer won): octwin agents ${ref}`);
2516
+ }
2517
+ /**
2518
+ * `octwin projects` — which `--project <slug>` values this token can actually name.
2519
+ *
2520
+ * Every project-scoped command takes a `--project` slug, and until now nothing
2521
+ * printed the list: an author whose token was not pinned had to guess, and a wrong
2522
+ * guess 404s identically to a project that exists but has no install. Tenant-scoped
2523
+ * (`/api/self/t/`), unlike `agents` — the list is a property of the workspace.
2524
+ */
2525
+ async function cmdProjects(flags) {
2526
+ const t = resolveTarget(flags);
2527
+ const { url } = t;
2528
+ const asJson = flags.json === true;
2529
+ const archived = flags.archived === true;
2530
+ if (!asJson)
2531
+ console.log(`→ Reading projects from ${targetLabel(t)} …`);
2532
+ const qs = archived ? '?include_archived=1' : '';
2533
+ const { status, json } = await apiGet(`${url}/api/self/t/projects${qs}`, t);
2534
+ if (status !== 200)
2535
+ die(`could not read projects (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
2536
+ if (asJson) {
2537
+ console.log(JSON.stringify(json, null, 2));
2538
+ return;
2539
+ }
2540
+ const projects = (json?.projects ?? []);
2541
+ if (projects.length === 0) {
2542
+ console.log(archived
2543
+ ? 'No projects at all — create one in the console (Workspace → Projects).'
2544
+ : 'No active projects. Try `octwin projects --archived` before creating one.');
2545
+ return;
2546
+ }
2547
+ // The route already computes the plan cap; inventing a second "N of M" here
2548
+ // would drift from the 402 the POST handler actually enforces.
2549
+ const max = json?.limits?.max_projects ?? null;
2550
+ const plan = json?.limits?.plan_label ? ` · ${json.limits.plan_label} plan` : '';
2551
+ console.log(`Projects in ${json?.tenant?.slug ?? targetLabel(t)} — ${projects.length}${max ? ` of ${max}` : ''}${plan}:`);
2552
+ for (const p of projects) {
2553
+ const flags_ = [];
2554
+ if (p.status && p.status !== 'active')
2555
+ flags_.push(p.status.toUpperCase());
2556
+ console.log(` ${p.slug}${flags_.length ? ` [${flags_.join(', ')}]` : ''}${p.name ? ` "${p.name}"` : ''}`);
2557
+ }
2558
+ console.log('\nUse one as: octwin deploy --project <slug>');
2559
+ if (!archived)
2560
+ console.log('Archived too: octwin projects --archived');
2561
+ }
1901
2562
  async function cmdAgents(flags) {
2563
+ if (flags._[0] === 'set')
2564
+ return cmdAgentsWrite(flags);
1902
2565
  const t = resolveTarget(flags);
1903
2566
  const { url } = t;
1904
2567
  const base = `${url}/api/self/p/agents`;
@@ -1995,7 +2658,77 @@ function printPaymentNote(paymentStatus) {
1995
2658
  /** `octwin orders [referenceId] [--status s] [--payment p] [--limit n] [--json]` —
1996
2659
  * the orders a conversation created: money breakdown, payment state, allowed
1997
2660
  * transitions. Needs an `orders:read` token + the `orders` plan feature. */
2661
+ /** Reserved leading words on `octwin orders`. A reference_id is opaque but never these. */
2662
+ const ORDER_VERBS = new Set(['transition', 'refund']);
2663
+ /**
2664
+ * The write half of `octwin orders` — fulfilment transitions and refunds.
2665
+ *
2666
+ * `payment_status` is deliberately NOT settable: the forward payment lifecycle is
2667
+ * webhook-owned, which is why `pending` on a gateway-less workspace is expected
2668
+ * rather than a bug (`printPaymentNote`).
2669
+ */
2670
+ async function cmdOrdersWrite(flags) {
2671
+ const t = resolveTarget(flags);
2672
+ const { url } = t;
2673
+ const base = `${url}/api/self/p/orders`;
2674
+ const verb = flags._[0];
2675
+ const ref = flags._[1]
2676
+ ?? die(`usage: octwin orders ${verb} <reference_id> … (the opaque reference_id, not the #number)`);
2677
+ if (verb === 'transition') {
2678
+ const to = typeof flags.to === 'string' ? flags.to
2679
+ : die('usage: octwin orders transition <reference_id> --to <status>');
2680
+ console.log(`→ Moving order ${ref} to '${to}' …`);
2681
+ const { status, json } = await apiSend('POST', `${base}/${encodeURIComponent(ref)}/transition`, { to_status: to }, t);
2682
+ if (status === 404)
2683
+ die(`order '${ref}' not found (pass the opaque reference_id, not the #number)`);
2684
+ if (status === 409) {
2685
+ console.error(`✗ '${to}' is not a legal move for this order.`);
2686
+ if (Array.isArray(json?.transitions))
2687
+ console.error(` → allowed: ${json.transitions.join(', ') || '(none)'}`);
2688
+ else
2689
+ console.error(` → see the allowed set: octwin orders ${ref}`);
2690
+ process.exit(1);
2691
+ }
2692
+ if (status !== 200)
2693
+ writeFail(`move order ${ref} to '${to}'`, status, json, url);
2694
+ console.log(`✓ Order is now '${json?.order?.status ?? to}'.`);
2695
+ return;
2696
+ }
2697
+ // refund — irreversible, and money. `--force` rather than a prompt: the CLI is
2698
+ // non-interactive by construction (same reasoning as `octwin pull --force`).
2699
+ if (flags.force !== true) {
2700
+ die(`refunding is irreversible and moves money — re-run with --force:\n octwin orders refund ${ref} --force`);
2701
+ }
2702
+ const body = {};
2703
+ if (typeof flags.reason === 'string')
2704
+ body.reason = flags.reason;
2705
+ if (flags['mark-returned'] === true)
2706
+ body.mark_returned = true;
2707
+ console.log(`→ Refunding order ${ref} …`);
2708
+ const { status, json } = await apiSend('POST', `${base}/${encodeURIComponent(ref)}/refund`, body, t);
2709
+ if (status === 404)
2710
+ die(`order '${ref}' not found`);
2711
+ if (status === 409)
2712
+ die(`order '${ref}' cannot be refunded — only a payment in 'captured' state can be${errDetail(json)}`);
2713
+ if (status !== 200)
2714
+ writeFail(`refund order ${ref}`, status, json, url);
2715
+ // THE trap: the route answers 200 even when the gateway REFUSED — the verdict
2716
+ // is in `gateway`. Reporting the 200 as success would tell an operator money
2717
+ // moved when it did not, so read the gateway result and exit non-zero on refusal.
2718
+ const gw = json?.gateway;
2719
+ const refused = gw != null && (gw.ok === false || gw.status === 'failed' || gw.status === 'error');
2720
+ console.log(` order status : ${json?.order?.status ?? '?'} / ${json?.order?.payment_status ?? '?'}`);
2721
+ if (gw != null)
2722
+ console.log(` gateway : ${gw.status ?? (gw.ok === false ? 'failed' : 'ok')}${gw.error || gw.message ? ` — ${gw.error ?? gw.message}` : ''}`);
2723
+ if (refused) {
2724
+ console.error('\n✗ the PAYMENT GATEWAY refused the refund — the order was updated but no money moved.');
2725
+ process.exit(1);
2726
+ }
2727
+ console.log('✓ Refund accepted.');
2728
+ }
1998
2729
  async function cmdOrders(flags) {
2730
+ if (typeof flags._[0] === 'string' && ORDER_VERBS.has(flags._[0]))
2731
+ return cmdOrdersWrite(flags);
1999
2732
  const t = resolveTarget(flags);
2000
2733
  const { url } = t;
2001
2734
  const base = `${url}/api/self/p/orders`;
@@ -2005,6 +2738,8 @@ async function cmdOrders(flags) {
2005
2738
  console.log(`→ Reading ${referenceId ? `order ${referenceId}` : 'orders'} from ${targetLabel(t)} …`);
2006
2739
  if (!referenceId) {
2007
2740
  const q = new URLSearchParams({ limit: flags.limit ?? '50' });
2741
+ if (typeof flags.offset === 'string')
2742
+ q.set('offset', flags.offset);
2008
2743
  if (typeof flags.status === 'string')
2009
2744
  q.set('status', flags.status);
2010
2745
  if (typeof flags.payment === 'string')
@@ -2016,14 +2751,17 @@ async function cmdOrders(flags) {
2016
2751
  console.log(JSON.stringify(json, null, 2));
2017
2752
  return;
2018
2753
  }
2019
- const rows = (json?.orders ?? []);
2020
- console.log(`Orders in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
2021
- if (rows.length === 0)
2754
+ const page = readPage(json);
2755
+ console.log(`Orders in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
2756
+ if (page.rows.length === 0)
2022
2757
  console.log(' (none — drive a cart to `cart_submit` with `octwin chat`, or seed demo data)');
2023
- for (const o of rows) {
2758
+ for (const o of page.rows) {
2024
2759
  const who = o.contact?.channel_contact_handle ?? o.contact?.display_name ?? '—';
2025
2760
  console.log(` #${o.record_number} ${o.status}/${o.payment_status} ${fmtMinor(o.total_minor, o.currency)} ${who} ${o.reference_id}`);
2026
2761
  }
2762
+ const more = morePageHint(page, 'octwin orders');
2763
+ if (more)
2764
+ console.log(more);
2027
2765
  console.log('\nOne order + its money breakdown: octwin orders <reference_id>');
2028
2766
  return;
2029
2767
  }
@@ -2107,8 +2845,7 @@ async function cmdAnalytics(flags) {
2107
2845
  if (stage) {
2108
2846
  if (!asJson)
2109
2847
  console.log(`→ Reading ${entity} records at stage '${stage}' from ${targetLabel(t)} …`);
2110
- const limit = flags.limit ?? '50';
2111
- const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/stages/${encodeURIComponent(stage)}/records?limit=${limit}`, t);
2848
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/stages/${encodeURIComponent(stage)}/records?${pagingQs(flags)}`, t);
2112
2849
  if (status === 404)
2113
2850
  die(`unknown stage '${stage}' for '${entity}'${errDetail(json)}`);
2114
2851
  if (status !== 200)
@@ -2121,12 +2858,15 @@ async function cmdAnalytics(flags) {
2121
2858
  printNoAnalyticsData(entity);
2122
2859
  return;
2123
2860
  }
2124
- const rows = (json?.records ?? []);
2125
- console.log(`${entity} at '${stage}' (live snapshot): ${json?.total ?? rows.length} record(s)`);
2126
- for (const r of rows) {
2861
+ const page = readPage(json);
2862
+ console.log(`${entity} at '${stage}' (live snapshot): ${page.total ?? page.rows.length} record(s)`);
2863
+ for (const r of page.rows) {
2127
2864
  const who = r.channel_contact_handle ?? r.display_name ?? '—';
2128
2865
  console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'} ${who}${r.completed ? ' [completed]' : ''} ${r.record_id}`);
2129
2866
  }
2867
+ const more = morePageHint(page, `octwin analytics ${entity} --stage ${stage}`);
2868
+ if (more)
2869
+ console.log(more);
2130
2870
  return;
2131
2871
  }
2132
2872
  if (!asJson)
@@ -2187,10 +2927,67 @@ async function cmdAnalytics(flags) {
2187
2927
  }
2188
2928
  }
2189
2929
  // ── catalog: the commerce products + their WhatsApp binding ──────────────────
2930
+ /** Reserved leading words on `octwin catalog`. */
2931
+ const CATALOG_VERBS = new Set(['availability', 'stock']);
2932
+ /**
2933
+ * The write half of `octwin catalog` — the two per-SKU levers a pack author needs
2934
+ * to exercise a commerce flow (is it sellable, and how many are there).
2935
+ *
2936
+ * Creating/deleting products and the Meta Graph binding/sync/pull are deliberately
2937
+ * NOT here — see docs/BACKLOG.md. Those are catalog *operations*, need a bound
2938
+ * access token to be meaningful, and belong to the console.
2939
+ */
2940
+ async function cmdCatalogWrite(flags) {
2941
+ const t = resolveTarget(flags);
2942
+ const { url } = t;
2943
+ const base = `${url}/api/self/p/catalog`;
2944
+ const verb = flags._[0];
2945
+ const sku = flags._[1] ?? die(`usage: octwin catalog ${verb} <retailerId> …`);
2946
+ if (verb === 'availability') {
2947
+ const to = typeof flags.to === 'string' ? flags.to
2948
+ : die("usage: octwin catalog availability <retailerId> --to 'in stock'|'out of stock'|…");
2949
+ console.log(`→ Setting ${sku} availability to '${to}' …`);
2950
+ const { status, json } = await apiSend('PATCH', `${base}/${encodeURIComponent(sku)}/availability`, { availability: to }, t);
2951
+ if (status !== 200)
2952
+ writeFail(`set availability for '${sku}'`, status, json, url);
2953
+ console.log(`✓ ${sku} is now '${to}'.`);
2954
+ return;
2955
+ }
2956
+ // stock — read when no --set-on-hand, write when there is.
2957
+ const raw = flags['set-on-hand'];
2958
+ if (raw === undefined) {
2959
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(sku)}/stock`, t);
2960
+ if (status === 404)
2961
+ die(`product '${sku}' not found`);
2962
+ if (status !== 200)
2963
+ writeFail(`read stock for '${sku}'`, status, json, url);
2964
+ // null is a real answer, and a different one from zero: the SKU is not
2965
+ // inventory-tracked, so it is always sellable.
2966
+ console.log(json?.stock == null
2967
+ ? `${sku}: not inventory-tracked (always sellable)`
2968
+ : `${sku}: on_hand=${json.stock.on_hand ?? '?'} reserved=${json.stock.reserved ?? 0}`);
2969
+ return;
2970
+ }
2971
+ const onHand = Number(raw);
2972
+ if (!Number.isInteger(onHand) || onHand < 0)
2973
+ die(`--set-on-hand must be a non-negative integer (got '${String(raw)}')`);
2974
+ console.log(`→ Setting ${sku} on_hand to ${onHand} …`);
2975
+ const { status, json } = await apiSend('PUT', `${base}/${encodeURIComponent(sku)}/stock`, { on_hand: onHand }, t);
2976
+ if (status === 404)
2977
+ die(`product '${sku}' not found`);
2978
+ if (status === 409) {
2979
+ die(`refused: ${onHand} is below the units already RESERVED for open carts/orders${errDetail(json)}`);
2980
+ }
2981
+ if (status !== 200)
2982
+ writeFail(`set stock for '${sku}'`, status, json, url);
2983
+ console.log(`✓ ${sku}: on_hand=${json?.stock?.on_hand ?? onHand} reserved=${json?.stock?.reserved ?? 0}`);
2984
+ }
2190
2985
  /** `octwin catalog [--readiness] [--json]` — the `product` records a commerce pack
2191
2986
  * sells, their stock, and the WhatsApp catalog binding. Needs `catalog:read` + the
2192
2987
  * `catalog` plan feature. */
2193
2988
  async function cmdCatalog(flags) {
2989
+ if (typeof flags._[0] === 'string' && CATALOG_VERBS.has(flags._[0]))
2990
+ return cmdCatalogWrite(flags);
2194
2991
  const t = resolveTarget(flags);
2195
2992
  const { url } = t;
2196
2993
  const base = `${url}/api/self/p/catalog`;
@@ -2221,15 +3018,17 @@ async function cmdCatalog(flags) {
2221
3018
  }
2222
3019
  if (!asJson)
2223
3020
  console.log(`→ Reading the product catalog from ${targetLabel(t)} …`);
2224
- const { status, json } = await apiGet(base, t);
3021
+ const { status, json } = await apiGet(`${base}?${pagingQs(flags)}`, t);
2225
3022
  if (status !== 200)
2226
3023
  die(`could not read the catalog (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
2227
3024
  if (asJson) {
2228
3025
  console.log(JSON.stringify(json, null, 2));
2229
3026
  return;
2230
3027
  }
2231
- const products = (json?.products ?? []);
2232
- console.log(`Products in ${targetLabel(t)}: ${products.length}`);
3028
+ const page = readPage(json);
3029
+ const products = page.rows;
3030
+ // The route reports the catalog-wide total; `products.length` is only this page.
3031
+ console.log(`Products in ${targetLabel(t)}: ${page.total ?? products.length}`);
2233
3032
  if (products.length === 0)
2234
3033
  console.log(' (none — a commerce pack seeds `product` records, or add them in the console Catalog)');
2235
3034
  for (const p of products) {
@@ -2238,6 +3037,9 @@ async function cmdCatalog(flags) {
2238
3037
  console.log(` ${String(p.retailer_id).padEnd(20)} ${String(p.name ?? '').padEnd(28)} ${fmtAmount(p.price, p.currency)}`
2239
3038
  + ` avail=${p.availability} stock=${stock} sync=${p.sync_status ?? '—'}`);
2240
3039
  }
3040
+ const morePages = morePageHint(page, 'octwin catalog');
3041
+ if (morePages)
3042
+ console.log(morePages);
2241
3043
  // A binding row can exist with no catalog_id yet (a WABA is configured but no Meta
2242
3044
  // catalog picked) — that is "not bound" for selling purposes, so say so.
2243
3045
  const b = json?.binding;
@@ -2247,10 +3049,121 @@ async function cmdCatalog(flags) {
2247
3049
  + ' — the catalog works web-only (`--readiness` explains what Meta needs).');
2248
3050
  }
2249
3051
  // ── scheduling: the availability engine + a slot preview ─────────────────────
3052
+ /** Reserved leading words on `octwin scheduling`. */
3053
+ const SCHEDULING_VERBS = new Set(['rules', 'rule', 'exception']);
3054
+ /**
3055
+ * The write half of `octwin scheduling` — the availability rules and exceptions
3056
+ * behind the slots `octwin scheduling --slots` computes.
3057
+ *
3058
+ * `rules` (the LIST) ships with them on purpose: the deletes take a rule id, and
3059
+ * without a way to see one there was no path from "a rule exists" to "remove it".
3060
+ */
3061
+ async function cmdSchedulingWrite(flags) {
3062
+ const t = resolveTarget(flags);
3063
+ const { url } = t;
3064
+ const base = `${url}/api/self/p/scheduling`;
3065
+ const verb = flags._[0];
3066
+ const asJson = flags.json === true;
3067
+ if (verb === 'rules') {
3068
+ const resource = typeof flags.resource === 'string' ? flags.resource
3069
+ : die('usage: octwin scheduling rules --resource <resourceRecordId>');
3070
+ if (!asJson)
3071
+ console.log(`→ Reading availability for resource ${resource} …`);
3072
+ const { status, json } = await apiGet(`${base}/availability?resource_id=${encodeURIComponent(resource)}`, t);
3073
+ if (status !== 200)
3074
+ writeFail('read availability', status, json, url);
3075
+ if (asJson) {
3076
+ console.log(JSON.stringify(json, null, 2));
3077
+ return;
3078
+ }
3079
+ if (json?.has_scheduling === false) {
3080
+ console.log('This pack declares no scheduling.');
3081
+ return;
3082
+ }
3083
+ const rules = (json?.rules ?? []);
3084
+ const exceptions = (json?.exceptions ?? []);
3085
+ const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
3086
+ console.log(`Rules: ${rules.length}`);
3087
+ for (const r of rules) {
3088
+ console.log(` ${DOW[r.dow] ?? `dow${r.dow}`} ${r.start_time}–${r.end_time}`
3089
+ + ` slot=${r.slot_minutes ?? '—'}m cap=${r.capacity ?? '—'} ${r.id}`);
3090
+ }
3091
+ console.log(`Exceptions: ${exceptions.length}`);
3092
+ for (const e of exceptions) {
3093
+ console.log(` ${e.exception_date} ${e.kind}${e.start_time ? ` ${e.start_time}–${e.end_time}` : ''} ${e.id}`);
3094
+ }
3095
+ console.log('\nRemove one: octwin scheduling rule rm <ruleId> · octwin scheduling exception rm <exceptionId>');
3096
+ return;
3097
+ }
3098
+ const sub = flags._[1];
3099
+ const isRule = verb === 'rule';
3100
+ const noun = isRule ? 'rule' : 'exception';
3101
+ const path = isRule ? 'rules' : 'exceptions';
3102
+ if (sub === 'rm') {
3103
+ const id = flags._[2] ?? die(`usage: octwin scheduling ${noun} rm <${noun}Id>`);
3104
+ console.log(`→ Removing ${noun} ${id} …`);
3105
+ const { status, json } = await apiSend('DELETE', `${base}/availability/${path}/${encodeURIComponent(id)}`, undefined, t);
3106
+ if (status === 404)
3107
+ die(`${noun} '${id}' not found`);
3108
+ if (status !== 200)
3109
+ writeFail(`remove ${noun} ${id}`, status, json, url);
3110
+ console.log(`✓ ${noun[0].toUpperCase()}${noun.slice(1)} removed.`);
3111
+ return;
3112
+ }
3113
+ if (sub !== 'add') {
3114
+ die(isRule
3115
+ ? 'usage: octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00 [--slot-minutes 30] [--capacity 1]\n octwin scheduling rule rm <ruleId>'
3116
+ : 'usage: octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra [--start 09:00 --end 13:00]\n octwin scheduling exception rm <exceptionId>');
3117
+ }
3118
+ const resource = typeof flags.resource === 'string' ? flags.resource
3119
+ : die(`octwin scheduling ${noun} add needs --resource <resourceRecordId>`);
3120
+ const body = { resource_id: resource };
3121
+ if (typeof flags['slot-minutes'] === 'string')
3122
+ body.slot_minutes = Number(flags['slot-minutes']);
3123
+ if (typeof flags.capacity === 'string')
3124
+ body.capacity = Number(flags.capacity);
3125
+ if (typeof flags.start === 'string')
3126
+ body.start_time = flags.start;
3127
+ if (typeof flags.end === 'string')
3128
+ body.end_time = flags.end;
3129
+ if (isRule) {
3130
+ const dowRaw = flags.dow;
3131
+ if (typeof dowRaw !== 'string')
3132
+ die('octwin scheduling rule add needs --dow <0-6> (0 = Sunday)');
3133
+ const dow = Number(dowRaw);
3134
+ if (!Number.isInteger(dow) || dow < 0 || dow > 6)
3135
+ die(`--dow must be an integer 0-6, 0 = Sunday (got '${dowRaw}')`);
3136
+ body.dow = dow;
3137
+ if (!body.start_time || !body.end_time)
3138
+ die('octwin scheduling rule add needs --start HH:MM and --end HH:MM');
3139
+ }
3140
+ else {
3141
+ const date = typeof flags.date === 'string' ? flags.date
3142
+ : die('octwin scheduling exception add needs --date YYYY-MM-DD');
3143
+ const kind = typeof flags.kind === 'string' ? flags.kind
3144
+ : die("octwin scheduling exception add needs --kind closed|extra");
3145
+ if (kind !== 'closed' && kind !== 'extra')
3146
+ die(`--kind must be 'closed' or 'extra' (got '${kind}')`);
3147
+ body.exception_date = date;
3148
+ body.kind = kind;
3149
+ }
3150
+ console.log(`→ Adding a ${noun} for resource ${resource} …`);
3151
+ const { status, json } = await apiSend('POST', `${base}/availability/${path}`, body, t);
3152
+ if (status !== 201 && status !== 200)
3153
+ writeFail(`add the ${noun}`, status, json, url);
3154
+ if (json?.has_scheduling === false)
3155
+ die('this pack declares no scheduling');
3156
+ const created = json?.rule ?? json?.exception ?? {};
3157
+ console.log(`✓ ${noun[0].toUpperCase()}${noun.slice(1)} added — ${created.id ?? '(no id returned)'}`);
3158
+ console.log(`\nSee it: octwin scheduling rules --resource ${resource}`);
3159
+ console.log(`Verify the slots it produces: octwin scheduling --slots ${resource}`);
3160
+ }
2250
3161
  /** `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]`
2251
3162
  * — the scheduling engine's state, or the computed slots for one bookable resource
2252
3163
  * (the verification the `--seed` availability fan-out was missing). `scheduling:read`. */
2253
3164
  async function cmdScheduling(flags) {
3165
+ if (typeof flags._[0] === 'string' && SCHEDULING_VERBS.has(flags._[0]))
3166
+ return cmdSchedulingWrite(flags);
2254
3167
  const t = resolveTarget(flags);
2255
3168
  const { url } = t;
2256
3169
  const base = `${url}/api/self/p/scheduling`;
@@ -2315,11 +3228,13 @@ function help() {
2315
3228
 
2316
3229
  octwin --version # print the CLI version (+ any upgrade notice)
2317
3230
  octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
2318
- octwin validate [--dir .] [--remote] # --remote runs the platform's FULL schema check (all errors at once)
3231
+ octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
2319
3232
  octwin login --url <platformUrl> --token oct_… # a deploy token from the console
2320
3233
  octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
3234
+ octwin projects [--archived] [--json] # the --project slugs this token can name
2321
3235
  octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
2322
3236
  octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
3237
+ octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
2323
3238
  octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
2324
3239
  octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
2325
3240
  octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
@@ -2332,6 +3247,17 @@ function help() {
2332
3247
  octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
2333
3248
  octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
2334
3249
  octwin test [--dir .] # = validate --remote (the full platform check)
3250
+ octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
3251
+
3252
+ Writes — exercise the state your pack creates (each needs the matching :write scope):
3253
+ octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
3254
+ octwin records tasks | task complete <taskId> [--outcome done|cancelled]
3255
+ octwin cases assign <id> --to user:<uuid>|none | note <id> "…" | transition <id> --to <status>
3256
+ octwin cases decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
3257
+ octwin orders transition <ref> --to <status> | refund <ref> --force
3258
+ octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
3259
+ octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
3260
+ octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
2335
3261
 
2336
3262
  Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
2337
3263
  \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
@@ -2346,26 +3272,57 @@ Per-command usage: octwin <command> --help`);
2346
3272
  const COMMAND_HELP = {
2347
3273
  init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
2348
3274
  Scaffold a pure-YAML starter pack into <dir>.`,
2349
- validate: `octwin validate [--dir .] [--remote]
2350
- Offline structural check; --remote additionally runs the platform's FULL
2351
- manifest + flow-DSL validation (all errors at once) same check as deploy.`,
3275
+ validate: `octwin validate [--dir .] [--remote] [--require-kb]
3276
+ Offline structural check, plus two checks driven by the pulled capability
3277
+ reference (render-intent fields, primitive arguments). Those two SKIP when the
3278
+ reference is missing — the run says so, and --require-kb turns the skip into a
3279
+ failure for CI. --remote additionally runs the platform's FULL manifest +
3280
+ flow-DSL validation and its flow lint (all errors at once) — same check as deploy.`,
2352
3281
  login: `octwin login --url <platformUrl> --token oct_…
2353
3282
  Save a deploy token (console → Settings → API tokens) for that platform url,
2354
3283
  make that url the DEFAULT deploy target for every later command, and echo the
2355
3284
  workspace + project pin + scopes the token reaches.`,
2356
3285
  whoami: `octwin whoami [--url <url>] [--tenant <slug>]
2357
3286
  Verify the resolved token authenticates against the tenant.`,
3287
+ projects: `octwin projects [--archived] [--json]
3288
+ List the workspace's projects — the slugs every --project flag takes, with the
3289
+ plan's project cap. --archived includes archived ones. A pack:deploy token
3290
+ reaches this (it names a project in every other command).`,
2358
3291
  deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
2359
3292
  Upload the pack bundle, validate server-side, install onto the project.
2360
3293
  --seed additionally applies the pack's demo seed (streams progress).`,
2361
3294
  status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
2362
3295
  Show installed vs live version + the flow list for this pack.`,
2363
- records: `octwin records [entity] [id] [--limit 50]
3296
+ records: `octwin records [entity] [id] [--limit 50] [--offset n]
2364
3297
  Inspect the pack's XRM data. No args = list entities. Cases/tickets are
2365
- casework, not XRM — use \`octwin cases\` for those.`,
2366
- cases: `octwin cases [caseId] [--queues] [--limit 50] [--json]
3298
+ casework, not XRM — use \`octwin cases\` for those.
3299
+
3300
+ WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
3301
+ octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
3302
+ octwin records patch <recordId> --entity <entity> --set field=value
3303
+ octwin records stage <recordId> --to <stage> [--note "..."]
3304
+ octwin records note <recordId> "the note text"
3305
+ octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
3306
+ octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
3307
+
3308
+ --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
3309
+ sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
3310
+ \`patch\` needs --entity even though it has an id: the route resolves the field
3311
+ validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
3312
+ VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
3313
+ cases: `octwin cases [caseId] [--queues] [--limit 50] [--offset n] [--json]
2367
3314
  Inspect casework (support tickets): the inbox, one case + its timeline
2368
- (+ applicable decisions), or --queues for queue keys + open counts.`,
3315
+ (+ applicable decisions), or --queues for queue keys + open counts.
3316
+
3317
+ WRITES (need \`cases:write\`):
3318
+ octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none
3319
+ octwin cases note <caseId> "the note text"
3320
+ octwin cases transition <caseId> --to <status> [--note "..."]
3321
+ octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]
3322
+
3323
+ \`decide\` applies one of the case's declared dispositions — \`octwin cases <id>\`
3324
+ lists them with their params. --dry-run previews the customer-facing copy and the
3325
+ resulting status WITHOUT committing (that route needs only \`cases:read\`).`,
2369
3326
  logs: `octwin logs [conversationId] [--as <handle>] [--json]
2370
3327
  No id = recent conversations (handle, status, last activity; --as filters).
2371
3328
  With id = the full event timeline including what each turn rendered.
@@ -2413,13 +3370,30 @@ const COMMAND_HELP = {
2413
3370
  override what your manifest declares, and this is where you see that.
2414
3371
  --prompt = the exact system prompt the LLM sees for this project (pack
2415
3372
  instructions + platform protocol + any project overlay). Needs agents:read.
2416
- The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.`,
3373
+ The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
3374
+
3375
+ WRITES (need \`agents:write\`):
3376
+ octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
3377
+ [--enable-tool <toolId>] [--disable-tool <toolId>]
3378
+
3379
+ Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
3380
+ so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
3381
+ ids refuses --model with a 403 — the platform default governs there.`,
2417
3382
  orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
2418
3383
  No args = the order list (#number, status/payment, total, contact). With a
2419
3384
  reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
2420
3385
  payment_ref, and the allowed status transitions. Needs orders:read + the
2421
3386
  \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
2422
- so \`pending\` on a gateway-less workspace is expected, not a bug.`,
3387
+ so \`pending\` on a gateway-less workspace is expected, not a bug.
3388
+
3389
+ WRITES (need \`orders:write\`):
3390
+ octwin orders transition <reference_id> --to <status>
3391
+ octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
3392
+
3393
+ Refund is irreversible and moves money, hence --force. The route answers 200 even
3394
+ when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
3395
+ on a refusal rather than reporting a refund that never happened. Only a payment in
3396
+ \`captured\` state can be refunded; \`payment_status\` is never settable directly.`,
2423
3397
  analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
2424
3398
  No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
2425
3399
  With an entity = stage-by-stage conversion (default --funnel) over the last 30
@@ -2429,22 +3403,58 @@ const COMMAND_HELP = {
2429
3403
  The commerce \`product\` records + price, availability, stock (null = not
2430
3404
  inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
2431
3405
  Graph checklist (LIVE Graph calls; needs a bound access token). Needs
2432
- catalog:read + the \`catalog\` plan feature.`,
3406
+ catalog:read + the \`catalog\` plan feature.
3407
+
3408
+ WRITES (need \`catalog:write\`):
3409
+ octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
3410
+ octwin catalog stock <retailerId> [--set-on-hand <n>]
3411
+
3412
+ \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
3413
+ inventory-tracked (always sellable), which is different from 0. Lowering on_hand
3414
+ below the units already reserved for open carts is refused. Creating/deleting
3415
+ products and the Meta catalog binding/sync stay in the console.`,
2433
3416
  scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
2434
3417
  No args = the engine state (bookable resource types, upcoming slots, booked
2435
3418
  seats). --slots <recordId> computes the slots for one bookable resource
2436
3419
  (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
2437
- the availability rules a \`deploy --seed\` created. Needs scheduling:read.`,
3420
+ the availability rules a \`deploy --seed\` created. Needs scheduling:read.
3421
+
3422
+ RULES (list needs scheduling:read; add/rm need scheduling:write):
3423
+ octwin scheduling rules --resource <resourceRecordId>
3424
+ octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
3425
+ [--slot-minutes 30] [--capacity 1]
3426
+ octwin scheduling rule rm <ruleId>
3427
+ octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
3428
+ [--start 09:00 --end 13:00]
3429
+ octwin scheduling exception rm <exceptionId>
3430
+
3431
+ --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
3432
+ \`--slots\` is how you check what a rule actually produces.`,
2438
3433
  'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
2439
3434
  Pull the platform capability reference (markdown + JSON catalogs) into
2440
3435
  .octwin/platform-kb/ for the octwin-pack authoring skill.`,
2441
3436
  test: `octwin test [--dir .]
2442
3437
  Alias for \`octwin validate --remote\` — the full platform check.`,
3438
+ feedback: `octwin feedback [--dir .]
3439
+ Submit this pack's FEEDBACK.md to the platform team.
3440
+ The octwin-pack skill writes that file in its last step — findings grouped by
3441
+ owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
3442
+ you to paste it into a chat.
3443
+ Attaches the pack id + version from manifest.yaml, this CLI's version, and the
3444
+ content_hash of the capability reference in .octwin/platform-kb/ — triage needs
3445
+ the last two to tell "the platform is wrong" from "that was already fixed" or
3446
+ "you were reading a stale reference". Needs the \`pack:deploy\` scope.`,
2443
3447
  };
2444
3448
  async function main() {
2445
3449
  const [command, ...rest] = process.argv.slice(2);
2446
3450
  const flags = parseFlags(rest);
2447
- CURRENT_COMMAND = command; // so an auth failure can name the scope THIS command needs
3451
+ // So an auth failure can name the scope THIS invocation needs. A leading write
3452
+ // verb changes the answer (`cases` reads, `cases note` writes), so it rides along
3453
+ // when the first positional is one — `VERB_REQUIREMENTS` is keyed that way.
3454
+ const leadingVerb = flags._[0];
3455
+ CURRENT_COMMAND = (typeof leadingVerb === 'string' && command && `${command} ${leadingVerb}` in VERB_REQUIREMENTS)
3456
+ ? `${command} ${leadingVerb}`
3457
+ : command;
2448
3458
  // Per-subcommand --help/-h — intercepted BEFORE the command runs, so help can
2449
3459
  // never hit the network or die on auth (author-feedback A8).
2450
3460
  if (command && command in COMMAND_HELP && (flags.help === true || flags._.includes('-h'))) {
@@ -2488,6 +3498,9 @@ async function main() {
2488
3498
  case 'media':
2489
3499
  await cmdMedia(flags);
2490
3500
  break;
3501
+ case 'projects':
3502
+ await cmdProjects(flags);
3503
+ break;
2491
3504
  case 'agents':
2492
3505
  await cmdAgents(flags);
2493
3506
  break;
@@ -2506,6 +3519,9 @@ async function main() {
2506
3519
  case 'platform-kb':
2507
3520
  await cmdPlatformKb(flags);
2508
3521
  break;
3522
+ case 'feedback':
3523
+ await cmdFeedback(flags);
3524
+ break;
2509
3525
  case 'test':
2510
3526
  await cmdValidate({ ...flags, remote: true });
2511
3527
  break; // A6: `test` = the full remote validate, not a validate-clone