@remits/remits-cli 0.1.90 → 0.1.92

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/index.js CHANGED
@@ -72,6 +72,10 @@ const ACCOUNT_SCAN_EXCLUDE_DIRS = new Set([
72
72
  'tmp'
73
73
  ]);
74
74
 
75
+ // Flags that are legitimately repeatable accumulate into an array instead of last-wins. Every other
76
+ // flag keeps last-wins so existing callers are unaffected.
77
+ const REPEATABLE_FLAGS = new Set(['expected-removed', 'expectedRemoved']);
78
+
75
79
  function parseArgs(argv) {
76
80
  const out = { _: [] };
77
81
  for (let i = 0; i < argv.length; i++) {
@@ -82,12 +86,18 @@ function parseArgs(argv) {
82
86
  }
83
87
  const key = arg.slice(2);
84
88
  const next = argv[i + 1];
89
+ let value;
85
90
  if (!next || next.startsWith('--')) {
86
- out[key] = true;
91
+ value = true;
87
92
  } else {
88
- out[key] = next;
93
+ value = next;
89
94
  i += 1;
90
95
  }
96
+ if (REPEATABLE_FLAGS.has(key) && Object.prototype.hasOwnProperty.call(out, key)) {
97
+ out[key] = (Array.isArray(out[key]) ? out[key] : [out[key]]).concat(value);
98
+ } else {
99
+ out[key] = value;
100
+ }
91
101
  }
92
102
  return out;
93
103
  }
@@ -268,6 +278,35 @@ function printResolvedBaseUrl(baseUrl) {
268
278
  console.log('Base URL:', normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL));
269
279
  }
270
280
 
281
+ // Tools whose name signals they mutate state rather than only reading it. Used solely to escalate the
282
+ // prod banner from "reading live data" to "WRITING live data" — never to block or permit anything.
283
+ const MUTATING_TOOL_PATTERN = /(^|_)(patch|edit|create|commit|update|delete|remove|write|set|assign|run|execute|send|pause|unpause|interrupt|restore|repair|migrate|sync)(_|$)/i;
284
+
285
+ function looksLikeDryRun(input) {
286
+ if (!input || typeof input !== 'object') return false;
287
+ const value = input.dryRun !== undefined ? input.dryRun : input.dry_run;
288
+ return value === true || value === 'true';
289
+ }
290
+
291
+ /**
292
+ * One consistent banner across every surface that can touch production. Agents have repeatedly run a
293
+ * prod write believing they were in test mode, so the banner is loud, states the account it resolved,
294
+ * and distinguishes a live WRITE from a live read.
295
+ */
296
+ function printProdDataBanner({ dataMode, accountId, baseUrl, operation, mutating = false, dryRun = false }) {
297
+ if (String(dataMode || '').toLowerCase() !== 'prod') return;
298
+ const bar = '='.repeat(72);
299
+ const headline = dryRun
300
+ ? 'PROD DATA — DRY RUN (no write will be attempted)'
301
+ : (mutating ? 'PROD DATA WRITE — this runs against LIVE production data' : 'PROD DATA READ — this reads LIVE production data');
302
+ console.log(bar);
303
+ console.log(' ' + headline);
304
+ console.log(' operation: ' + (operation || 'unknown'));
305
+ if (accountId != null) console.log(' account: ' + accountId);
306
+ if (baseUrl) console.log(' host: ' + normalizeBaseUrl(baseUrl));
307
+ console.log(bar);
308
+ }
309
+
271
310
  // A tool's OWN verdict, which is separate from whether the call was dispatched. Only an explicit
272
311
  // failure signal counts: tools legitimately return strings, arrays, and maps with no `success` key.
273
312
  // Used as a fallback when the platform build predates the envelope's `toolSuccess`.
@@ -1242,6 +1281,124 @@ function collectComponents(cwd) {
1242
1281
  return components;
1243
1282
  }
1244
1283
 
