tr-plugin-auth 0.0.0-stage โ†’ 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +45 -2
  2. package/cli.mjs +384 -0
  3. package/package.json +40 -4
  4. package/plugin.json +302 -0
  5. package/src/access-keys.mjs +225 -0
  6. package/src/agent-dir.mjs +3 -0
  7. package/src/browser-session.mjs +240 -0
  8. package/src/cli-secret.mjs +1695 -0
  9. package/src/config.mjs +4 -0
  10. package/src/configuration.mjs +19 -0
  11. package/src/credential-preservation.mjs +28 -0
  12. package/src/crypto.mjs +195 -0
  13. package/src/env-import.mjs +502 -0
  14. package/src/fact-seeker.mjs +3 -0
  15. package/src/frontmatter.mjs +3 -0
  16. package/src/google-oauth.mjs +139 -0
  17. package/src/host-runtime.mjs +7 -0
  18. package/src/leader-election.mjs +9 -0
  19. package/src/logger.mjs +3 -0
  20. package/src/mesh-auth.mjs +7 -0
  21. package/src/mesh-identity.mjs +16 -0
  22. package/src/mesh-secrets.mjs +146 -0
  23. package/src/mesh.mjs +23 -0
  24. package/src/metadata-state.mjs +17 -0
  25. package/src/project-id.mjs +3 -0
  26. package/src/provider-account-sync.mjs +1145 -0
  27. package/src/provider-catalog.mjs +515 -0
  28. package/src/provider-rotation-recipes.mjs +299 -0
  29. package/src/research-gate.mjs +3 -0
  30. package/src/research-queue.mjs +3 -0
  31. package/src/rotation-capability.mjs +189 -0
  32. package/src/routes/auth.mjs +207 -0
  33. package/src/routes/keys.mjs +61 -0
  34. package/src/routes/secrets-runtime.mjs +5 -0
  35. package/src/routes/secrets.mjs +747 -0
  36. package/src/routes/webauthn.mjs +163 -0
  37. package/src/runtime-credentials.mjs +19 -0
  38. package/src/runtime.mjs +3 -0
  39. package/src/secret-integration-research.mjs +340 -0
  40. package/src/secrets-env-diff.mjs +132 -0
  41. package/src/secrets-env-export.mjs +322 -0
  42. package/src/secrets-gateway.mjs +253 -0
  43. package/src/secrets-keychain.mjs +83 -0
  44. package/src/secrets-rekey.mjs +366 -0
  45. package/src/secrets-remote-deploy.mjs +250 -0
  46. package/src/secrets-rotate.mjs +437 -0
  47. package/src/secrets-store.mjs +756 -0
  48. package/src/secrets-sync.mjs +81 -0
  49. package/src/secure-file.mjs +71 -0
  50. package/src/server-auth.mjs +500 -0
  51. package/src/session-credentials.mjs +71 -0
  52. package/src/ssss-store.mjs +13 -0
  53. package/src/stdin.mjs +31 -0
  54. package/src/throttled-fetch.mjs +3 -0
  55. package/src/vault.mjs +3 -0
  56. package/src/vfs-documents.mjs +3 -0
  57. package/src/webauthn-store.mjs +265 -0
  58. package/ui/DESIGN.md +19 -0
  59. package/ui/card.js +274 -0
  60. package/ui/panel.js +338 -0
