@remits/remits-cli 0.1.89 → 0.1.91

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,48 @@ 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
+
310
+ // A tool's OWN verdict, which is separate from whether the call was dispatched. Only an explicit
311
+ // failure signal counts: tools legitimately return strings, arrays, and maps with no `success` key.
312
+ // Used as a fallback when the platform build predates the envelope's `toolSuccess`.
313
+ function toolResultFailed(result) {
314
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return false;
315
+ return result.success === false || result.is_error === true;
316
+ }
317
+
318
+ function toolResultMessage(result) {
319
+ if (!toolResultFailed(result)) return null;
320
+ return result.message || result.error || 'The tool returned an error';
321
+ }
322
+
271
323
  function readConfig() {
272
324
  if (!fs.existsSync(CONFIG_FILE)) {
273
325
  return {};
@@ -1229,6 +1281,124 @@ function collectComponents(cwd) {
1229
1281
  return components;
1230
1282
  }
1231
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
+
1232
1402
  function parsePositiveInt(value, fallback) {
1233
1403
  const parsed = Number(value);
1234
1404
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
@@ -1246,6 +1416,14 @@ function describeError(err) {
1246
1416
  const parts = [];
1247
1417
  if (err.response && err.response.data) {
1248
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
+ }
1249
1427
  if (responseData.message) {
1250
1428
  parts.push(String(responseData.message));
1251
1429
  } else if (typeof responseData === 'string') {
@@ -1666,6 +1844,7 @@ async function pushComponentsCommand(flags) {
1666
1844
  const requestedMode = String(flags.mode || 'stage').toLowerCase();
1667
1845
  const mode = requestedMode === 'push' ? 'stage' : requestedMode;
1668
1846
  const components = collectComponents(cwd);
1847
+ const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
1669
1848
 
1670
1849
  if (requestedMode === 'push') {
1671
1850
  console.log('Warning: `components push` is deprecated and currently behaves the same as `components stage`.');
@@ -1681,6 +1860,8 @@ async function pushComponentsCommand(flags) {
1681
1860
  replace: true,
1682
1861
  components
1683
1862
  }).then((r) => r.data);
1863
+ response.changedFromWorkingTree = changedFromWorkingTree || [];
1864
+ response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
1684
1865
 
1685
1866
  if (flagEnabled(flags.json)) {
1686
1867
  console.log(JSON.stringify(response, null, 2));
@@ -1731,14 +1912,35 @@ function printComponentCommandResponse(label, response, flags) {
1731
1912
  }
1732
1913
 
1733
1914
  function printStageSummary(response, flags) {
1734
- console.log('Updated:', response.updated || 0);
1735
- 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);
1736
1938
  console.log('Skipped:', Array.isArray(response.skipped) ? response.skipped.length : 0);
1737
1939
  if (response.reconcile) {
1738
1940
  console.log('Reconciled stale keys:', response.reconcile.removedCount || 0);
1739
1941
  }
1740
1942
  if (response.staging) {
1741
- console.log('Remaining staged count:', response.staging.remainingCount || 0);
1943
+ console.log('Total staged entries for this branch/user:', response.staging.remainingCount || 0);
1742
1944
  }
1743
1945
  printComponentCommandResponse('Components stage', response, flags);
1744
1946
  }
@@ -1888,6 +2090,15 @@ async function syncComponentsCommand(flags) {
1888
2090
  const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
1889
2091
  const api = buildAxios(baseUrl, session.token);
1890
2092
 
2093
+ printProdDataBanner({
2094
+ dataMode,
2095
+ accountId,
2096
+ baseUrl,
2097
+ operation: 'components sync (branch ' + branchName + ')',
2098
+ mutating: true,
2099
+ dryRun
2100
+ });
2101
+
1891
2102
  const response = await loggedPost(api, cwd, '/cli/components', {
1892
2103
  token: session.token,
1893
2104
  accountId,
@@ -1903,8 +2114,13 @@ async function syncComponentsCommand(flags) {
1903
2114
  throw new Error(response.message || 'Server sync failed');
1904
2115
  }
1905
2116
 
2117
+ const summary = buildSyncSummary(response);
2118
+ const gate = evaluateSyncGates(response, flags, changedComponentsFromWorkingTree(cwd));
2119
+ summary.gates = gate.checks;
2120
+
1906
2121
  if (flagEnabled(flags.json)) {
1907
- console.log(JSON.stringify(response, null, 2));
2122
+ console.log(JSON.stringify(flagEnabled(flags.summary) ? summary : response, null, 2));
2123
+ failOnSyncGate(gate);
1908
2124
  return response;
1909
2125
  }
1910
2126
 
@@ -1915,10 +2131,169 @@ async function syncComponentsCommand(flags) {
1915
2131
  if (response.sync && response.sync.dryRun) {
1916
2132
  console.log('Dry run: no variants were written and staging was not cleared.');
1917
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
+ }
1918
2144
  console.log('Components sync:', JSON.stringify(response, null, 2));
2145
+ failOnSyncGate(gate);
1919
2146
  return response;
1920
2147
  }
1921
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
+
1922
2297
  // Inspect committed branch variants: durable, branch-scoped overlays of this account's components.
1923
2298
  // Unlike `components status` (which shows the ephemeral Redis staging cache), these are what
1924
2299
  // subscribing accounts actually resolve in production.
@@ -2272,6 +2647,15 @@ async function testCommand(flags) {
2272
2647
  // --variant-branch explicitly probes a committed variant branch. Normally omitted: variants resolve
2273
2648
  // from the account's subscription edge, which is what production does.
2274
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
+
2275
2659
  const start = await loggedPost(api, cwd, '/cli/test', {
2276
2660
  token: session.token,
2277
2661
  accountId,
@@ -2485,10 +2869,24 @@ async function toolCommand(flags) {
2485
2869
  if (data.threadGroupingId) console.log('Thread grouping ID:', data.threadGroupingId);
2486
2870
  console.log('Session log:', sessionJsonlFile(cwd));
2487
2871
  console.log('Tool response file:', statusResponse.responseFile);
2488
- if (data.status === 'failed') process.exitCode = 1;
2872
+ // A completed run can still carry a tool-level refusal — see toolResultFailed.
2873
+ const polledFailed = data.toolSuccess === false || toolResultFailed(data.result);
2874
+ if (polledFailed) {
2875
+ console.log('Tool error:', data.toolMessage || toolResultMessage(data.result));
2876
+ }
2877
+ if (data.status === 'failed' || polledFailed) process.exitCode = 1;
2489
2878
  return;
2490
2879
  }
2491
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
+
2492
2890
  const response = await loggedPost(api, cwd, '/cli/tool', {
2493
2891
  token: session.token,
2494
2892
  accountId,
@@ -2507,9 +2905,19 @@ async function toolCommand(flags) {
2507
2905
  throw new Error(data.message || 'Tool execution failed');
2508
2906
  }
2509
2907
 
2908
+ // `data.success` only means the tool was found and DISPATCHED. A tool that ran and refused (unmet
2909
+ // precondition, rejected enum value, validation failure) still comes back 200 with its own
2910
+ // success:false, and printing "Tool call succeeded" for that has caused agents to report work as
2911
+ // done that never happened. Prefer the server's hoisted verdict; fall back to introspecting the
2912
+ // result so this still works against an older platform build.
2913
+ const toolFailed = data.toolSuccess === false || toolResultFailed(data.result);
2914
+ const toolFailureMessage = data.toolMessage || toolResultMessage(data.result);
2915
+
2510
2916
  printSessionResolutionWarning(sessionContext);
2511
2917
  printResolvedBaseUrl(baseUrl);
2512
- console.log(asyncMode ? 'Tool call started.' : 'Tool call succeeded.');
2918
+ console.log(asyncMode ? 'Tool call started.'
2919
+ : (toolFailed ? 'Tool call FAILED — the tool ran and returned an error.' : 'Tool call succeeded.'));
2920
+ if (toolFailed && toolFailureMessage) console.log('Tool error:', toolFailureMessage);
2513
2921
  console.log('Call ID:', callId);
2514
2922
  console.log('Data mode:', data.dataMode || dataMode);
2515
2923
  if (data.variantBranch || variantBranch) console.log('Variant branch:', data.variantBranch || variantBranch);
@@ -2523,6 +2931,9 @@ async function toolCommand(flags) {
2523
2931
  console.log('Session log:', sessionJsonlFile(cwd));
2524
2932
  console.log('Tool response file:', response.responseFile);
2525
2933
 
2934
+ // Non-zero exit so scripted/agent callers that check status notice the refusal too.
2935
+ if (toolFailed) process.exitCode = 1;
2936
+
2526
2937
  if (asyncMode && waitForAsync) {
2527
2938
  const finalStatus = await waitForToolStatus(api, cwd, {
2528
2939
  token: session.token,
@@ -4934,6 +5345,113 @@ function autoUpdateIfNeeded(originalArgv, options = {}) {
4934
5345
  }
4935
5346
  }
4936
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
+
4937
5455
  async function main() {
4938
5456
  migrateSessionIfNeeded();
4939
5457
  const originalArgv = process.argv.slice(2);
@@ -4991,7 +5509,7 @@ async function main() {
4991
5509
  console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
4992
5510
  console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
4993
5511
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
4994
- 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]');
4995
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]');
4996
5514
  console.log(' remits-cli components branches [--json] # committed branch variants for this account');
4997
5515
  console.log(' remits-cli components branch <name> [--diff <componentId> --component-type <kind>] [--subscribers] [--json]');
@@ -5008,27 +5526,28 @@ async function main() {
5008
5526
  process.exit(0);
5009
5527
  }
5010
5528
 
5011
- if (command === 'components' && wantsHelp) {
5012
- console.log('Usage: remits-cli components <stage|status|clear|sync|commit|branches|branch>');
5013
- console.log(' remits-cli components stage [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--json|--verbose]');
5014
- console.log(' remits-cli components status [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE --component-id ID] [--json|--verbose]');
5015
- console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
5016
- console.log(' remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run]');
5017
- console.log(' remits-cli components commit [--message \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones]');
5018
- console.log('');
5019
- console.log(' --force-tombstones applies only on non-trunk variant syncs, when a missing trunk');
5020
- console.log(' component file is intentionally being recorded as a tombstone override.');
5021
- console.log(' --dry-run reports the variant override/add/remove plan without writing variants,');
5022
- console.log(' updating sync SHA cache, or clearing staged CLI entries.');
5023
- console.log('');
5024
- console.log('Committed branch variants (durable overlays subscribing accounts resolve in production):');
5025
- console.log(' remits-cli components branches [--json]');
5026
- console.log(' remits-cli components branch <name> [--json] # overridden/added/removed + drift');
5027
- console.log(' remits-cli components branch <name> --diff <componentId> --component-type <kind> [--json]');
5028
- console.log(' remits-cli components branch <name> --subscribers [--json]');
5029
- console.log(' remits-cli components branch <name> --subscribe <accountId> [--parent-account <id>] [--domain <host>]');
5030
- console.log(' remits-cli components branch <name> --unsubscribe <accountId>');
5031
- 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);
5032
5551
  process.exit(0);
5033
5552
  }
5034
5553