1284
+ function componentPathInfo(cwd, filePath) {
1285
+ const mapping = {
1286
+ schemas: 'schema',
1287
+ readers: 'reader',
1288
+ actions: 'action',
1289
+ embeddables: 'embeddable',
1290
+ i18n: 'i18n',
1291
+ rules: 'rule',
1292
+ templates: 'htmltemplate',
1293
+ agents: 'utility',
1294
+ tools: 'tool',
1295
+ prompts: 'prompt',
1296
+ tests: 'test'
1297
+ };
1298
+ const relative = String(filePath || '').replace(/\\/g, '/').replace(/^"|"$/g, '');
1299
+ const parts = relative.split('/');
1300
+ if (parts.length < 3 || parts[0] !== 'components') {
1301
+ return null;
1302
+ }
1303
+ const type = mapping[parts[1]];
1304
+ if (!type) {
1305
+ return null;
1306
+ }
1307
+ const fileName = parts.slice(2).join('/');
1308
+ if (fileName.includes('/')) {
1309
+ return null;
1310
+ }
1311
+ const metaMatch = fileName.match(/^(.+?)_(.+)\.meta\.ya?ml$/i);
1312
+ const match = metaMatch || fileName.match(/^(.+?)_(.+)\.([^.]+)$/);
1313
+ if (!match) {
1314
+ return null;
1315
+ }
1316
+ const prefix = match[1];
1317
+ const rawName = match[2];
1318
+ const ext = metaMatch ? 'meta' : match[3].toLowerCase();
1319
+ const id = /^\d+$/.test(prefix) ? Number(prefix) : null;
1320
+ const name = normalizeName(rawName);
1321
+ const key = type + ':' + (id ? 'id:' + id : 'name:' + name.toLowerCase());
1322
+ let field = ext;
1323
+ if (ext === 'meta') field = 'metadata';
1324
+ else if ((type === 'utility' || type === 'prompt') && ext === 'md') field = 'prompt';
1325
+ else if (ext === 'groovy' || ext === 'md') field = 'source';
1326
+ else if (type === 'embeddable' && ext === 'html') field = 'html';
1327
+ else if (type === 'embeddable' && ext === 'js') field = 'javascript';
1328
+ else if (type === 'htmltemplate' && ext === 'html') field = 'html';
1329
+ else if (type === 'htmltemplate' && ext === 'json') field = 'previewData';
1330
+ else if (type === 'tool' && ext === 'json') field = 'inputSchema';
1331
+ else if (type === 'i18n' && ext === 'json') field = 'messages';
1332
+ else if (type === 'schema' && ext === 'json') field = 'schema';
1333
+
1334
+ return { key, type, id, name, field, path: relative };
1335
+ }
1336
+
1337
+ // Returns null (not []) when git cannot answer — e.g. this checkout is not a git repo. The caller
1338
+ // must not render "0 components edited" in that case, because it is a missing answer, not a zero.
1339
+ function changedComponentsFromWorkingTree(cwd) {
1340
+ let porcelain = '';
1341
+ try {
1342
+ // NOT runGit(): it trims the whole output, which eats the leading space of porcelain's 2-char
1343
+ // status field on the FIRST line (` M path` -> `M path`). Any fixed-offset slice then reads the
1344
+ // path one character short and silently drops that entry — so a single modified component
1345
+ // reported as zero, while an untracked one ("?? path", no leading space) reported fine.
1346
+ porcelain = execSync('git status --porcelain -- components', {
1347
+ cwd,
1348
+ stdio: ['ignore', 'pipe', 'pipe']
1349
+ }).toString();
1350
+ } catch (_) {
1351
+ return null;
1352
+ }
1353
+ if (!porcelain.trim()) {
1354
+ return [];
1355
+ }
1356
+ const byKey = new Map();
1357
+ for (const rawLine of porcelain.split(/\r?\n/)) {
1358
+ // Status is exactly 2 columns, then a space, then the path. Match it rather than slicing, so a
1359
+ // status whose first column is a space (unstaged change) parses identically to a staged one.
1360
+ const parsed = /^(..) (.*)$/.exec(rawLine.replace(/\s+$/, ''));
1361
+ if (!parsed) continue;
1362
+ const status = parsed[1].trim() || parsed[1];
1363
+ let filePath = parsed[2].trim();
1364
+ // Renames render as "old -> new"; the new path is the one that exists on disk.
1365
+ if (filePath.includes(' -> ')) {
1366
+ filePath = filePath.split(' -> ').pop().trim();
1367
+ }
1368
+ // Quoted paths (non-ASCII / spaces) come back wrapped in double quotes.
1369
+ if (filePath.startsWith('"') && filePath.endsWith('"')) {
1370
+ filePath = filePath.slice(1, -1);
1371
+ }
1372
+ const info = componentPathInfo(cwd, filePath);
1373
+ if (!info) {
1374
+ continue;
1375
+ }
1376
+ if (!byKey.has(info.key)) {
1377
+ byKey.set(info.key, {
1378
+ type: info.type,
1379
+ id: info.id,
1380
+ name: info.name,
1381
+ fields: [],
1382
+ statuses: [],
1383
+ paths: []
1384
+ });
1385
+ }
1386
+ const entry = byKey.get(info.key);
1387
+ if (!entry.fields.includes(info.field)) entry.fields.push(info.field);
1388
+ if (!entry.statuses.includes(status)) entry.statuses.push(status);
1389
+ if (!entry.paths.includes(info.path)) entry.paths.push(info.path);
1390
+ }
1391
+ return Array.from(byKey.values()).map((entry) => ({
1392
+ ...entry,
1393
+ fields: entry.fields.sort(),
1394
+ paths: entry.paths.sort()
1395
+ })).sort((a, b) => {
1396
+ const typeCmp = String(a.type).localeCompare(String(b.type));
1397
+ if (typeCmp !== 0) return typeCmp;
1398
+ return String(a.id || a.name || '').localeCompare(String(b.id || b.name || ''));
1399
+ });
1400
+ }
1401
+
1245
1402
  function parsePositiveInt(value, fallback) {
1246
1403
  const parsed = Number(value);
1247
1404
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
@@ -1259,6 +1416,14 @@ function describeError(err) {
1259
1416
  const parts = [];
1260
1417
  if (err.response && err.response.data) {
1261
1418
  const responseData = err.response.data;
1419
+ // The server distinguishes a failure in the RUNTIME (Groovy reflective dispatch, empty connection
1420
+ // pool, Redis reconnect, lock-wait timeout) from a tool that ran and genuinely failed. Surface that
1421
+ // verdict, or the caller cannot tell "retry this" from "this is broken".
1422
+ if (responseData.failureClass === 'transient_infrastructure') {
1423
+ parts.push('TRANSIENT INFRASTRUCTURE FAILURE (retryable)');
1424
+ if (responseData.failureReason) parts.push('signal: ' + responseData.failureReason);
1425
+ if (responseData.hint) parts.push(responseData.hint);
1426
+ }
1262
1427
  if (responseData.message) {
1263
1428
  parts.push(String(responseData.message));
1264
1429
  } else if (typeof responseData === 'string') {
@@ -1679,6 +1844,7 @@ async function pushComponentsCommand(flags) {
1679
1844
  const requestedMode = String(flags.mode || 'stage').toLowerCase();
1680
1845
  const mode = requestedMode === 'push' ? 'stage' : requestedMode;
1681
1846
  const components = collectComponents(cwd);
1847
+ const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
1682
1848
 
1683
1849
  if (requestedMode === 'push') {
1684
1850
  console.log('Warning: `components push` is deprecated and currently behaves the same as `components stage`.');
@@ -1694,6 +1860,8 @@ async function pushComponentsCommand(flags) {
1694
1860
  replace: true,
1695
1861
  components
1696
1862
  }).then((r) => r.data);
1863
+ response.changedFromWorkingTree = changedFromWorkingTree || [];
1864
+ response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
1697
1865
 
1698
1866
  if (flagEnabled(flags.json)) {
1699
1867
  console.log(JSON.stringify(response, null, 2));
@@ -1744,14 +1912,35 @@ function printComponentCommandResponse(label, response, flags) {
1744
1912
  }
1745
1913
 
1746
1914
  function printStageSummary(response, flags) {
1747
- console.log('Updated:', response.updated || 0);
1748
- console.log('Unchanged:', response.unchanged || 0);
1915
+ // `stage` always uploads EVERY component in the repo, so the server's Updated/Unchanged counts
1916
+ // describe the whole staging overlay, not what this checkout edited. That is what made "Updated: 75"
1917
+ // read as "I changed 75 components". Lead with the working-tree delta, which is the number an agent
1918
+ // is actually asking about, and label the server counts as the overlay they describe.
1919
+ const changed = Array.isArray(response.changedFromWorkingTree) ? response.changedFromWorkingTree : [];
1920
+ const tracked = response.changedFromWorkingTreeAvailable !== false;
1921
+ if (tracked) {
1922
+ console.log('Components edited in this working tree:', changed.length);
1923
+ changed.slice(0, 20).forEach((entry) => {
1924
+ const label = (entry.type || 'component') + ' ' + (entry.id || entry.name || '(unknown)');
1925
+ const name = entry.name && entry.id ? ' ' + entry.name : '';
1926
+ const fields = Array.isArray(entry.fields) && entry.fields.length ? ': ' + entry.fields.join(', ') : '';
1927
+ console.log(' ' + label + name + fields);
1928
+ });
1929
+ if (changed.length > 20) {
1930
+ console.log(' ...' + (changed.length - 20) + ' more');
1931
+ }
1932
+ if (!changed.length) {
1933
+ console.log(' (no uncommitted component edits — already-committed edits are staged but not listed here)');
1934
+ }
1935
+ }
1936
+ console.log('Staging overlay — newly written:', response.updated || 0);
1937
+ console.log('Staging overlay — already current:', response.unchanged || 0);
1749
1938
  console.log('Skipped:', Array.isArray(response.skipped) ? response.skipped.length : 0);
1750
1939
  if (response.reconcile) {
1751
1940
  console.log('Reconciled stale keys:', response.reconcile.removedCount || 0);
1752
1941
  }
1753
1942
  if (response.staging) {
1754
- console.log('Remaining staged count:', response.staging.remainingCount || 0);
1943
+ console.log('Total staged entries for this branch/user:', response.staging.remainingCount || 0);
1755
1944
  }
1756
1945
  printComponentCommandResponse('Components stage', response, flags);
1757
1946
  }
@@ -1901,6 +2090,15 @@ async function syncComponentsCommand(flags) {
1901
2090
  const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
1902
2091
  const api = buildAxios(baseUrl, session.token);
1903
2092
 
2093
+ printProdDataBanner({
2094
+ dataMode,
2095
+ accountId,
2096
+ baseUrl,
2097
+ operation: 'components sync (branch ' + branchName + ')',
2098
+ mutating: true,
2099
+ dryRun
2100
+ });
2101
+
1904
2102
  const response = await loggedPost(api, cwd, '/cli/components', {
1905
2103
  token: session.token,
1906
2104
  accountId,
@@ -1916,8 +2114,13 @@ async function syncComponentsCommand(flags) {
1916
2114
  throw new Error(response.message || 'Server sync failed');
1917
2115
  }
1918
2116
 
2117
+ const summary = buildSyncSummary(response);
2118
+ const gate = evaluateSyncGates(response, flags, changedComponentsFromWorkingTree(cwd));
2119
+ summary.gates = gate.checks;
2120
+
1919
2121
  if (flagEnabled(flags.json)) {
1920
- console.log(JSON.stringify(response, null, 2));
2122
+ console.log(JSON.stringify(flagEnabled(flags.summary) ? summary : response, null, 2));
2123
+ failOnSyncGate(gate);
1921
2124
  return response;
1922
2125
  }
1923
2126
 
@@ -1928,10 +2131,169 @@ async function syncComponentsCommand(flags) {
1928
2131
  if (response.sync && response.sync.dryRun) {
1929
2132
  console.log('Dry run: no variants were written and staging was not cleared.');
1930
2133
  }
2134
+ if (flagEnabled(flags['names-only']) || flagEnabled(flags.namesOnly)) {
2135
+ printSyncNames(response);
2136
+ failOnSyncGate(gate);
2137
+ return response;
2138
+ }
2139
+ if (flagEnabled(flags.summary)) {
2140
+ console.log('Components sync summary:', JSON.stringify(summary, null, 2));
2141
+ failOnSyncGate(gate);
2142
+ return response;
2143
+ }
1931
2144
  console.log('Components sync:', JSON.stringify(response, null, 2));
2145
+ failOnSyncGate(gate);
1932
2146
  return response;
1933
2147
  }
1934
2148
 
2149
+ // One "type:id" / "type:name" token per planned write, so an agent can eyeball or diff the plan
2150
+ // without paging through the full server response.
2151
+ function syncPlanEntries(response) {
2152
+ const results = ((response && response.sync) || {}).syncResults || {};
2153
+ const entries = [];
2154
+ ['overridden', 'added', 'removed', 'errors'].forEach((bucket) => {
2155
+ (Array.isArray(results[bucket]) ? results[bucket] : []).forEach((entry) => {
2156
+ entries.push({
2157
+ bucket,
2158
+ type: String(entry.type || entry.kind || 'component').toLowerCase(),
2159
+ id: entry.id == null ? null : String(entry.id),
2160
+ name: entry.name || null
2161
+ });
2162
+ });
2163
+ });
2164
+ return entries;
2165
+ }
2166
+
2167
+ function syncEntryToken(entry) {
2168
+ return entry.type + ':' + (entry.id != null ? entry.id : (entry.name || '?'));
2169
+ }
2170
+
2171
+ function printSyncNames(response) {
2172
+ const entries = syncPlanEntries(response);
2173
+ if (!entries.length) {
2174
+ console.log('No planned component writes.');
2175
+ return;
2176
+ }
2177
+ entries.forEach((entry) => {
2178
+ console.log(entry.bucket.toUpperCase().padEnd(10), syncEntryToken(entry), entry.name || '');
2179
+ });
2180
+ }
2181
+
2182
+ function parseExpectedRemoved(flags) {
2183
+ const raw = flags['expected-removed'] != null ? flags['expected-removed'] : flags.expectedRemoved;
2184
+ if (raw == null) return null;
2185
+ // A bare `--expected-removed` with no value means "I expect none" — fail closed on any removal.
2186
+ const values = raw === true ? [] : (Array.isArray(raw) ? raw : [raw]);
2187
+ const tokens = new Set();
2188
+ values.forEach((value) => {
2189
+ String(value).split(',').forEach((token) => {
2190
+ const trimmed = token.trim().toLowerCase();
2191
+ if (trimmed) tokens.add(trimmed);
2192
+ });
2193
+ });
2194
+ return tokens;
2195
+ }
2196
+
2197
+ // Fail-closed gates. Each returns a violation string or null; the command exits non-zero if any fire.
2198
+ function evaluateSyncGates(response, flags, changedFromWorkingTree) {
2199
+ const entries = syncPlanEntries(response);
2200
+ const results = ((response && response.sync) || {}).syncResults || {};
2201
+ const removed = entries.filter((e) => e.bucket === 'removed');
2202
+ const errors = Array.isArray(results.errors) ? results.errors : [];
2203
+ const expectedRemoved = parseExpectedRemoved(flags);
2204
+ const violations = [];
2205
+ const checks = {};
2206
+
2207
+ if (flagEnabled(flags['fail-on-errors']) || flagEnabled(flags.failOnErrors)) {
2208
+ checks.failOnErrors = errors.length === 0;
2209
+ if (errors.length) {
2210
+ violations.push('--fail-on-errors: server reported ' + errors.length + ' component sync error(s): ' +
2211
+ errors.map((e) => (e.type || 'component') + ':' + (e.id != null ? e.id : e.name)).join(', '));
2212
+ }
2213
+ }
2214
+
2215
+ // --expected-removed implies --fail-on-removed: you named the removals you accept, so any other
2216
+ // removal is by definition unexpected.
2217
+ const failOnRemoved = flagEnabled(flags['fail-on-removed']) || flagEnabled(flags.failOnRemoved) || expectedRemoved !== null;
2218
+ if (failOnRemoved) {
2219
+ const unexpected = removed.filter((entry) => !expectedRemoved || !expectedRemoved.has(syncEntryToken(entry)));
2220
+ checks.failOnRemoved = unexpected.length === 0;
2221
+ if (unexpected.length) {
2222
+ violations.push((expectedRemoved ? '--expected-removed' : '--fail-on-removed') +
2223
+ ': plan removes/tombstones ' + unexpected.length + ' unlisted component(s): ' +
2224
+ unexpected.map(syncEntryToken).join(', '));
2225
+ }
2226
+ }
2227
+
2228
+ if (flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly)) {
2229
+ if (changedFromWorkingTree === null) {
2230
+ violations.push('--changed-only: this checkout is not a git working tree, so the changed set cannot be established.');
2231
+ checks.changedOnly = false;
2232
+ } else {
2233
+ // A component the checkout edited is identified by type + id, or type + name for `new_` files.
2234
+ const allowedIds = new Set(changedFromWorkingTree.filter((c) => c.id != null).map((c) => c.type + ':' + c.id));
2235
+ const allowedNames = new Set(changedFromWorkingTree.filter((c) => c.name).map((c) => c.type + ':' + String(c.name).toLowerCase()));
2236
+ const unexpected = entries.filter((entry) => {
2237
+ if (entry.id != null && allowedIds.has(entry.type + ':' + entry.id)) return false;
2238
+ if (entry.name && allowedNames.has(entry.type + ':' + String(entry.name).toLowerCase())) return false;
2239
+ return true;
2240
+ });
2241
+ checks.changedOnly = unexpected.length === 0;
2242
+ if (unexpected.length) {
2243
+ violations.push('--changed-only: plan touches ' + unexpected.length + ' component(s) this working tree did not edit: ' +
2244
+ unexpected.slice(0, 20).map(syncEntryToken).join(', ') + (unexpected.length > 20 ? ', ...' : ''));
2245
+ }
2246
+ }
2247
+ }
2248
+
2249
+ return { violations, checks };
2250
+ }
2251
+
2252
+ function failOnSyncGate(gate) {
2253
+ if (gate && gate.violations && gate.violations.length) {
2254
+ throw new Error('components sync safety gate failed:\n ' + gate.violations.join('\n '));
2255
+ }
2256
+ }
2257
+
2258
+ function buildSyncSummary(response) {
2259
+ const sync = (response && response.sync) || {};
2260
+ const results = sync.syncResults || {};
2261
+ const removed = Array.isArray(results.removed) ? results.removed : [];
2262
+ const errors = Array.isArray(results.errors) ? results.errors : [];
2263
+ const skipped = Array.isArray(results.skipped) ? results.skipped : [];
2264
+ const added = Array.isArray(results.added) ? results.added : [];
2265
+ const overridden = Array.isArray(results.overridden) ? results.overridden : [];
2266
+ const unchanged = Array.isArray(results.unchanged) ? results.unchanged : [];
2267
+ const warnings = [];
2268
+ if (removed.length) warnings.push(String(removed.length) + ' removed/tombstone entr' + (removed.length === 1 ? 'y' : 'ies') + ' present');
2269
+ if (errors.length) warnings.push(String(errors.length) + ' sync error' + (errors.length === 1 ? '' : 's') + ' present');
2270
+ return {
2271
+ success: response && response.success === true,
2272
+ accountId: response && response.accountId,
2273
+ branchName: sync.branchName || (response && response.branchName),
2274
+ mode: sync.mode || 'trunk',
2275
+ dryRun: Boolean(sync.dryRun),
2276
+ overridden: overridden.length,
2277
+ added: added.length,
2278
+ removed: removed.map((entry) => ({
2279
+ type: entry.type,
2280
+ kind: entry.kind,
2281
+ name: entry.name,
2282
+ id: entry.id,
2283
+ variantId: entry.variantId
2284
+ })),
2285
+ unchanged: unchanged.length,
2286
+ skipped: skipped.length,
2287
+ errors: errors.length,
2288
+ errorDetails: errors.map((entry) => ({
2289
+ type: entry.type,
2290
+ id: entry.id,
2291
+ error: entry.error
2292
+ })),
2293
+ warnings
2294
+ };
2295
+ }
2296
+
1935
2297
  // Inspect committed branch variants: durable, branch-scoped overlays of this account's components.
1936
2298
  // Unlike `components status` (which shows the ephemeral Redis staging cache), these are what
1937
2299
  // subscribing accounts actually resolve in production.
@@ -2285,6 +2647,15 @@ async function testCommand(flags) {
2285
2647
  // --variant-branch explicitly probes a committed variant branch. Normally omitted: variants resolve
2286
2648
  // from the account's subscription edge, which is what production does.
2287
2649
  const variantBranch = flags['variant-branch'];
2650
+
2651
+ printProdDataBanner({
2652
+ dataMode,
2653
+ accountId: asAccountId || accountId,
2654
+ baseUrl,
2655
+ operation: 'test run ' + String(testRef),
2656
+ mutating: true
2657
+ });
2658
+
2288
2659
  const start = await loggedPost(api, cwd, '/cli/test', {
2289
2660
  token: session.token,
2290
2661
  accountId,
@@ -2507,6 +2878,15 @@ async function toolCommand(flags) {
2507
2878
  return;
2508
2879
  }
2509
2880
 
2881
+ printProdDataBanner({
2882
+ dataMode,
2883
+ accountId,
2884
+ baseUrl,
2885
+ operation: 'tool ' + String(toolName),
2886
+ mutating: MUTATING_TOOL_PATTERN.test(String(toolName)),
2887
+ dryRun: looksLikeDryRun(input)
2888
+ });
2889
+
2510
2890
  const response = await loggedPost(api, cwd, '/cli/tool', {
2511
2891
  token: session.token,
2512
2892
  accountId,
@@ -4965,6 +5345,113 @@ function autoUpdateIfNeeded(originalArgv, options = {}) {
4965
5345
  }
4966
5346
  }
4967
5347
 
5348
+ function printComponentsHelp(subcommand) {
5349
+ if (subcommand === 'sync') {
5350
+ console.log('Usage: remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--json]');
5351
+ console.log('');
5352
+ console.log('On trunk this reconciles the pushed repository into live component rows.');
5353
+ console.log('On a variant branch this writes ComponentVariant overlays only.');
5354
+ console.log('--dry-run is only supported on variant branches and writes nothing.');
5355
+ console.log('--summary prints compact counts, removals/tombstones, errors, skipped items, and warnings.');
5356
+ console.log('');
5357
+ console.log('Agent safety gates (each exits non-zero instead of printing a wall of JSON):');
5358
+ console.log(' --changed-only fail unless every planned write is a component this checkout changed');
5359
+ console.log(' --names-only print only "type id name" lines for the planned writes');
5360
+ console.log(' --fail-on-removed fail if the plan removes/tombstones anything');
5361
+ console.log(' --fail-on-errors fail if the server reported any per-component sync error');
5362
+ console.log(' --expected-removed t:id whitelist an intended removal; repeatable/comma-delimited.');
5363
+ console.log(' Implies --fail-on-removed, so any OTHER removal fails.');
5364
+ return;
5365
+ }
5366
+ if (subcommand === 'stage' || subcommand === 'push') {
5367
+ console.log('Usage: remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
5368
+ console.log('');
5369
+ console.log('Stages local component files into the Redis staging cache. It never writes the database or git.');
5370
+ console.log('Terminal output separates local working-tree component deltas from the full materialized staging cache count.');
5371
+ return;
5372
+ }
5373
+ if (subcommand === 'status') {
5374
+ console.log('Usage: remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
5375
+ console.log('');
5376
+ console.log('Shows staged entries and whether this checkout resolves/writes trunk or a variant branch.');
5377
+ return;
5378
+ }
5379
+ if (subcommand === 'clear') {
5380
+ console.log('Usage: remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
5381
+ console.log('');
5382
+ console.log('Clears staged Redis entries without touching the database or git.');
5383
+ console.log('Use --component-type with --component-id when the id could exist in multiple component families.');
5384
+ console.log('Use --all for the temporary-experiment cleanup flow.');
5385
+ return;
5386
+ }
5387
+ if (subcommand === 'commit') {
5388
+ console.log('Usage: remits-cli components commit [--message "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
5389
+ console.log('');
5390
+ console.log('Runs local git add/commit/push, then server sync. Prefer explicit git + components sync when you need inspectable phases.');
5391
+ console.log('components commit does not support --dry-run.');
5392
+ return;
5393
+ }
5394
+ console.log('Usage: remits-cli components <stage|status|clear|sync|commit|branches|branch>');
5395
+ console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
5396
+ console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
5397
+ console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
5398
+ console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
5399
+ console.log(' remits-cli components commit [--message "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
5400
+ console.log('');
5401
+ console.log(' --force-tombstones applies only on non-trunk variant syncs, when a missing trunk');
5402
+ console.log(' component file is intentionally being recorded as a tombstone override.');
5403
+ console.log(' --dry-run reports the variant override/add/remove plan without writing variants,');
5404
+ console.log(' updating sync SHA cache, or clearing staged CLI entries.');
5405
+ console.log(' --summary, --changed-only, --names-only, --fail-on-removed, --fail-on-errors, and');
5406
+ console.log(' --expected-removed keep sync output small and fail closed. See `components sync --help`.');
5407
+ console.log('');
5408
+ console.log('Committed branch variants (durable overlays subscribing accounts resolve in production):');
5409
+ console.log(' remits-cli components branches [--json]');
5410
+ console.log(' remits-cli components branch <name> [--json] # overridden/added/removed + drift');
5411
+ console.log(' remits-cli components branch <name> --diff <componentId> --component-type <kind> [--json]');
5412
+ console.log(' remits-cli components branch <name> --subscribers [--json]');
5413
+ console.log(' remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>]');
5414
+ console.log(' remits-cli components branch <name> --unsubscribe <accountId>');
5415
+ console.log(' remits-cli components branch <name> --retire [--force]');
5416
+ }
5417
+
5418
+ function printTestHelp() {
5419
+ console.log('Usage: remits-cli test run --test <id|name> [--base-url URL] [--names name1,name2] [--watch true|false] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
5420
+ console.log('');
5421
+ console.log('Runs a Test component against the staged/variant world for this checkout.');
5422
+ console.log('Examples:');
5423
+ console.log(' remits-cli test run --test 11');
5424
+ console.log(' remits-cli test run --test "Merchant Statements" --names "managed account case"');
5425
+ console.log('');
5426
+ console.log('Notes:');
5427
+ console.log(' --names is comma-delimited, so avoid commas in individual test case names.');
5428
+ console.log(' --as-account changes the execution account so subscriber branch edges apply.');
5429
+ console.log(' --data-mode prod intentionally targets live production data.');
5430
+ }
5431
+
5432
+ function printToolHelp() {
5433
+ console.log('Usage: remits-cli tool --name <toolName> [--base-url URL] [--account-id ID] [--branch BRANCH] [--input "{...}"|--input-file file.json] [--data-mode test|prod] [--variant-branch NAME|none] [--timeout-ms 60000] [--async true --wait true]');
5434
+ console.log(' remits-cli tool status --call-id <callId> [--base-url URL] [--account-id ID] [--data-mode test|prod]');
5435
+ console.log('');
5436
+ console.log('Examples:');
5437
+ console.log(' remits-cli tool --name mcp_firestore_search --input-file query.json --data-mode prod');
5438
+ console.log(' remits-cli tool --name mcp_run_action --input \'{"accountId":49,"actionId":200,"executionMode":"async"}\' --data-mode prod');
5439
+ console.log('');
5440
+ console.log('For mcp_run_action and mcp_run_agent, prefer the tool\'s own executionMode:"async" and poll with that tool\'s controlAction:"status".');
5441
+ }
5442
+
5443
+ function printToolsHelp() {
5444
+ console.log('Usage: remits-cli tools [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--variant-branch NAME|none]');
5445
+ console.log('');
5446
+ console.log('Refreshes .remits-cli/tools/tools.json for the current account/branch/data-mode context.');
5447
+ }
5448
+
5449
+ function printTokenHelp() {
5450
+ console.log('Usage: remits-cli token [--base-url URL] [--branch BRANCH] [--path embeddable/path] [--data-mode test|prod] [--as-account ID] [--variant-branch NAME|none]');
5451
+ console.log('');
5452
+ console.log('Mints a branch-aware browser URL for embeddable verification.');
5453
+ }
5454
+
4968
5455
  async function main() {
4969
5456
  migrateSessionIfNeeded();
4970
5457
  const originalArgv = process.argv.slice(2);
@@ -5022,7 +5509,7 @@ async function main() {
5022
5509
  console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
5023
5510
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
5024
5511
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
5025
- console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run]');
5512
+ console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary]');
5026
5513
  console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
5027
5514
  console.log(' remits-cli components branches [--json] # committed branch variants for this account');
5028
5515
  console.log(' remits-cli components branch <name> [--diff <componentId> --component-type <kind>] [--subscribers] [--json]');
@@ -5039,27 +5526,28 @@ async function main() {
5039
5526
  process.exit(0);
5040
5527
  }
5041
5528
 
5042
- if (command === 'components' && wantsHelp) {
5043
- console.log('Usage: remits-cli components <stage|status|clear|sync|commit|branches|branch>');
5044
- console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
5045
- console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
5046
- console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
5047
- console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run]');
5048
- console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
5049
- console.log('');
5050
- console.log(' --force-tombstones applies only on non-trunk variant syncs, when a missing trunk');
5051
- console.log(' component file is intentionally being recorded as a tombstone override.');
5052
- console.log(' --dry-run reports the variant override/add/remove plan without writing variants,');
5053
- console.log(' updating sync SHA cache, or clearing staged CLI entries.');
5054
- console.log('');
5055
- console.log('Committed branch variants (durable overlays subscribing accounts resolve in production):');
5056
- console.log(' remits-cli components branches [--json]');
5057
- console.log(' remits-cli components branch <name> [--json] # overridden/added/removed + drift');
5058
- console.log(' remits-cli components branch <name> --diff <componentId> --component-type <kind> [--json]');
5059
- console.log(' remits-cli components branch <name> --subscribers [--json]');
5060
- console.log(' remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>]');
5061
- console.log(' remits-cli components branch <name> --unsubscribe <accountId>');
5062
- console.log(' remits-cli components branch <name> --retire [--force]');
5529
+ if (wantsHelp && command === 'test') {
5530
+ printTestHelp();
5531
+ process.exit(0);
5532
+ }
5533
+
5534
+ if (wantsHelp && command === 'tool') {
5535
+ printToolHelp();
5536
+ process.exit(0);
5537
+ }
5538
+
5539
+ if (wantsHelp && command === 'tools') {
5540
+ printToolsHelp();
5541
+ process.exit(0);
5542
+ }
5543
+
5544
+ if (wantsHelp && command === 'token') {
5545
+ printTokenHelp();
5546
+ process.exit(0);
5547
+ }
5548
+
5549
+ if (wantsHelp && command === 'components') {
5550
+ printComponentsHelp(subcommand);
5063
5551
  process.exit(0);
5064
5552
  }
5065
5553