@@ -0,0 +1,1695 @@
1
+ /**
2
+ * total-recall secret โ€” manage API keys and credentials (not in the memory vault).
3
+ *
4
+ * Usage:
5
+ * npx total-recall secret set <key> <value> [--provider name] [--scope global|project]
6
+ * npx total-recall secret get <key> # prints value (use carefully)
7
+ * npx total-recall secret list
8
+ * npx total-recall secret rotate <key> # prompts for the value
9
+ * npx total-recall secret delete <key>
10
+ * npx total-recall secret audit [--limit N]
11
+ * npx total-recall secret usage [--days N]
12
+ * npx total-recall secret usage-record --provider x --cost 0.01
13
+ */
14
+
15
+ import {
16
+ setSecret,
17
+ getSecret,
18
+ listSecretsMeta,
19
+ rotateSecret,
20
+ deleteSecret,
21
+ readSecretAudit,
22
+ recordUsage,
23
+ summarizeUsage,
24
+ summarizeUsageByKey,
25
+ loadBudgetConfig,
26
+ resolveSecretsPath,
27
+ textContainsSecrets,
28
+ getSecretsCatalog,
29
+ updateSecretMeta,
30
+ listRotationDue,
31
+ getSharedValueHealth,
32
+ loadSecrets,
33
+ } from './secrets-store.mjs';
34
+ import {
35
+ scanEnvSources,
36
+ publicScanResult,
37
+ importEnvSecrets,
38
+ parseEnvText,
39
+ inferProvider,
40
+ } from './env-import.mjs';
41
+ import {
42
+ exportEnvToProject,
43
+ exportEnvToRegistry,
44
+ buildEnvProjection,
45
+ buildDeploySecretsPayload,
46
+ } from './secrets-env-export.mjs';
47
+ import {
48
+ rotateSecretAndExport,
49
+ enqueueRotationDueTasks,
50
+ getBrowserRotateAssist,
51
+ } from './secrets-rotate.mjs';
52
+ import {
53
+ loadRemoteTargets,
54
+ addRemoteTarget,
55
+ removeRemoteTarget,
56
+ deployEnvToRemote,
57
+ deployKeyToRemotes,
58
+ } from './secrets-remote-deploy.mjs';
59
+ import { listProviders, getProvider } from './provider-catalog.mjs';
60
+ import { resolveBrainDir, parseLayerFlag, detectProjectBrain } from './agent-dir.mjs';
61
+ import fs from 'node:fs';
62
+ import { ensureProjectId } from './project-id.mjs';
63
+ import path from 'node:path';
64
+ import { execFileSync } from 'node:child_process';
65
+ import os from 'node:os';
66
+ import {
67
+ generateSecretsMasterPassword,
68
+ readMasterPasswordFromCarrier,
69
+ rekeySecretsTransaction,
70
+ } from './secrets-rekey.mjs';
71
+ import {
72
+ DEFAULT_KEYCHAIN_SERVICE,
73
+ keychainAvailable,
74
+ readKeychainPassword,
75
+ } from './secrets-keychain.mjs';
76
+ import { agentDir as configuredAgentDir, getActiveBrains } from './config.mjs';
77
+ import { readAllStdinSync } from './stdin.mjs';
78
+
79
+ // Reads a secret value from stdin (fd 0) so it never has to appear as a CLI
80
+ // argument โ€” CLI args land in shell history, `ps aux`, and any transcript
81
+ // that echoes the command. Trims exactly one trailing newline (what a
82
+ // pasted/piped value normally carries), not all whitespace, so a value that
83
+ // legitimately starts or ends with a space round-trips intact.
84
+ function readStdinValue() {
85
+ // A terminal never sends EOF on Enter, so a plain read would wait for a
86
+ // Ctrl-D on an empty line. Read one line (Enter ends it) with echo off.
87
+ if (process.stdin.isTTY) return readTtyLine();
88
+ const raw = readAllStdinSync();
89
+ return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
90
+ }
91
+
92
+ function readTtyLine() {
93
+ const stty = (arg) => { try { execFileSync('stty', [arg], { stdio: ['inherit', 'ignore', 'ignore'] }); } catch { /* not a tty */ } };
94
+ process.stderr.write('Paste the value and press Enter: ');
95
+ stty('-echo');
96
+ let line = '';
97
+ try {
98
+ const chunk = Buffer.alloc(4096);
99
+ for (;;) {
100
+ let n;
101
+ try { n = fs.readSync(0, chunk, 0, chunk.length, null); } catch (err) { if (err.code === 'EAGAIN') continue; throw err; }
102
+ if (n === 0) break;
103
+ line += chunk.toString('utf8', 0, n);
104
+ if (/[\r\n]/.test(line)) break;
105
+ }
106
+ } finally {
107
+ stty('echo');
108
+ process.stderr.write('\n');
109
+ }
110
+ return line.split(/[\r\n]/)[0];
111
+ }
112
+
113
+ /**
114
+ * Read the OS clipboard. Keeps a pasted credential inside this process rather
115
+ * than routing it through argv, shell history, or an agent transcript.
116
+ *
117
+ * @returns {string}
118
+ */
119
+ function readSystemClipboard() {
120
+ const cmds =
121
+ process.platform === 'darwin'
122
+ ? [['pbpaste', []]]
123
+ : process.platform === 'win32'
124
+ ? [['powershell', ['-NoProfile', '-Command', 'Get-Clipboard']]]
125
+ : [
126
+ ['wl-paste', ['--no-newline']],
127
+ ['xclip', ['-selection', 'clipboard', '-o']],
128
+ ['xsel', ['--clipboard', '--output']],
129
+ ];
130
+ for (const [cmd, args] of cmds) {
131
+ try {
132
+ return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
133
+ } catch {
134
+ // try the next clipboard tool
135
+ }
136
+ }
137
+ return '';
138
+ }
139
+
140
+ /**
141
+ * Read a credential without echoing it and without it ever becoming an argv
142
+ * element. `argv` is world-readable via `ps`, lands in shell history, and is
143
+ * captured verbatim by any agent driving this CLI. Falls back to reading one
144
+ * line from stdin when the input is piped rather than a terminal.
145
+ *
146
+ * @param {string} label
147
+ * @returns {Promise<string>}
148
+ */
149
+ function readSecretValue(label) {
150
+ return new Promise((resolve, reject) => {
151
+ const input = process.stdin;
152
+ if (!input.isTTY) {
153
+ resolve(readStdinValue());
154
+ return;
155
+ }
156
+ process.stdout.write(label);
157
+ input.setRawMode(true);
158
+ input.resume();
159
+ input.setEncoding('utf8');
160
+ let buf = '';
161
+ // A paste arrives as one chunk (often with the Enter in it): walk it character by character.
162
+ const onData = (chunk) => { for (const ch of chunk) { if (onChar(ch)) return; } };
163
+ const onChar = (ch) => {
164
+ if (ch === '\n' || ch === '\r' || ch === '\u0004') {
165
+ input.setRawMode(false);
166
+ input.pause();
167
+ input.removeListener('data', onData);
168
+ process.stdout.write('\n');
169
+ resolve(buf.trim());
170
+ return true;
171
+ } else if (ch === '\u0003') {
172
+ input.setRawMode(false);
173
+ process.stdout.write('\n');
174
+ process.exit(130);
175
+ } else if (ch === '\u007f' || ch === '\b') {
176
+ buf = buf.slice(0, -1);
177
+ } else {
178
+ buf += ch;
179
+ }
180
+ };
181
+ input.on('data', onData);
182
+ });
183
+ }
184
+
185
+ function printHelp() {
186
+ console.log(`
187
+ total-recall secret โ€” Secrets store (separate from memory vault)
188
+
189
+ Usage: total-recall secret <command> [options]
190
+
191
+ Commands:
192
+ set <key> [value] Store a secret (encrypted, unlock required; 0600 file)
193
+ --stdin reads the value from fd0 instead of argv,
194
+ so it never lands in shell history/ps/a transcript
195
+ generate <key> Generate a random secret for outside integrations
196
+ get <key> [--json] Read selected-store value (audited); JSON reports found/value
197
+ list List keys + metadata only (no values)
198
+ rotate <key> Replace value and mark rotated. Prompts for the value;
199
+ --stdin reads fd0, --from-clipboard reads the OS
200
+ clipboard (use the browser you are already signed
201
+ into). The value never appears in argv or output.
202
+ rekey Rotate the secrets-store master password transactionally.
203
+ The replacement is generated in memory by default;
204
+ --stdin accepts a coordinated replacement without argv.
205
+ --launch-agent/--env-file/--keychain select persistent
206
+ carriers. The macOS Keychain entry is included
207
+ automatically when present, because a shell profile
208
+ that reads it would otherwise be left on the retired
209
+ password; --no-keychain opts out.
210
+ delete <key> Remove a secret
211
+ audit [--limit N] Recent set/get/rotate events (no values)
212
+ usage [--days N] Sum usage.jsonl for last N days (default 1)
213
+ usage-record Append a usage event (see flags)
214
+ check-surfaces Fail if any secret value appears in INSTRUCTIONS.md / openwiki
215
+ export-env Write .env FROM secrets store (SSOT โ†’ repo/deploy projection)
216
+ --path <dir> Target repo root (default: cwd)
217
+ --all-projects Export to every project-registry path
218
+ --filename .env Output name (default .env)
219
+ --no-example Skip .env.example
220
+ --no-global Only keys bound to this repo (skip unbound)
221
+ --replace-all Overwrite entire .env (default: merge TR block only)
222
+ --remote <name> Also push the SSOT projection to a configured remote
223
+ target over SSH (see 'secret remote') instead of
224
+ (or in addition to) a local file
225
+ --dry-run Show keys that would be written
226
+ remote <cmd> Manage remote (production) deploy targets โ€” generic,
227
+ per-repo config, no host/path ever hardcoded in TR
228
+ add <name> --host <h> --path </remote/dir> [--user root]
229
+ [--port 22] [--filename .env] [--restart-cmd "..."]
230
+ list Show configured targets (no secret values)
231
+ remove <name> Delete a target
232
+ deploy <name> Push the SSOT projection to that target now
233
+ [--dry-run] [--keys k1,k2]
234
+ diff-env Compare repo .env files against the store (no values printed)
235
+ --path <dir> Single repo (default: every project-registry path)
236
+ --filename .env File to compare (default .env; server/<name> also checked)
237
+ --strict Exit 1 on any drift or unadopted key
238
+ --layer project Compare against the project-local store (default: global)
239
+ import-env Import selected local .env credentials into auth
240
+ --all / --file / --overwrite / --dry-run
241
+ catalog Full catalog: keys, providers, usage, rotation, cost, tracking
242
+ providers List provider registry (docs/schema/tiers)
243
+ account-sync [key] Live-probe vendor account/usage/subscription APIs
244
+ --all Sync every set secret (default if no key)
245
+ --strict partial (no $ API) โ†’ ERROR (default)
246
+ --no-strict Allow partial status
247
+ --use-ai AI-classify unknown keys (slow)
248
+ --exempt Mark key tracking_exempt (with optional --monthly-cost)
249
+ tracking-health Fail (exit 1) if any set secret is untracked
250
+ shared List credential values reused across keys/apps (exit 1 if any)
251
+ --shared-ok Mark key as intentional share (waive) โ€” use with meta
252
+ meta <key> Update metadata (no value change)
253
+ --tracking-exempt Waive live account API requirement
254
+ --no-tracking-exempt
255
+ --shared-ok Allow this key to share its value with other secret names
256
+ --no-shared-ok
257
+ --repo <name> Bind to exactly ONE product repo (not multi)
258
+ --repos <name> Same as --repo (single name only; multi rejected)
259
+ --tier <id> Subscription tier
260
+ --monthly-cost <n> Planned monthly $
261
+ --monthly-cap <n> Cap $
262
+ --docs <url> API docs URL
263
+ --rotate-days <n> Auto-rotation interval
264
+ --auto-rotate Enable auto-rotate flag
265
+ --tags <a,b,c> Optional tags saying what the key is and what uses it
266
+ --notes <text>
267
+ --label <text>
268
+ --project <path>
269
+ --headscale-url <https-url>
270
+ rotation-due List keys overdue for rotation
271
+ --enqueue Create daemon tasks + browser-use prompts for each due key
272
+ rotate-browser <key> Drive the provider console in TR's browser and rotate
273
+ --print-only Only print instructions
274
+ --headless Allow headless for verified recipes
275
+ rotate-auto <key> Rotate by whatever method the key's class supports
276
+ rotation-status Rotation coverage for every key in the vault
277
+ --json Machine-readable output
278
+ browser-logout Delete TR's persistent browser profile (all sessions)
279
+ usage Cost/events (add --key-ref for per-key)
280
+
281
+ rotate options:
282
+ --export-env After rotate, write .env to bound repos (and cwd if unbound)
283
+ --export-all After rotate, export-env to all project-registry paths
284
+ --remote <name> After rotate, also push to a configured remote target
285
+ (production, etc.) over SSH โ€” see 'secret remote'
286
+
287
+ set / rotate options:
288
+ --provider <name> Optional provider tag (e.g. openai, anthropic)
289
+ --scope global|project
290
+ --repo / --repos Bind secret to repo(s)
291
+ --tier / --monthly-cost / --rotate-days
292
+ --headscale-url <https-url> Headscale control-plane URL metadata
293
+
294
+ usage-record options:
295
+ --provider <name>
296
+ --model <id>
297
+ --input-tokens <n>
298
+ --output-tokens <n>
299
+ --cost <usd>
300
+ --key-ref <secret-name> name only, never the value
301
+
302
+ Env:
303
+ TR_SECRETS_PASSWORD Unlock the required AES-256-GCM encrypted store
304
+
305
+ Never put secrets in remember/vault/openwiki/instruction shims.
306
+ `);
307
+ }
308
+
309
+ function parseArgs(args) {
310
+ const out = {
311
+ command: null,
312
+ key: null,
313
+ value: null,
314
+ provider: null,
315
+ scope: 'global',
316
+ limit: 50,
317
+ days: 1,
318
+ model: null,
319
+ input_tokens: 0,
320
+ output_tokens: 0,
321
+ cost: null,
322
+ key_ref: null,
323
+ help: false,
324
+ all: false,
325
+ dryRun: false,
326
+ overwrite: false,
327
+ file: null,
328
+ keys: [],
329
+ repos: [],
330
+ tier: null,
331
+ monthly_cost: null,
332
+ monthly_cap: null,
333
+ docs: null,
334
+ rotate_days: null,
335
+ auto_rotate: false,
336
+ notes: null,
337
+ tags: null,
338
+ label: null,
339
+ project: null,
340
+ headscale_url: null,
341
+ path: null,
342
+ allProjects: false,
343
+ filename: '.env',
344
+ noExample: false,
345
+ noGlobal: false,
346
+ replaceAll: false,
347
+ exportEnv: false,
348
+ exportAll: false,
349
+ enqueue: false,
350
+ bytes: 32,
351
+ format: 'base64url',
352
+ strict: true,
353
+ use_ai: false,
354
+ exempt: false,
355
+ tracking_exempt: null,
356
+ shared_ok: null,
357
+ subcommand: null,
358
+ remote: null,
359
+ remoteHost: null,
360
+ remoteUser: 'root',
361
+ remotePort: 22,
362
+ restartCmd: null,
363
+ stdin: false,
364
+ launchAgents: [],
365
+ envFiles: [],
366
+ keychainServices: [],
367
+ keychain: true,
368
+ storePaths: [],
369
+ };
370
+ if (!args.length || args[0] === '--help' || args[0] === '-h') {
371
+ out.help = true;
372
+ return out;
373
+ }
374
+ out.command = args[0];
375
+ let i = 1;
376
+ if (out.command === 'remote') {
377
+ if (args[i] && !args[i].startsWith('--')) out.subcommand = args[i++];
378
+ if (['add', 'remove', 'rm', 'deploy'].includes(out.subcommand) && args[i] && !args[i].startsWith('--')) {
379
+ out.key = args[i++]; // reused as the remote target name
380
+ }
381
+ }
382
+ if (
383
+ [
384
+ 'set',
385
+ 'get',
386
+ 'rotate',
387
+ 'delete',
388
+ 'meta',
389
+ 'rotate-browser',
390
+ 'rotate-auto',
391
+ 'rm',
392
+ 'generate',
393
+ 'account-sync',
394
+ ].includes(out.command) &&
395
+ args[i] &&
396
+ !args[i].startsWith('--')
397
+ ) {
398
+ out.key = args[i++];
399
+ }
400
+ if (['set', 'rotate'].includes(out.command) && args[i] && !args[i].startsWith('--')) {
401
+ out.value = args[i++];
402
+ }
403
+ while (i < args.length) {
404
+ const a = args[i];
405
+ switch (a) {
406
+ case '--remote':
407
+ out.remote = args[++i];
408
+ break;
409
+ case '--host':
410
+ out.remoteHost = args[++i];
411
+ break;
412
+ case '--user':
413
+ out.remoteUser = args[++i] || 'root';
414
+ break;
415
+ case '--port':
416
+ out.remotePort = parseInt(args[++i], 10) || 22;
417
+ break;
418
+ case '--restart-cmd':
419
+ out.restartCmd = args[++i];
420
+ break;
421
+ case '--provider':
422
+ out.provider = args[++i];
423
+ break;
424
+ case '--tags':
425
+ out.tags = args[++i];
426
+ break;
427
+ case '--scope':
428
+ out.scope = args[++i];
429
+ break;
430
+ case '--limit':
431
+ out.limit = parseInt(args[++i], 10) || 50;
432
+ break;
433
+ case '--days':
434
+ out.days = parseInt(args[++i], 10) || 1;
435
+ break;
436
+ case '--model':
437
+ out.model = args[++i];
438
+ break;
439
+ case '--input-tokens':
440
+ out.input_tokens = parseInt(args[++i], 10) || 0;
441
+ break;
442
+ case '--output-tokens':
443
+ out.output_tokens = parseInt(args[++i], 10) || 0;
444
+ break;
445
+ case '--cost':
446
+ out.cost = parseFloat(args[++i]);
447
+ break;
448
+ case '--key-ref':
449
+ out.key_ref = args[++i];
450
+ break;
451
+ case '--all':
452
+ out.all = true;
453
+ break;
454
+ case '--dry-run':
455
+ out.dryRun = true;
456
+ break;
457
+ case '--overwrite':
458
+ out.overwrite = true;
459
+ break;
460
+ case '--file':
461
+ out.file = args[++i];
462
+ break;
463
+ case '--repo':
464
+ out.repos.push(args[++i]);
465
+ break;
466
+ case '--repos':
467
+ out.repos.push(
468
+ ...String(args[++i] || '')
469
+ .split(',')
470
+ .map((s) => s.trim())
471
+ .filter(Boolean),
472
+ );
473
+ break;
474
+ case '--keys':
475
+ out.keys.push(
476
+ ...String(args[++i] || '')
477
+ .split(',')
478
+ .map((s) => s.trim())
479
+ .filter(Boolean),
480
+ );
481
+ break;
482
+ case '--tier':
483
+ out.tier = args[++i];
484
+ break;
485
+ case '--monthly-cost':
486
+ out.monthly_cost = parseFloat(args[++i]);
487
+ break;
488
+ case '--monthly-cap':
489
+ out.monthly_cap = parseFloat(args[++i]);
490
+ break;
491
+ case '--docs':
492
+ out.docs = args[++i];
493
+ break;
494
+ case '--rotate-days':
495
+ out.rotate_days = parseInt(args[++i], 10);
496
+ break;
497
+ case '--auto-rotate':
498
+ out.auto_rotate = true;
499
+ break;
500
+ case '--notes':
501
+ out.notes = args[++i];
502
+ break;
503
+ case '--label':
504
+ out.label = args[++i];
505
+ break;
506
+ case '--project':
507
+ out.project = args[++i];
508
+ break;
509
+ case '--headscale-url':
510
+ out.headscale_url = args[++i];
511
+ break;
512
+ case '--path':
513
+ out.path = args[++i];
514
+ break;
515
+ case '--layer':
516
+ out.layer = args[++i];
517
+ break;
518
+ case '--all-projects':
519
+ out.allProjects = true;
520
+ break;
521
+ case '--filename':
522
+ out.filename = args[++i] || '.env';
523
+ break;
524
+ case '--no-example':
525
+ out.noExample = true;
526
+ break;
527
+ case '--no-global':
528
+ out.noGlobal = true;
529
+ break;
530
+ case '--replace-all':
531
+ out.replaceAll = true;
532
+ break;
533
+ case '--from-clipboard':
534
+ out.fromClipboard = true;
535
+ break;
536
+ case '--export-env':
537
+ out.exportEnv = true;
538
+ break;
539
+ case '--export-all':
540
+ out.exportAll = true;
541
+ out.exportEnv = true;
542
+ break;
543
+ case '--enqueue':
544
+ out.enqueue = true;
545
+ break;
546
+ case '--print-only':
547
+ out.printOnly = true;
548
+ break;
549
+ case '--headless':
550
+ out.headless = true;
551
+ break;
552
+ case '--json':
553
+ out.json = true;
554
+ break;
555
+ case '--bytes':
556
+ out.bytes = parseInt(args[++i], 10) || 32;
557
+ break;
558
+ case '--format':
559
+ out.format = args[++i] || 'base64url';
560
+ break;
561
+ case '--strict':
562
+ out.strict = true;
563
+ break;
564
+ case '--no-strict':
565
+ out.strict = false;
566
+ break;
567
+ case '--use-ai':
568
+ out.use_ai = true;
569
+ break;
570
+ case '--exempt':
571
+ out.exempt = true;
572
+ break;
573
+ case '--tracking-exempt':
574
+ out.tracking_exempt = true;
575
+ break;
576
+ case '--no-tracking-exempt':
577
+ out.tracking_exempt = false;
578
+ break;
579
+ case '--shared-ok':
580
+ out.shared_ok = true;
581
+ break;
582
+ case '--no-shared-ok':
583
+ out.shared_ok = false;
584
+ break;
585
+ case '--stdin':
586
+ out.stdin = true;
587
+ break;
588
+ case '--launch-agent':
589
+ out.launchAgents.push(args[++i]);
590
+ break;
591
+ case '--env-file':
592
+ out.envFiles.push(args[++i]);
593
+ break;
594
+ case '--keychain':
595
+ out.keychainServices.push(args[++i]);
596
+ break;
597
+ case '--no-keychain':
598
+ out.keychain = false;
599
+ break;
600
+ case '--store':
601
+ out.storePaths.push(args[++i]);
602
+ break;
603
+ case '--help':
604
+ case '-h':
605
+ out.help = true;
606
+ break;
607
+ default:
608
+ if (!a.startsWith('--') && out.command === 'import-env') {
609
+ out.keys.push(a);
610
+ } else if (!a.startsWith('--') && !out.key) out.key = a;
611
+ else if (!a.startsWith('--') && !out.value) out.value = a;
612
+ }
613
+ i++;
614
+ }
615
+ return out;
616
+ }
617
+
618
+ /** Id of the project the command runs in (created on first use), or null outside a project. */
619
+ function currentProjectId() {
620
+ try {
621
+ const project = detectProjectBrain(process.cwd());
622
+ return project ? ensureProjectId(project.brainDir).project_id : null;
623
+ } catch {
624
+ return null;
625
+ }
626
+ }
627
+
628
+ export default async function secretCli(argv) {
629
+ const { layer, remainingArgs } = parseLayerFlag(argv);
630
+ const opts = parseArgs(remainingArgs);
631
+ if (opts.help || !opts.command) {
632
+ printHelp();
633
+ return;
634
+ }
635
+
636
+ let brainDir;
637
+ try {
638
+ brainDir = process.env.TR_SECRETS_BRAIN || process.env.TR_BRAIN || resolveBrainDir(layer);
639
+ } catch (err) {
640
+ console.error(`โŒ ${err.message}`);
641
+ process.exit(1);
642
+ }
643
+
644
+ if (opts.stdin) {
645
+ if (!['set', 'rotate', 'rekey'].includes(opts.command)) {
646
+ console.error('โŒ --stdin is only valid with `set`, `rotate`, or `rekey`');
647
+ process.exit(1);
648
+ }
649
+ if (opts.command !== 'rekey' && !opts.key) {
650
+ console.error(`Usage: total-recall secret ${opts.command} <key> --stdin`);
651
+ process.exit(1);
652
+ }
653
+ opts.value = readStdinValue();
654
+ if (!opts.value) {
655
+ console.error('โŒ --stdin read an empty value');
656
+ process.exit(1);
657
+ }
658
+ }
659
+
660
+ try {
661
+ if (opts.command === 'rekey') {
662
+ const carriers = [
663
+ ...opts.launchAgents.filter(Boolean).map((carrierPath) => ({
664
+ path: path.resolve(carrierPath),
665
+ type: 'launchd-plist',
666
+ allowInsert: true,
667
+ })),
668
+ ...opts.envFiles.filter(Boolean).map((carrierPath) => ({
669
+ path: path.resolve(carrierPath),
670
+ type: 'env',
671
+ allowInsert: true,
672
+ })),
673
+ ];
674
+
675
+ if (!carriers.length && process.platform === 'darwin') {
676
+ const launchAgentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
677
+ if (fs.existsSync(launchAgentsDir)) {
678
+ for (const entry of fs.readdirSync(launchAgentsDir)) {
679
+ if (!entry.endsWith('.plist')) continue;
680
+ const carrierPath = path.join(launchAgentsDir, entry);
681
+ const source = fs.readFileSync(carrierPath, 'utf8');
682
+ if (source.includes('<key>TR_SECRETS_PASSWORD</key>')) {
683
+ carriers.push({ path: carrierPath, type: 'launchd-plist' });
684
+ }
685
+ }
686
+ }
687
+ }
688
+
689
+ // A shell profile that reads the password from the Keychain is the most
690
+ // common macOS arrangement, and it is invisible to a file-carrier scan.
691
+ // Left out, a rotation succeeds and every interactive shell breaks at
692
+ // once โ€” so the entry is picked up by default when it exists.
693
+ const keychainCarriers = opts.keychainServices.filter(Boolean).map((service) => ({ service }));
694
+ if (!keychainCarriers.length && opts.keychain !== false && keychainAvailable()) {
695
+ if (readKeychainPassword({ service: DEFAULT_KEYCHAIN_SERVICE })) {
696
+ keychainCarriers.push({ service: DEFAULT_KEYCHAIN_SERVICE });
697
+ }
698
+ }
699
+
700
+ if (!carriers.length && !keychainCarriers.length) {
701
+ console.error(
702
+ 'โŒ No persistent master-password carrier found. Use --launch-agent <path>, --env-file <path> or --keychain <service>.',
703
+ );
704
+ process.exit(1);
705
+ }
706
+
707
+ const currentPasswords = [
708
+ ...carriers.map((carrier) => readMasterPasswordFromCarrier(carrier)),
709
+ ...keychainCarriers.map((carrier) => readKeychainPassword(carrier)),
710
+ ].filter(Boolean);
711
+ const oldPassword = currentPasswords[0] || process.env.TR_SECRETS_PASSWORD;
712
+ if (!oldPassword) {
713
+ console.error('โŒ The current master password is unavailable from the selected carriers.');
714
+ process.exit(1);
715
+ }
716
+ if (currentPasswords.some((value) => value !== oldPassword)) {
717
+ console.error('โŒ Selected credential carriers do not contain the same current master password.');
718
+ console.error(' Every carrier must hold the same password before one can replace it.');
719
+ process.exit(1);
720
+ }
721
+
722
+ const defaultBrainDirs = [
723
+ configuredAgentDir,
724
+ brainDir,
725
+ ...Object.values(getActiveBrains()).map((brain) => brain?.brainDir),
726
+ ].filter(Boolean);
727
+ const storePaths = opts.storePaths.length
728
+ ? opts.storePaths.map((storePath) => path.resolve(storePath))
729
+ : [...new Set(defaultBrainDirs.map((dir) => resolveSecretsPath(dir)))].filter(
730
+ (storePath) => fs.existsSync(storePath),
731
+ );
732
+ const newPassword = opts.value || generateSecretsMasterPassword();
733
+ const result = await rekeySecretsTransaction({
734
+ storePaths,
735
+ carriers,
736
+ keychainCarriers,
737
+ oldPassword,
738
+ newPassword,
739
+ });
740
+
741
+ console.log(`\n โœ… Secrets master password rotated transactionally`);
742
+ console.log(` stores re-encrypted and verified: ${result.stores.length}`);
743
+ console.log(` persistent carriers updated: ${result.carriers.length}`);
744
+ console.log(` keychain entries updated: ${result.keychain.length}`);
745
+ console.log(` retired password rejected: yes`);
746
+ console.log(` replacement value was not printed`);
747
+ console.log(` restart affected services so they load the new credential\n`);
748
+ return;
749
+ }
750
+
751
+ if (opts.command === 'set') {
752
+ if (!opts.key || opts.value == null) {
753
+ console.error('Usage: total-recall secret set <key> <value>');
754
+ process.exit(1);
755
+ }
756
+ await setSecret(brainDir, opts.key, opts.value, {
757
+ tags: opts.tags != null ? opts.tags : undefined,
758
+ project_id: currentProjectId(),
759
+ provider: opts.provider || inferProvider(opts.key),
760
+ scope: opts.scope,
761
+ repos: opts.repos.length ? opts.repos : undefined,
762
+ subscription_tier: opts.tier,
763
+ monthly_cost_usd: opts.monthly_cost,
764
+ monthly_cap_usd: opts.monthly_cap,
765
+ api_docs_url: opts.docs,
766
+ rotate_every_days: opts.rotate_days,
767
+ auto_rotate: opts.auto_rotate,
768
+ notes: opts.notes,
769
+ label: opts.label,
770
+ project_path: opts.project,
771
+ headscale_url: opts.headscale_url,
772
+ });
773
+ console.log(`\n โœ… Secret set: ${opts.key}`);
774
+ console.log(` store: ${resolveSecretsPath(brainDir)}`);
775
+ console.log(` (value not printed)\n`);
776
+ return;
777
+ }
778
+
779
+ if (opts.command === 'generate') {
780
+ if (!opts.key) {
781
+ console.error('Usage: total-recall secret generate <key> [--bytes 32] [--format hex|base64|base64url]');
782
+ process.exit(1);
783
+ }
784
+ const crypto = await import('node:crypto');
785
+ const val = crypto.randomBytes(opts.bytes).toString(opts.format);
786
+ await setSecret(brainDir, opts.key, val, {
787
+ provider: opts.provider || inferProvider(opts.key),
788
+ scope: opts.scope,
789
+ repos: opts.repos.length ? opts.repos : undefined,
790
+ project_path: opts.project,
791
+ notes: opts.notes || 'Generated by CLI for outside integration',
792
+ });
793
+ console.log(`\n โœ… Secret generated: ${opts.key}`);
794
+ console.log(` Value: ${val}`);
795
+ console.log(` store: ${resolveSecretsPath(brainDir)}\n`);
796
+ return;
797
+ }
798
+
799
+ if (opts.command === 'catalog') {
800
+ const cat = await getSecretsCatalog(brainDir);
801
+ console.log(`\n ๐Ÿ“ฆ Secrets catalog โ€” ${cat.store}`);
802
+ console.log(
803
+ ` keys=${cat.summary.total_keys} providers=${cat.summary.providers_active} planned/mo=$${cat.summary.monthly_subscription_usd.toFixed(2)} usage30d=$${cat.summary.usage_30d.cost_usd.toFixed(4)} rotate_due=${cat.summary.rotation_overdue}\n`,
804
+ );
805
+
806
+ // Group: multi-repo errors โ†’ one section per product repo โ†’ Developer secrets
807
+ const byRepo = new Map();
808
+ const developer = [];
809
+ const multiError = [];
810
+ for (const k of cat.keys) {
811
+ const repos = Array.isArray(k.repos) ? k.repos.filter(Boolean) : [];
812
+ if (repos.length > 1 || k.multi_repo_error) {
813
+ multiError.push(k);
814
+ continue;
815
+ }
816
+ if (!repos.length) {
817
+ developer.push(k);
818
+ continue;
819
+ }
820
+ const r = repos[0];
821
+ if (!byRepo.has(r)) byRepo.set(r, []);
822
+ byRepo.get(r).push(k);
823
+ }
824
+ const sections = [];
825
+ if (multiError.length) sections.push(['โš ๏ธ NEEDS FIX โ€” multi-repo (one key โ†’ one repo only)', multiError]);
826
+ for (const [repo, list] of [...byRepo.entries()].sort(([a], [b]) => a.localeCompare(b))) {
827
+ sections.push([repo, list]);
828
+ }
829
+ if (developer.length) sections.push(['Developer secrets (tooling / unbound)', developer]);
830
+
831
+ if (multiError.length) {
832
+ console.log(
833
+ ` โŒ ${multiError.length} key(s) bound to multiple repos โ€” fix with:\n` +
834
+ ` secret meta <KEY> --repo <one-repo>\n` +
835
+ ` or clear repos for Developer secrets\n`,
836
+ );
837
+ }
838
+
839
+ for (const [repo, list] of sections) {
840
+ console.log(` โ”€โ”€ ${repo} (${list.length})`);
841
+ for (const k of list.sort((a, b) => a.key.localeCompare(b.key))) {
842
+ const due = k.rotation_overdue
843
+ ? 'โš ๏ธ ROTATE'
844
+ : k.next_rotate_due
845
+ ? `due ${k.next_rotate_due.slice(0, 10)}`
846
+ : '';
847
+ const multi =
848
+ k.multi_repo_error || (k.repos?.length > 1)
849
+ ? ` โŒ multi=[${(k.repos || []).join(',')}]`
850
+ : '';
851
+ console.log(
852
+ ` โ€ข ${k.key.padEnd(28)} ${(k.provider || '-').padEnd(12)} tier=${(k.subscription_tier || '-').padEnd(8)} $${String(k.monthly_cost_usd ?? '-').padStart(6)} u30=$${k.usage_30d.cost_usd.toFixed(3)} ${due}${multi}`,
853
+ );
854
+ if (k.api_docs_url) console.log(` docs: ${k.api_docs_url}`);
855
+ }
856
+ console.log('');
857
+ }
858
+ return;
859
+ }
860
+
861
+ if (opts.command === 'providers') {
862
+ console.log('\n Provider registry\n');
863
+ for (const p of listProviders()) {
864
+ console.log(` โ€ข ${p.id.padEnd(14)} ${p.name}`);
865
+ console.log(` docs: ${p.docs_url}`);
866
+ if (p.pricing_url) console.log(` pricing: ${p.pricing_url}`);
867
+ console.log(` keys: ${p.key_patterns.join(', ')}`);
868
+ console.log(` auth: ${p.schema.auth} ${p.schema.header || ''}`);
869
+ if (p.tiers?.length) {
870
+ console.log(` tiers: ${p.tiers.map((t) => t.id).join(', ')}`);
871
+ }
872
+ console.log('');
873
+ }
874
+ return;
875
+ }
876
+
877
+ if (opts.command === 'account-sync' || opts.command === 'sync-accounts') {
878
+ const {
879
+ syncSecretAccount,
880
+ syncAllSecretAccounts,
881
+ getTrackingHealth,
882
+ } = await import('./provider-account-sync.mjs');
883
+ const syncOpts = {
884
+ strict: opts.strict !== false,
885
+ use_ai: !!opts.use_ai,
886
+ force_exempt: !!opts.exempt,
887
+ };
888
+ if (opts.key) {
889
+ if (opts.exempt) {
890
+ await updateSecretMeta(
891
+ brainDir,
892
+ opts.key,
893
+ {
894
+ tracking_exempt: true,
895
+ monthly_cost_usd: opts.monthly_cost ?? undefined,
896
+ },
897
+ { actor: 'cli' },
898
+ );
899
+ }
900
+ const r = await syncSecretAccount(brainDir, opts.key, syncOpts);
901
+ const icon =
902
+ r.tracking_status === 'ok' ? 'โœ…' : r.tracking_status === 'exempt' ? 'โญ' : 'โŒ';
903
+ console.log(`\n ${icon} ${r.key} status=${r.tracking_status} provider=${r.provider || '-'}`);
904
+ if (r.probe) console.log(` probe: ${r.probe}`);
905
+ if (r.error) console.log(` error: ${r.error}`);
906
+ if (r.account) console.log(` account: ${JSON.stringify(r.account)}`);
907
+ if (r.usage) console.log(` usage: ${JSON.stringify(r.usage)}`);
908
+ if (r.subscription) console.log(` subscription: ${JSON.stringify(r.subscription)}`);
909
+ console.log('');
910
+ if (r.tracking_status === 'error') process.exit(1);
911
+ return;
912
+ }
913
+ console.log('\n ๐Ÿ”„ Live provider account/usage sync (all set secrets)โ€ฆ\n');
914
+ const report = await syncAllSecretAccounts(brainDir, syncOpts);
915
+ console.log(` total=${report.total} ok/exempt=${report.ok} errors=${report.errors}`);
916
+ console.log(` ${report.healthy ? 'โœ…' : 'โŒ'} ${report.message}`);
917
+ if (report.error_keys?.length) {
918
+ console.log('\n Untracked / failed:');
919
+ for (const e of report.error_keys.slice(0, 50)) {
920
+ console.log(` โ€ข ${e.key}: ${e.error}`);
921
+ }
922
+ if (report.error_keys.length > 50) {
923
+ console.log(` โ€ฆ and ${report.error_keys.length - 50} more`);
924
+ }
925
+ }
926
+ const health = await getTrackingHealth(brainDir);
927
+ console.log(
928
+ `\n tracking-health: healthy=${health.healthy} ok=${health.ok} exempt=${health.exempt} errors=${health.errors}\n`,
929
+ );
930
+ if (!report.healthy) process.exit(1);
931
+ return;
932
+ }
933
+
934
+ if (opts.command === 'tracking-health' || opts.command === 'tracking') {
935
+ const { getTrackingHealth } = await import('./provider-account-sync.mjs');
936
+ const health = await getTrackingHealth(brainDir);
937
+ console.log(`\n ${health.healthy ? 'โœ…' : 'โŒ'} ${health.message}`);
938
+ console.log(` ok=${health.ok} exempt=${health.exempt} errors=${health.errors}`);
939
+ if (health.error_keys?.length) {
940
+ console.log('');
941
+ for (const e of health.error_keys) {
942
+ console.log(` โ€ข ${e.key.padEnd(28)} ${(e.provider || '-').padEnd(14)} ${e.error}`);
943
+ }
944
+ }
945
+ const shared = await getSharedValueHealth(brainDir);
946
+ console.log(`\n ${shared.healthy ? 'โœ…' : 'โŒ'} ${shared.message}`);
947
+ if (shared.errors?.length) {
948
+ for (const e of shared.errors) {
949
+ console.log(` โ€ข fp=${e.fingerprint} apps=[${e.apps.join(', ')}]`);
950
+ console.log(` keys: ${e.keys.join(', ')}`);
951
+ }
952
+ }
953
+ console.log('');
954
+ if (!health.healthy || !shared.healthy) process.exit(1);
955
+ return;
956
+ }
957
+
958
+ if (opts.command === 'shared' || opts.command === 'shared-values' || opts.command === 'duplicates') {
959
+ const shared = await getSharedValueHealth(brainDir);
960
+ console.log(`\n ${shared.healthy ? 'โœ…' : 'โŒ'} ${shared.message}`);
961
+ console.log(
962
+ ` groups=${shared.groups.length} error_groups=${shared.error_groups} multi_app=${shared.multi_app_groups} keys_in_error=${shared.shared_keys}`,
963
+ );
964
+ if (shared.groups.length) {
965
+ console.log('');
966
+ for (const g of shared.groups) {
967
+ const icon = g.severity === 'error' ? 'โŒ' : 'โญ';
968
+ console.log(
969
+ ` ${icon} fingerprint ${g.fingerprint} ร—${g.count} apps=[${g.apps.join(', ')}]`,
970
+ );
971
+ for (const m of g.members) {
972
+ console.log(
973
+ ` โ€ข ${m.key.padEnd(36)} app=${String(m.app).padEnd(16)} provider=${m.provider || '-'} ${m.masked || ''}`,
974
+ );
975
+ }
976
+ if (g.error) console.log(` โ†’ ${g.error}`);
977
+ console.log(
978
+ ` fix: rotate each key to a unique value, then: secret rotate <key> <new> --export-env`,
979
+ );
980
+ console.log(
981
+ ` waive (rare): secret meta <key> --shared-ok # only if intentional mirror`,
982
+ );
983
+ console.log('');
984
+ }
985
+ } else {
986
+ console.log('\n No shared credential values detected.\n');
987
+ }
988
+ if (!shared.healthy) process.exit(1);
989
+ return;
990
+ }
991
+
992
+ if (opts.command === 'meta') {
993
+ if (!opts.key) {
994
+ console.error('Usage: total-recall secret meta <key> [--repo ...] [--tier ...]');
995
+ process.exit(1);
996
+ }
997
+ const patch = {};
998
+ if (opts.repos.length) patch.repos = opts.repos;
999
+ if (opts.tier != null) patch.subscription_tier = opts.tier;
1000
+ if (opts.monthly_cost != null) patch.monthly_cost_usd = opts.monthly_cost;
1001
+ if (opts.monthly_cap != null) patch.monthly_cap_usd = opts.monthly_cap;
1002
+ if (opts.docs) patch.api_docs_url = opts.docs;
1003
+ if (opts.rotate_days != null) patch.rotate_every_days = opts.rotate_days;
1004
+ if (opts.auto_rotate) patch.auto_rotate = true;
1005
+ if (opts.notes) patch.notes = opts.notes;
1006
+ if (opts.tags != null) patch.tags = opts.tags;
1007
+ if (opts.label) patch.label = opts.label;
1008
+ if (opts.project) patch.project_path = opts.project;
1009
+ if (opts.provider) patch.provider = opts.provider;
1010
+ if (opts.scope) patch.scope = opts.scope;
1011
+ if (opts.tracking_exempt != null) {
1012
+ patch.tracking_exempt = opts.tracking_exempt;
1013
+ if (opts.tracking_exempt) patch.tracking_status = 'exempt';
1014
+ }
1015
+ if (opts.shared_ok != null) patch.shared_value_ok = opts.shared_ok;
1016
+ // Fill docs from catalog if provider set and no docs
1017
+ if (patch.provider && !patch.api_docs_url) {
1018
+ const p = getProvider(patch.provider);
1019
+ if (p) {
1020
+ patch.api_docs_url = p.docs_url;
1021
+ if (patch.monthly_cap_usd == null) patch.monthly_cap_usd = p.default_monthly_cap_usd;
1022
+ }
1023
+ }
1024
+ const row = await updateSecretMeta(brainDir, opts.key, patch, { actor: 'cli' });
1025
+ console.log(`\n โœ… Meta updated: ${opts.key}`);
1026
+ console.log(` provider=${row.provider} tier=${row.subscription_tier} repos=${(row.repos || []).join(',') || '-'}`);
1027
+ console.log(` monthly_cost=$${row.monthly_cost_usd ?? '-'} cap=$${row.monthly_cap_usd ?? '-'}`);
1028
+ console.log(` docs=${row.api_docs_url || '-'}`);
1029
+ console.log(` rotate_every=${row.rotate_every_days || '-'}d next=${row.next_rotate_due || '-'}\n`);
1030
+ return;
1031
+ }
1032
+
1033
+ if (opts.command === 'rotation-due') {
1034
+ const due = await listRotationDue(brainDir, { autoOnly: false });
1035
+ if (!due.length) {
1036
+ console.log('\n No secrets overdue for rotation.\n');
1037
+ return;
1038
+ }
1039
+ console.log(`\n โš ๏ธ ${due.length} secret(s) overdue for rotation\n`);
1040
+ for (const k of due) {
1041
+ console.log(
1042
+ ` โ€ข ${k.key} provider=${k.provider || '-'} due=${k.next_rotate_due} auto=${k.auto_rotate ? 'yes' : 'no'}`,
1043
+ );
1044
+ }
1045
+ if (opts.enqueue) {
1046
+ const r = await enqueueRotationDueTasks(brainDir, { autoOnly: false });
1047
+ console.log(`\n ๐Ÿ“‹ Enqueued ${r.tasks.filter((t) => t.status === 'created').length} rotation assist task(s)`);
1048
+ }
1049
+ console.log('\n Rotate: npx total-recall secret rotate <key> <new> --export-env');
1050
+ console.log(' Browser assist: npx total-recall secret rotate-browser <key>');
1051
+ console.log(' Enqueue tasks: npx total-recall secret rotation-due --enqueue\n');
1052
+ return;
1053
+ }
1054
+
1055
+ if (opts.command === 'get') {
1056
+ if (!opts.key) {
1057
+ console.error('Usage: total-recall secret get <key>');
1058
+ process.exit(1);
1059
+ }
1060
+ const project_id = currentProjectId();
1061
+ const r = await getSecret(brainDir, opts.key, { project_id });
1062
+ if (opts.json) {
1063
+ console.log(JSON.stringify(r.found ? { found: true, value: r.value } : { found: false }));
1064
+ return;
1065
+ }
1066
+ if (!r.found) {
1067
+ console.error(` Secret not found: ${opts.key}`);
1068
+ process.exit(1);
1069
+ }
1070
+ // Print value only โ€” user explicitly asked
1071
+ console.log(r.value);
1072
+ return;
1073
+ }
1074
+
1075
+ if (opts.command === 'list') {
1076
+ const rows = await listSecretsMeta(brainDir);
1077
+ if (!rows.length) {
1078
+ console.log(`\n No secrets in ${resolveSecretsPath(brainDir)}\n`);
1079
+ return;
1080
+ }
1081
+ console.log(`\n Secrets metadata โ€” ${resolveSecretsPath(brainDir)}\n`);
1082
+ for (const r of rows) {
1083
+ const repos = r.repos?.length ? r.repos.join(',') : '-';
1084
+ const tr = r.tracking_status || 'never';
1085
+ const trIcon = tr === 'ok' ? 'โœ…' : tr === 'exempt' ? 'โญ' : 'โŒ';
1086
+ const share = r.shared_value ? ' ๐Ÿ”—SHARED' : '';
1087
+ console.log(
1088
+ ` โ€ข ${r.key.padEnd(28)} len=${String(r.length).padStart(4)} ${(r.provider || '-').padEnd(12)} ${trIcon}${tr.padEnd(7)} tier=${r.subscription_tier || '-'} $${r.monthly_cost_usd ?? '-'} repos=${repos}${share}`,
1089
+ );
1090
+ if (r.api_docs_url) console.log(` docs: ${r.api_docs_url}`);
1091
+ if (r.tracking_error && tr === 'error') console.log(` tracking: ${r.tracking_error}`);
1092
+ if (r.shared_value_error) {
1093
+ console.log(` shared: ${r.shared_value_error}`);
1094
+ if (r.shared_with?.length) {
1095
+ console.log(
1096
+ ` same value as: ${r.shared_with.map((s) => `${s.key}(${s.app})`).join(', ')}`,
1097
+ );
1098
+ }
1099
+ }
1100
+ }
1101
+ console.log('');
1102
+ return;
1103
+ }
1104
+
1105
+ if (opts.command === 'remote') {
1106
+ if (!opts.subcommand) {
1107
+ console.error(
1108
+ 'Usage: total-recall secret remote <add|list|remove|deploy> [...]\n' +
1109
+ ' add <name> --host <h> --path </remote/dir> [--user root] [--port 22] [--filename .env] [--restart-cmd "..."]\n' +
1110
+ ' list\n' +
1111
+ ' remove <name>\n' +
1112
+ ' deploy <name> [--dry-run] [--keys k1,k2]',
1113
+ );
1114
+ process.exit(1);
1115
+ }
1116
+
1117
+ if (opts.subcommand === 'add') {
1118
+ if (!opts.key || !opts.remoteHost || !opts.path) {
1119
+ console.error(
1120
+ 'Usage: total-recall secret remote add <name> --host <host> --path </remote/dir> [--user root] [--port 22] [--filename .env] [--restart-cmd "..."]',
1121
+ );
1122
+ process.exit(1);
1123
+ }
1124
+ const { path: targetsPath } = addRemoteTarget(brainDir, {
1125
+ name: opts.key,
1126
+ host: opts.remoteHost,
1127
+ user: opts.remoteUser,
1128
+ port: opts.remotePort,
1129
+ remotePath: opts.path,
1130
+ filename: opts.filename || '.env',
1131
+ restartCommand: opts.restartCmd || null,
1132
+ keys: opts.keys.length ? opts.keys : undefined,
1133
+ });
1134
+ console.log(`\n โœ… Remote target added: ${opts.key}`);
1135
+ console.log(` ${opts.remoteUser}@${opts.remoteHost}:${opts.path}/${opts.filename || '.env'}`);
1136
+ if (opts.restartCmd) console.log(` restart: ${opts.restartCmd}`);
1137
+ console.log(` config: ${targetsPath}\n`);
1138
+ return;
1139
+ }
1140
+
1141
+ if (opts.subcommand === 'list') {
1142
+ const targets = loadRemoteTargets(brainDir);
1143
+ if (!targets.length) {
1144
+ console.log(`\n No remote targets configured for this repo.\n`);
1145
+ return;
1146
+ }
1147
+ console.log(`\n Remote deploy targets\n`);
1148
+ for (const t of targets) {
1149
+ console.log(
1150
+ ` โ€ข ${t.name.padEnd(20)} ${t.user}@${t.host}:${t.port} ${t.remotePath}/${t.filename}`,
1151
+ );
1152
+ if (t.restartCommand) console.log(` restart: ${t.restartCommand}`);
1153
+ if (t.keys?.length) console.log(` keys: ${t.keys.join(', ')}`);
1154
+ }
1155
+ console.log('');
1156
+ return;
1157
+ }
1158
+
1159
+ if (opts.subcommand === 'remove' || opts.subcommand === 'rm') {
1160
+ if (!opts.key) {
1161
+ console.error('Usage: total-recall secret remote remove <name>');
1162
+ process.exit(1);
1163
+ }
1164
+ const r = removeRemoteTarget(brainDir, opts.key);
1165
+ console.log(r.removed ? ` โœ… Removed remote target: ${opts.key}` : ` Remote target not found: ${opts.key}`);
1166
+ return;
1167
+ }
1168
+
1169
+ if (opts.subcommand === 'deploy') {
1170
+ if (!opts.key) {
1171
+ console.error('Usage: total-recall secret remote deploy <name> [--dry-run] [--keys k1,k2]');
1172
+ process.exit(1);
1173
+ }
1174
+ const r = await deployEnvToRemote(brainDir, opts.key, {
1175
+ dryRun: opts.dryRun,
1176
+ keys: opts.keys.length ? opts.keys : undefined,
1177
+ });
1178
+ if (r.dryRun) {
1179
+ console.log(`\n ๐Ÿ“ค dry-run remote deploy โ†’ ${r.target} (${r.host})`);
1180
+ console.log(` would write ${r.count} keys โ†’ ${r.remoteFile}\n`);
1181
+ return;
1182
+ }
1183
+ console.log(`\n โœ… Deployed ${r.count} secret(s) โ†’ ${r.target} (${r.host}:${r.remoteFile})`);
1184
+ if (r.restarted) console.log(` restart: ok`);
1185
+ else if (r.restartOutput) console.log(` restart: FAILED โ€” ${r.restartOutput.slice(0, 300)}`);
1186
+ console.log(` (values written with mode 0600 remotely; not printed)\n`);
1187
+ return;
1188
+ }
1189
+
1190
+ console.error(`Unknown remote subcommand: ${opts.subcommand}`);
1191
+ process.exit(1);
1192
+ }
1193
+
1194
+ if (opts.command === 'rotate') {
1195
+ if (!opts.key) {
1196
+ console.error('Usage: total-recall secret rotate <key> [--export-env|--export-all|--remote <name>]');
1197
+ console.error(' The new value is read from a prompt or stdin โ€” never from argv.');
1198
+ process.exit(1);
1199
+ }
1200
+ // A credential passed as an argument is disclosed the moment it is typed:
1201
+ // argv is readable by any user via `ps`, is saved to shell history, and is
1202
+ // captured verbatim by any agent driving this CLI. Read it from a hidden
1203
+ // prompt (or stdin when piped) so it stays in this process's memory.
1204
+ if (!opts.value && opts.fromClipboard) {
1205
+ // Read the OS clipboard inside this process. This lets the operator use
1206
+ // the browser they are already signed into โ€” copy the new credential
1207
+ // there, and the value travels clipboard -> Node -> secrets.enc without
1208
+ // ever passing through argv, a terminal echo, or an agent transcript.
1209
+ opts.value = readSystemClipboard();
1210
+ if (!opts.value) {
1211
+ console.error('Error: clipboard is empty โ€” copy the new credential first.');
1212
+ process.exit(1);
1213
+ }
1214
+ console.log(` ๐Ÿ“‹ Read ${opts.value.length} chars from the clipboard (value not shown).`);
1215
+ }
1216
+ if (!opts.value) {
1217
+ opts.value = await readSecretValue(`New value for ${opts.key}: `);
1218
+ } else if (!opts.fromClipboard) {
1219
+ console.error(
1220
+ `\n โš ๏ธ WARNING: the value was passed as a command-line argument.\n` +
1221
+ ` It is now in your shell history and was visible to any user\n` +
1222
+ ` running \`ps\` on this machine. Treat it as already disclosed\n` +
1223
+ ` and rotate again using the prompt: total-recall secret rotate ${opts.key}\n`,
1224
+ );
1225
+ }
1226
+ if (!opts.value) {
1227
+ console.error('Error: empty value โ€” aborting rotation.');
1228
+ process.exit(1);
1229
+ }
1230
+ if (opts.remote) {
1231
+ await rotateSecret(brainDir, opts.key, opts.value, { provider: opts.provider, scope: opts.scope });
1232
+ console.log(` โœ… Rotated: ${opts.key}`);
1233
+ const r = await deployEnvToRemote(brainDir, opts.remote, {});
1234
+ console.log(` โœ… Deployed to remote target: ${r.target} (${r.host}:${r.remoteFile}, ${r.count} keys)`);
1235
+ if (r.restarted) console.log(` restart: ok`);
1236
+ else if (r.restartOutput) console.log(` restart: FAILED โ€” ${r.restartOutput.slice(0, 300)}`);
1237
+ if (opts.exportEnv || opts.exportAll) {
1238
+ const localR = await rotateSecretAndExport(brainDir, opts.key, opts.value, {
1239
+ provider: opts.provider,
1240
+ actor: 'cli',
1241
+ exportEnv: true,
1242
+ exportAllProjects: opts.exportAll,
1243
+ exportCwd: true,
1244
+ includeGlobal: true,
1245
+ });
1246
+ const ok = (localR.exports || []).filter((e) => e.ok !== false);
1247
+ console.log(` local export-env: ${ok.length} target(s)`);
1248
+ }
1249
+ return;
1250
+ }
1251
+ if (opts.exportEnv || opts.exportAll) {
1252
+ const r = await rotateSecretAndExport(brainDir, opts.key, opts.value, {
1253
+ provider: opts.provider,
1254
+ actor: 'cli',
1255
+ exportEnv: true,
1256
+ exportAllProjects: opts.exportAll,
1257
+ exportCwd: true,
1258
+ includeGlobal: true,
1259
+ });
1260
+ console.log(` โœ… Rotated: ${opts.key}`);
1261
+ if (r.next_rotate_due) console.log(` next due: ${r.next_rotate_due}`);
1262
+ const ok = (r.exports || []).filter((e) => e.ok !== false);
1263
+ console.log(` export-env: ${ok.length} target(s)`);
1264
+ for (const e of ok) {
1265
+ if (e.envPath) console.log(` โ†’ ${e.envPath} (${e.count} keys)`);
1266
+ }
1267
+ return;
1268
+ }
1269
+ await rotateSecret(brainDir, opts.key, opts.value, {
1270
+ provider: opts.provider,
1271
+ scope: opts.scope,
1272
+ });
1273
+ console.log(` โœ… Rotated: ${opts.key}`);
1274
+ console.log(` tip: add --export-env to push .env projections after rotate`);
1275
+ return;
1276
+ }
1277
+
1278
+ if (opts.command === 'rotate-browser' || opts.command === 'rotate-auto') {
1279
+ if (!opts.key) {
1280
+ console.error(`Usage: total-recall secret ${opts.command} <key>`);
1281
+ process.exit(1);
1282
+ }
1283
+
1284
+ if (opts.printOnly) {
1285
+ const assist = await getBrowserRotateAssist(brainDir, opts.key);
1286
+ console.log(`\n ๐ŸŒ Supervised browser rotation โ€” ${assist.key}\n`);
1287
+ if (assist.console_url) console.log(` Console: ${assist.console_url}`);
1288
+ if (assist.docs_url) console.log(` Docs: ${assist.docs_url}`);
1289
+ console.log(` Overdue: ${assist.overdue ? 'YES' : 'no'}`);
1290
+ console.log('\n' + assist.prompt + '\n');
1291
+ return;
1292
+ }
1293
+
1294
+ const { rotateViaBrowser, rotateAuto } = await import('./secrets-rotate.mjs');
1295
+ const run = opts.command === 'rotate-auto' ? rotateAuto : rotateViaBrowser;
1296
+ console.log(`\n ๐Ÿ” Rotating ${opts.key}โ€ฆ\n`);
1297
+ const r = await run(brainDir, opts.key, {
1298
+ headless: !!opts.headless,
1299
+ exportEnv: opts.exportEnv !== false,
1300
+ onStatus: (m) => console.log(` ยท ${m}`),
1301
+ });
1302
+
1303
+ if (!r.ok) {
1304
+ console.error(`\n โŒ ${r.error}`);
1305
+ if (r.console_url) console.error(` Console: ${r.console_url}`);
1306
+ process.exit(1);
1307
+ }
1308
+ console.log(`\n โœ… Rotated ${r.key}` + (r.method ? ` via ${r.method}` : ''));
1309
+ if (r.verified === true) console.log(' verified against provider API');
1310
+ if (r.supervised) console.log(' (supervised โ€” recipe not yet auto-verified)');
1311
+ if (r.exports?.length) console.log(` exported to ${r.exports.length} project(s)`);
1312
+ if (r.revoke_hint) console.log(`\n โš ๏ธ ${r.revoke_hint}`);
1313
+ return;
1314
+ }
1315
+
1316
+ if (opts.command === 'rotation-status') {
1317
+ const { planAll, summarizePlans } = await import('./rotation-capability.mjs');
1318
+ const all = await loadSecrets(brainDir);
1319
+ const keys = Object.keys(all || {}).filter((k) => k !== 'meta');
1320
+ const plans = planAll(keys, all.meta || {});
1321
+ const sum = summarizePlans(plans);
1322
+
1323
+ if (opts.json) {
1324
+ console.log(JSON.stringify({ summary: sum, plans }, null, 2));
1325
+ return;
1326
+ }
1327
+
1328
+ console.log(`\n ๐Ÿ”„ Rotation coverage โ€” ${sum.automatable}/${sum.total} automatable\n`);
1329
+ for (const [cls, n] of Object.entries(sum.byClass)) {
1330
+ console.log(` ${cls.padEnd(18)} ${n}`);
1331
+ }
1332
+ const manual = plans.filter((p) => !p.automatable && p.class !== 'non_secret');
1333
+ if (manual.length) {
1334
+ console.log(`\n Needs a human (${manual.length}):`);
1335
+ for (const p of manual) console.log(` ${p.key.padEnd(34)} ${p.reason}`);
1336
+ }
1337
+ console.log('');
1338
+ return;
1339
+ }
1340
+
1341
+ if (opts.command === 'browser-logout') {
1342
+ const { clearProfile } = await import('./browser-session.mjs');
1343
+ const r = clearProfile(brainDir);
1344
+ console.log(r.removed ? ` โœ… Cleared browser profile: ${r.path}` : ` (no profile at ${r.path})`);
1345
+ return;
1346
+ }
1347
+
1348
+ if (opts.command === 'delete' || opts.command === 'rm') {
1349
+ if (!opts.key) {
1350
+ console.error('Usage: total-recall secret delete <key>');
1351
+ process.exit(1);
1352
+ }
1353
+ const r = await deleteSecret(brainDir, opts.key);
1354
+ if (!r.found) {
1355
+ console.error(` Secret not found: ${opts.key}`);
1356
+ process.exit(1);
1357
+ }
1358
+ console.log(` โœ… Deleted: ${opts.key}`);
1359
+ return;
1360
+ }
1361
+
1362
+ if (opts.command === 'audit') {
1363
+ const events = readSecretAudit(brainDir, { limit: opts.limit });
1364
+ if (!events.length) {
1365
+ console.log('\n No secret audit events yet.\n');
1366
+ return;
1367
+ }
1368
+ console.log(`\n Secret audit (last ${events.length})\n`);
1369
+ for (const e of events) {
1370
+ console.log(` ${e.ts} ${e.action.padEnd(8)} ${e.key} ${e.actor || ''}${e.project_id ? ` project=${e.project_id}` : ''}`);
1371
+ }
1372
+ console.log('');
1373
+ return;
1374
+ }
1375
+
1376
+ if (opts.command === 'usage') {
1377
+ const day = summarizeUsage(brainDir, { days: opts.days });
1378
+ const week = summarizeUsage(brainDir, { days: 7 });
1379
+ const month = summarizeUsage(brainDir, { days: 30 });
1380
+ const by = summarizeUsageByKey(brainDir, { days: 30 });
1381
+ const budget = loadBudgetConfig(brainDir);
1382
+ console.log(`\n Usage summary`);
1383
+ console.log(` last ${opts.days}d: events=${day.events} cost=$${day.cost_usd.toFixed(4)} tokens_in=${day.input_tokens} tokens_out=${day.output_tokens}`);
1384
+ console.log(` last 7d: events=${week.events} cost=$${week.cost_usd.toFixed(4)}`);
1385
+ console.log(` last 30d: events=${month.events} cost=$${month.cost_usd.toFixed(4)}`);
1386
+ const dailyCap = budget.config.daily_cap_usd ?? budget.config.dailyCapUsd;
1387
+ const weeklyCap = budget.config.weekly_cap_usd ?? budget.config.weeklyCapUsd;
1388
+ if (dailyCap != null) {
1389
+ console.log(` budget daily_cap_usd=${dailyCap} ${day.cost_usd > dailyCap ? 'โš ๏ธ OVER' : 'ok'}`);
1390
+ }
1391
+ if (weeklyCap != null) {
1392
+ console.log(` budget weekly_cap_usd=${weeklyCap} ${week.cost_usd > weeklyCap ? 'โš ๏ธ OVER' : 'ok'}`);
1393
+ }
1394
+ if (budget.path) console.log(` budget file: ${budget.path}`);
1395
+ const keyEntries = Object.entries(by.by_key).filter(([k]) => k !== '_unattributed');
1396
+ if (keyEntries.length) {
1397
+ console.log(`\n By key_ref (30d)`);
1398
+ for (const [k, v] of keyEntries.sort((a, b) => b[1].cost_usd - a[1].cost_usd)) {
1399
+ console.log(` ${k.padEnd(28)} events=${v.events} cost=$${v.cost_usd.toFixed(4)}`);
1400
+ }
1401
+ }
1402
+ const provEntries = Object.entries(by.by_provider);
1403
+ if (provEntries.length) {
1404
+ console.log(`\n By provider (30d)`);
1405
+ for (const [p, v] of provEntries.sort((a, b) => b[1].cost_usd - a[1].cost_usd)) {
1406
+ console.log(` ${p.padEnd(16)} events=${v.events} cost=$${v.cost_usd.toFixed(4)}`);
1407
+ }
1408
+ }
1409
+ console.log('');
1410
+ return;
1411
+ }
1412
+
1413
+ if (opts.command === 'usage-record') {
1414
+ const row = recordUsage(brainDir, {
1415
+ provider: opts.provider || 'unknown',
1416
+ model: opts.model,
1417
+ input_tokens: opts.input_tokens,
1418
+ output_tokens: opts.output_tokens,
1419
+ cost_usd: opts.cost,
1420
+ key_ref: opts.key_ref,
1421
+ source: 'cli',
1422
+ });
1423
+ console.log(` โœ… Usage recorded: ${JSON.stringify(row)}`);
1424
+ return;
1425
+ }
1426
+
1427
+ if (opts.command === 'export-env' || opts.command === 'sync-env' || opts.command === 'apply-env') {
1428
+ const exportOpts = {
1429
+ filename: opts.filename || '.env',
1430
+ example: !opts.noExample,
1431
+ dryRun: opts.dryRun,
1432
+ includeGlobal: !opts.noGlobal,
1433
+ replaceAll: !!opts.replaceAll,
1434
+ keys: opts.keys.length ? opts.keys : undefined,
1435
+ };
1436
+
1437
+ if (opts.remote) {
1438
+ const r = await deployEnvToRemote(brainDir, opts.remote, {
1439
+ dryRun: opts.dryRun,
1440
+ keys: opts.keys.length ? opts.keys : undefined,
1441
+ });
1442
+ if (r.dryRun) {
1443
+ console.log(`\n ๐Ÿ“ค dry-run remote export โ†’ ${r.target} (${r.host})`);
1444
+ console.log(` would write ${r.count} keys โ†’ ${r.remoteFile}\n`);
1445
+ } else {
1446
+ console.log(`\n โœ… Exported ${r.count} secret(s) โ†’ ${r.target} (${r.host}:${r.remoteFile})`);
1447
+ if (r.restarted) console.log(` restart: ok`);
1448
+ else if (r.restartOutput) console.log(` restart: FAILED โ€” ${r.restartOutput.slice(0, 300)}`);
1449
+ console.log(` (values written with mode 0600 remotely; not printed)\n`);
1450
+ }
1451
+ if (!opts.path && !opts.allProjects) return; // --remote alone doesn't also touch local disk
1452
+ }
1453
+
1454
+ if (opts.allProjects) {
1455
+ const results = await exportEnvToRegistry(brainDir, exportOpts);
1456
+ console.log(`\n ๐Ÿ“ค export-env โ†’ ${results.length} project(s) from ${resolveSecretsPath(brainDir)}\n`);
1457
+ for (const r of results) {
1458
+ if (!r.ok) {
1459
+ console.log(` โœ— ${r.name || r.path}: ${r.error}`);
1460
+ } else if (r.dryRun) {
1461
+ console.log(` ยท ${r.name || r.projectSlug}: would write ${r.count} keys โ†’ ${r.envPath}`);
1462
+ } else {
1463
+ console.log(` โœ“ ${r.name || r.projectSlug}: ${r.count} keys โ†’ ${r.envPath}`);
1464
+ }
1465
+ }
1466
+ console.log('');
1467
+ return;
1468
+ }
1469
+
1470
+ const targetPath = opts.path || opts.project || process.cwd();
1471
+ const result = await exportEnvToProject(brainDir, targetPath, exportOpts);
1472
+ if (result.dryRun) {
1473
+ console.log(`\n ๐Ÿ“ค dry-run export-env`);
1474
+ console.log(` store: ${result.store}`);
1475
+ console.log(` would write ${result.count} keys โ†’ ${result.envPath}`);
1476
+ if (result.keys.length) {
1477
+ for (const k of result.keys) console.log(` โ€ข ${k}`);
1478
+ }
1479
+ console.log('');
1480
+ return;
1481
+ }
1482
+ console.log(`\n โœ… Exported ${result.count} secret(s) โ†’ ${result.envPath}`);
1483
+ if (result.examplePath) console.log(` example: ${result.examplePath}`);
1484
+ console.log(` store: ${result.store}`);
1485
+ console.log(` (values written with mode 0600; not printed)\n`);
1486
+ return;
1487
+ }
1488
+
1489
+ if (opts.command === 'diff-env') {
1490
+ const { diffEnvAgainstStore, resolveEnvFiles } = await import('./secrets-env-diff.mjs');
1491
+ const { loadProjectRegistry } = await import('./secrets-env-export.mjs');
1492
+ const { globalBrainDir } = await import('./config.mjs');
1493
+
1494
+ // Compare against the GLOBAL store, not whichever layer the cwd resolves
1495
+ // to. A repo with its own .agent/ shadows the global store, so running
1496
+ // this from inside one would diff .env files against a project-local
1497
+ // store and report drift that is really just two different stores โ€”
1498
+ // which is how the real drift stayed invisible. --layer project opts in.
1499
+ const compareBrainDir = opts.layer === 'project' ? brainDir : globalBrainDir;
1500
+
1501
+ const roots = opts.path
1502
+ ? [path.resolve(opts.path)]
1503
+ : loadProjectRegistry()
1504
+ .map((entry) => (typeof entry === 'string' ? entry : entry.path || entry.root))
1505
+ .filter(Boolean);
1506
+
1507
+ const files = resolveEnvFiles(roots, opts.filename ? [opts.filename] : ['.env']);
1508
+ if (!files.length) {
1509
+ console.log('\n No .env files found for the configured projects.\n');
1510
+ return;
1511
+ }
1512
+
1513
+ const { totals, rows, storeKeyCount } = await diffEnvAgainstStore(compareBrainDir, files);
1514
+
1515
+ console.log(`\n Secrets store vs .env โ€” ${files.length} file(s), ${storeKeyCount} keys in store`);
1516
+ console.log(` store: ${compareBrainDir.replace(os.homedir(), '~')}\n`);
1517
+ console.log(` identical to store : ${totals.match}`);
1518
+ console.log(` DRIFTED : ${totals.drift}`);
1519
+ console.log(` name collisions : ${totals.collision}`);
1520
+ console.log(` only in .env : ${totals.only_env}`);
1521
+ console.log(` placeholders : ${totals.placeholder}`);
1522
+
1523
+ const drifted = rows.filter((r) => r.status === 'drift');
1524
+ if (drifted.length) {
1525
+ console.log('\n Drift โ€” the same key holds different values in the two places.');
1526
+ console.log(' Equal lengths mean the same credential format, i.e. a rotation that');
1527
+ console.log(' landed on one side only. Decide which side is live before exporting.\n');
1528
+ for (const r of drifted) {
1529
+ const shape = r.sameShape ? 'same shape' : 'different shape';
1530
+ console.log(` ${r.key.padEnd(34)} env=${String(r.envLength).padStart(3)}ch store=${String(r.storeLength).padStart(3)}ch (${shape})`);
1531
+ console.log(` ${r.file.replace(os.homedir(), '~')}`);
1532
+ }
1533
+ }
1534
+
1535
+ const collisions = rows.filter((r) => r.status === 'collision');
1536
+ if (collisions.length) {
1537
+ console.log('\n Name collisions โ€” the store entry is bound to a different repo, so');
1538
+ console.log(' these are two products\' credentials sharing a variable name, not');
1539
+ console.log(' staleness. Exporting would overwrite one product with the other\'s.\n');
1540
+ const seen = new Set();
1541
+ for (const r of collisions) {
1542
+ const line = `${r.key}|${r.binding}|${r.envRepo}`;
1543
+ if (seen.has(line)) continue;
1544
+ seen.add(line);
1545
+ console.log(` ${r.key.padEnd(30)} store bound to '${r.binding}' ยท .env belongs to '${r.envRepo}'`);
1546
+ }
1547
+ }
1548
+
1549
+ const onlyEnv = rows.filter((r) => r.status === 'only_env');
1550
+ if (onlyEnv.length) {
1551
+ console.log('\n Present in .env but absent from the store (import to adopt):\n');
1552
+ for (const r of onlyEnv) {
1553
+ console.log(` ${r.key.padEnd(34)} ${r.file.replace(os.homedir(), '~')}`);
1554
+ }
1555
+ }
1556
+
1557
+ console.log('');
1558
+ if (opts.strict && (totals.drift > 0 || totals.only_env > 0)) {
1559
+ console.error(' diff-env --strict: store is not the source of truth for every key.');
1560
+ process.exit(1);
1561
+ }
1562
+ return;
1563
+ }
1564
+
1565
+ if (opts.command === 'import-env') {
1566
+ const existing = await listSecretsMeta(brainDir);
1567
+ const existingSet = new Set(existing.map((e) => e.key));
1568
+ let scan = scanEnvSources({ brainDir, includeProcessEnv: true, cwd: process.cwd() });
1569
+
1570
+ if (opts.file) {
1571
+ if (!fs.existsSync(opts.file)) {
1572
+ console.error(` File not found: ${opts.file}`);
1573
+ process.exit(1);
1574
+ }
1575
+ const text = fs.readFileSync(opts.file, 'utf8');
1576
+ const map = parseEnvText(text);
1577
+ // merge file into scan candidates via import pairs path
1578
+ const pub = publicScanResult(scan, existingSet);
1579
+ console.log(`\n ๐Ÿ” Env scan (${pub.count} candidates)\n`);
1580
+ for (const c of pub.candidates) {
1581
+ console.log(
1582
+ ` ${c.already_set ? 'ยท' : '+'} ${c.key.padEnd(32)} ${c.masked.padEnd(28)} ${c.source_label}`,
1583
+ );
1584
+ }
1585
+ if (Object.keys(map).length) {
1586
+ console.log(`\n Also loading --file ${opts.file} (${Object.keys(map).length} vars)`);
1587
+ }
1588
+ if (opts.dryRun) {
1589
+ console.log('\n Dry-run โ€” nothing written.\n');
1590
+ return;
1591
+ }
1592
+ const keys = opts.keys.length
1593
+ ? opts.keys
1594
+ : opts.all
1595
+ ? undefined
1596
+ : pub.candidates.filter((c) => !c.already_set).map((c) => c.key);
1597
+ const result = await importEnvSecrets(brainDir, {
1598
+ pairs: map,
1599
+ keys: opts.keys.length ? opts.keys : undefined,
1600
+ all: opts.all || (!opts.keys.length && !Object.keys(map).length),
1601
+ overwrite: opts.overwrite,
1602
+ actor: 'cli-import-env',
1603
+ });
1604
+ // If --file provided, also import matching keys from file pairs
1605
+ if (Object.keys(map).length) {
1606
+ const fileResult = await importEnvSecrets(brainDir, {
1607
+ pairs: map,
1608
+ keys: opts.keys.length ? opts.keys : Object.keys(map),
1609
+ overwrite: opts.overwrite,
1610
+ actor: 'cli-import-env',
1611
+ });
1612
+ console.log(
1613
+ `\n โœ… Imported ${fileResult.imported_count} from file, skipped ${fileResult.skipped_count}\n`,
1614
+ );
1615
+ return;
1616
+ }
1617
+ console.log(
1618
+ `\n โœ… Imported ${result.imported_count}, skipped ${result.skipped_count}, errors ${result.errors.length}\n`,
1619
+ );
1620
+ return;
1621
+ }
1622
+
1623
+ const pub = publicScanResult(scan, existingSet);
1624
+ console.log(`\n ๐Ÿ” Env scan โ€” ${pub.count} candidate secrets\n`);
1625
+ for (const c of pub.candidates) {
1626
+ const mark = c.already_set ? 'ยท' : '+';
1627
+ console.log(
1628
+ ` ${mark} ${c.key.padEnd(32)} ${c.masked.padEnd(28)} ${c.provider || ''} ${c.source_label}`,
1629
+ );
1630
+ }
1631
+ if (!pub.count) {
1632
+ console.log(' No known API keys found in process.env or common .env paths.\n');
1633
+ return;
1634
+ }
1635
+ if (opts.dryRun || (!opts.all && !opts.keys.length)) {
1636
+ console.log(
1637
+ '\n Dry-run / preview. Import with:\n npx total-recall secret import-env --all\n npx total-recall secret import-env OPENAI_API_KEY ANTHROPIC_API_KEY\n',
1638
+ );
1639
+ return;
1640
+ }
1641
+ const result = await importEnvSecrets(brainDir, {
1642
+ keys: opts.keys.length ? opts.keys : undefined,
1643
+ all: opts.all || !opts.keys.length,
1644
+ overwrite: opts.overwrite,
1645
+ actor: 'cli-import-env',
1646
+ });
1647
+ console.log(`\n โœ… Imported ${result.imported_count}`);
1648
+ if (result.skipped_count) console.log(` skipped (already set): ${result.skipped_count}`);
1649
+ for (const e of result.errors) console.error(` error ${e.key}: ${e.error}`);
1650
+ console.log(` store: ${resolveSecretsPath(brainDir)}\n`);
1651
+ return;
1652
+ }
1653
+
1654
+ if (opts.command === 'check-surfaces') {
1655
+ const texts = [];
1656
+ const agentRoot = path.dirname(path.dirname(brainDir)); // โ€ฆ/skills/total-recall โ†’ โ€ฆ/.agent (or similar)
1657
+ const candidates = [
1658
+ path.join(process.cwd(), 'INSTRUCTIONS.md'),
1659
+ path.join(process.cwd(), 'AGENTS.md'),
1660
+ path.join(agentRoot, 'INSTRUCTIONS.md'),
1661
+ path.join(brainDir, 'openwiki'),
1662
+ ];
1663
+ for (const c of candidates) {
1664
+ if (!fs.existsSync(c)) continue;
1665
+ if (fs.statSync(c).isDirectory()) {
1666
+ for (const f of fs.readdirSync(c)) {
1667
+ if (f.endsWith('.md')) {
1668
+ texts.push({ file: path.join(c, f), body: fs.readFileSync(path.join(c, f), 'utf8') });
1669
+ }
1670
+ }
1671
+ } else {
1672
+ texts.push({ file: c, body: fs.readFileSync(c, 'utf8') });
1673
+ }
1674
+ }
1675
+ let bad = false;
1676
+ for (const t of texts) {
1677
+ const r = await textContainsSecrets(brainDir, t.body);
1678
+ if (r.leak) {
1679
+ bad = true;
1680
+ console.error(` โŒ LEAK in ${t.file}: keys ${r.keys.join(', ')}`);
1681
+ }
1682
+ }
1683
+ if (bad) process.exit(1);
1684
+ console.log(` โœ… No secret values found in ${texts.length} surface/openwiki file(s)`);
1685
+ return;
1686
+ }
1687
+
1688
+ console.error(`Unknown secret command: ${opts.command}`);
1689
+ printHelp();
1690
+ process.exit(1);
1691
+ } catch (err) {
1692
+ console.error(`โŒ ${err.message}`);
1693
+ process.exit(1);
1694
+ }
1695
+ }