auxilo-mcp 0.9.27 → 0.9.29

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/bin/auxilo-cli.js CHANGED
@@ -19,7 +19,7 @@ const fs = require('fs');
19
19
  const os = require('os');
20
20
  const path = require('path');
21
21
  const readline = require('readline');
22
- const { exec } = require('child_process');
22
+ const { exec, spawnSync } = require('child_process');
23
23
  const installer = require('../lib/installer.js');
24
24
  const review = require('../lib/review.js');
25
25
  const runnerAutoupdate = require('../lib/runner-autoupdate.js');
@@ -244,7 +244,15 @@ async function cmdSetup(flags) {
244
244
  console.log('');
245
245
  for (const client of chosen.filter((c) => c.mcp)) {
246
246
  try {
247
- const result = installer.registerMcp(client);
247
+ const result = installer.registerMcp(client, undefined, { commandRunner: spawnSync });
248
+ if (client.id === 'claude-code') {
249
+ if (result.failed) {
250
+ console.log('Setup could not add Auxilo to Claude Code, so run this command to add it yourself.');
251
+ console.log(installer.claudeMcpCommand());
252
+ }
253
+ console.log(installer.claudeMcpStatusLine(result));
254
+ continue;
255
+ }
248
256
  console.log(result.changed
249
257
  ? ` ✓ ${client.name}: registered Auxilo MCP server (${result.configPath})`
250
258
  : ` ✓ ${client.name}: already registered (no changes)`);
@@ -283,7 +291,7 @@ async function cmdSetup(flags) {
283
291
  });
284
292
  creds = installer.readCredentials(HOME);
285
293
  console.log(` ✓ Logged in as ${result.email || result.account_id}`);
286
- console.log(` ✓ Credentials saved to ~/.auxilo/credentials.json (mode 0600)`);
294
+ console.log(` ✓ Credentials saved to ~/.auxilo/credentials.json${process.platform === 'win32' ? '' : ' (mode 0600)'}`);
287
295
  } catch (err) {
288
296
  console.error(` ✗ Login failed: ${err.message}`);
289
297
  console.error(' MCP registration (above) is still in place. Re-run `auxilo setup` to retry login.');
@@ -505,7 +513,7 @@ async function cmdInit(flags) {
505
513
  try {
506
514
  const w = installer.writeEnvFile(envPath, { api_key: result.api_key, base_url: baseUrl });
507
515
  if (!flags.json) {
508
- console.log(` ✓ ${w.created ? 'Created' : 'Updated'} ${w.path} (AUXILO_API_KEY, mode 0600)`);
516
+ console.log(` ✓ ${w.created ? 'Created' : 'Updated'} ${w.path} (AUXILO_API_KEY${process.platform === 'win32' ? '' : ', mode 0600'})`);
509
517
  }
510
518
  } catch (err) {
511
519
  // The key was already minted — surface it rather than losing it.
@@ -523,7 +531,7 @@ async function cmdInit(flags) {
523
531
  email: result.email,
524
532
  account_id: result.account_id,
525
533
  });
526
- if (!flags.json) console.log(' ✓ Saved to ~/.auxilo/credentials.json (mode 0600) — this machine now uses this key.');
534
+ if (!flags.json) console.log(` ✓ Saved to ~/.auxilo/credentials.json${process.platform === 'win32' ? '' : ' (mode 0600)'} — this machine now uses this key.`);
527
535
  }
528
536
 
529
537
  if (flags.json) {
@@ -553,7 +561,7 @@ async function cmdInit(flags) {
553
561
  // ─── auxilo status ──────────────────────────────────────────────────────────
554
562
 
555
563
  async function cmdStatus() {
556
- const s = await installer.getStatus(HOME);
564
+ const s = await installer.getStatus(HOME, { commandRunner: spawnSync });
557
565
 
558
566
  console.log('\nAuxilo status');
559
567
  console.log('=============');
@@ -561,6 +569,10 @@ async function cmdStatus() {
561
569
  console.log('Clients:');
562
570
  if (s.clients.length === 0) console.log(' (none detected)');
563
571
  for (const c of s.clients) {
572
+ if (c.id === 'claude-code') {
573
+ console.log(` ${installer.claudeMcpStatusLine(c)}`);
574
+ continue;
575
+ }
564
576
  const reg = c.mcp
565
577
  ? (c.registered ? 'MCP registered' : 'detected, MCP NOT registered')
566
578
  : 'poll-based source (no MCP config)';
@@ -1121,6 +1133,10 @@ const CLEAN_LANE_AFFIRMATION = 'I understand and choose auto-publish for qualify
1121
1133
  * "readable only by your user account" claim below is never printed false.
1122
1134
  */
1123
1135
  const PROVIDER_KEY_CONSENT_SENTENCE = 'This key is yours. It stays on this machine in ~/.auxilo/providers.json, readable only by your user account, and Auxilo never receives it. It is used for one thing, drafting learnings from your own scrubbed sessions. Drafting sends those sessions to that provider under your own account, and any use is charged to that account, never to Auxilo. Run auxilo provider clear to remove it.';
1136
+ const WINDOWS_PROVIDER_KEY_CONSENT_SENTENCE = 'This key is yours. It stays on this machine in your user profile folder, at .auxilo\\providers.json, and Auxilo never receives it. It is used for one thing, drafting learnings from your own scrubbed sessions. Drafting sends those sessions to that provider under your own account, and any use is charged to that account, never to Auxilo. Run auxilo provider clear to remove it.';
1137
+ function providerKeyConsentSentence(platform = process.platform) {
1138
+ return platform === 'win32' ? WINDOWS_PROVIDER_KEY_CONSENT_SENTENCE : PROVIDER_KEY_CONSENT_SENTENCE;
1139
+ }
1124
1140
  const CLEAN_LANE_UNAVAILABLE = 'Auto-publish for clean learnings is not yet available on this account.';
1125
1141
  // CLEAN-LANE-FLIP Phase B (legal; DRAFT pending Tyler): the full text of ToS
1126
1142
  // §5.9.3(g) (plus its ratchet paragraph) prints ABOVE the affirmation prompt —
@@ -1371,6 +1387,7 @@ const PROVIDER_VENDORS = ['openai', 'anthropic', 'gemini'];
1371
1387
  * reason. Never throws now.
1372
1388
  */
1373
1389
  function providersFileModeUnsafe(target) {
1390
+ if (process.platform === 'win32') return !byoKeyProvider.providersFileInsideProfile(target);
1374
1391
  let stat;
1375
1392
  try {
1376
1393
  stat = fs.statSync(target);
@@ -1382,6 +1399,7 @@ function providersFileModeUnsafe(target) {
1382
1399
  }
1383
1400
 
1384
1401
  async function cmdProvider(flags) {
1402
+ const PROVIDER_KEY_CONSENT_SENTENCE = providerKeyConsentSentence();
1385
1403
  const sub = process.argv[3];
1386
1404
  if (!['status', 'set', 'clear'].includes(sub)) {
1387
1405
  if (sub) console.error(`Unknown provider subcommand: ${sub}`);
@@ -1430,7 +1448,7 @@ async function cmdProvider(flags) {
1430
1448
  // existing providers.json that is not owner-read-only would make the
1431
1449
  // sentence's "readable only by your user account" claim false.
1432
1450
  if (providersFileModeUnsafe(byoKeyProvider.DEFAULT_PROVIDERS_STATE_PATH)) {
1433
- console.error('auxilo provider set refuses to continue: ~/.auxilo/providers.json exists and is not owner-read-only, so this build cannot truthfully make the consent promise (reasonCode: providers-file-mode-unsafe). Fix its permissions (chmod 600 ~/.auxilo/providers.json) or remove the file, then try again.');
1451
+ console.error(process.platform === 'win32' ? 'The provider key file must be inside your Windows user profile folder. (reasonCode: providers-file-mode-unsafe)' : 'auxilo provider set refuses to continue: ~/.auxilo/providers.json exists and is not owner-read-only, so this build cannot truthfully make the consent promise (reasonCode: providers-file-mode-unsafe). Fix its permissions (chmod 600 ~/.auxilo/providers.json) or remove the file, then try again.');
1434
1452
  process.exit(1);
1435
1453
  }
1436
1454
 
@@ -1515,7 +1533,7 @@ async function cmdProvider(flags) {
1515
1533
  }
1516
1534
  throw err;
1517
1535
  }
1518
- console.log(`\n✓ Saved to ${written} (mode 0600). This machine drafts through Claude Code first when you are signed in to it; your ${vendor} key only takes over when Claude Code is not usable, and once it does, it keeps drafting even after Claude Code becomes usable again.`);
1536
+ console.log(`\n✓ Saved to ${written}${process.platform === 'win32' ? '' : ' (mode 0600)' }. This machine drafts through Claude Code first when you are signed in to it; your ${vendor} key only takes over when Claude Code is not usable, and once it does, it keeps drafting even after Claude Code becomes usable again.`);
1519
1537
  }
1520
1538
 
1521
1539
  // ─── Entry point ────────────────────────────────────────────────────────────
@@ -1708,6 +1726,7 @@ module.exports = {
1708
1726
  CLEAN_LANE_NO_EMAIL_LINE,
1709
1727
  wrapForTerminal,
1710
1728
  PROVIDER_KEY_CONSENT_SENTENCE,
1729
+ providerKeyConsentSentence,
1711
1730
  CLI_CLEAN_LANE_CALIBRATED_PROVIDERS,
1712
1731
  STATUS_WORTHY_SKIP_REASON_CODES,
1713
1732
  extractionSkipReasonLine,
@@ -1,5 +1,16 @@
1
1
  'use strict';
2
2
 
3
+ /** Recognize only the generated node command under Auxilo's scripts directory. */
4
+ function isAuxiloNodeHook(command, script, source = null) {
5
+ if (typeof command !== 'string') return false;
6
+ const match = /^node "([^"\r\n]+)"(?: --source ([a-z0-9-]+))?$/.exec(command);
7
+ if (!match) return false;
8
+ const windows = process.platform === 'win32' || /^[a-z]:[\\/]/i.test(match[1]) || match[1].includes('\\');
9
+ const target = windows ? match[1].replace(/\\/g, '/').toLowerCase() : match[1];
10
+ if (!target.endsWith(`/.auxilo/bin/scripts/${script}`)) return false;
11
+ return source === true ? Boolean(match[2]) : (match[2] || null) === source;
12
+ }
13
+
3
14
  /**
4
15
  * True when a Claude Code SessionEnd collection contains an Auxilo extraction
5
16
  * hook. Accepts both the legacy bare-command string and the current matcher
@@ -9,16 +20,17 @@
9
20
  * @returns {boolean}
10
21
  */
11
22
  function hasAuxiloSessionEndHook(sessionEnd) {
23
+ const ours = command => command.includes('auxilo-extract') || isAuxiloNodeHook(command, 'capture-core.js', 'claude-code');
12
24
  if (!Array.isArray(sessionEnd)) return false;
13
25
 
14
26
  return sessionEnd.some((entry) => {
15
- if (typeof entry === 'string') return entry.includes('auxilo-extract');
27
+ if (typeof entry === 'string') return ours(entry);
16
28
  if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return false;
17
29
  return entry.hooks.some((hook) =>
18
30
  hook && typeof hook === 'object' &&
19
31
  typeof hook.command === 'string' &&
20
- hook.command.includes('auxilo-extract'));
32
+ ours(hook.command));
21
33
  });
22
34
  }
23
35
 
24
- module.exports = { hasAuxiloSessionEndHook };
36
+ module.exports = { hasAuxiloSessionEndHook, isAuxiloNodeHook };
package/lib/installer.js CHANGED
@@ -25,7 +25,7 @@
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
27
  const crypto = require('crypto');
28
- const { hasAuxiloSessionEndHook } = require('./hook-status.js');
28
+ const { hasAuxiloSessionEndHook, isAuxiloNodeHook } = require('./hook-status.js');
29
29
 
30
30
  // ─── Constants ──────────────────────────────────────────────────────────────
31
31
 
@@ -47,6 +47,7 @@ const MCP_PACKAGE_NAME = MCP_ENTRY.args[0];
47
47
 
48
48
  /** True when `args` is shaped like an Auxilo-owned MCP entry (pinned or not). */
49
49
  function isOwnedMcpArgs(args) {
50
+ if (Array.isArray(args) && args.length === 3 && args[0] === '/c' && args[1] === 'npx') args = args.slice(2);
50
51
  return Array.isArray(args) && args.length === 1 && typeof args[0] === 'string' &&
51
52
  (args[0] === MCP_PACKAGE_NAME || args[0].startsWith(`${MCP_PACKAGE_NAME}@`));
52
53
  }
@@ -57,6 +58,7 @@ function isOwnedMcpArgs(args) {
57
58
  */
58
59
  function parseMcpPin(args) {
59
60
  if (!isOwnedMcpArgs(args)) return null;
61
+ if (args.length === 3) args = args.slice(2);
60
62
  return args[0] === MCP_PACKAGE_NAME ? 'unpinned' : args[0].slice(MCP_PACKAGE_NAME.length + 1);
61
63
  }
62
64
 
@@ -235,8 +237,11 @@ function clientRegistry(homeDir, opts = {}) {
235
237
  id: 'claude-code',
236
238
  name: 'Claude Code',
237
239
  detectDir: path.join(homeDir, '.claude'),
238
- configPath: path.join(homeDir, '.claude', 'settings.json'),
239
- format: 'json-mcpServers',
240
+ configPath: path.join(homeDir, '.claude.json'),
241
+ legacyConfigPath: path.join(homeDir, '.claude', 'settings.json'),
242
+ homeDir,
243
+ platform,
244
+ format: 'claude-cli',
240
245
  mcp: true,
241
246
  hooks: true, // SessionEnd hook (background extraction)
242
247
  },
@@ -466,7 +471,7 @@ function clientRegistry(homeDir, opts = {}) {
466
471
  mcp: true,
467
472
  hooks: false,
468
473
  },
469
- ];
474
+ ].map(client => ({ ...client, platform }));
470
475
  }
471
476
 
472
477
  /** True when p exists and is a directory (detection probe). */
@@ -601,10 +606,103 @@ function readClientConfig(configPath) {
601
606
  * unpinned shape (only used by parity checks below — every real writer call
602
607
  * always passes a version, defaulting to packageVersion() in registerMcp).
603
608
  */
604
- function mcpEntry(version) {
609
+ function mcpEntry(version, platform) {
610
+ if (platform === 'win32') return { command: 'cmd', args: ['/c', 'npx', version ? `${MCP_PACKAGE_NAME}@${version}` : MCP_PACKAGE_NAME] };
605
611
  return { command: MCP_ENTRY.command, args: [version ? `${MCP_PACKAGE_NAME}@${version}` : MCP_PACKAGE_NAME] };
606
612
  }
607
613
 
614
+ /** The binding layer supplies the runner; omission is deliberately inert. */
615
+ function claudeMcpCommand(platform = process.platform) {
616
+ return `claude mcp add --scope user auxilo -- ${platform === 'win32' ? 'cmd /c ' : ''}npx auxilo-mcp`;
617
+ }
618
+
619
+ function claudeMcpStatusLine(state, platform = process.platform) {
620
+ if (state.connection === 'connected') return `Claude Code: connected (${state.mcpPin || 'unpinned'})`;
621
+ if (state.connection === 'added') return 'Claude Code: added, not responding yet. Restart Claude Code, then run npx auxilo status';
622
+ return `Claude Code: not connected. Run ${claudeMcpCommand(platform)}`;
623
+ }
624
+
625
+ function claudeRunnerBin(client, opts) {
626
+ if (opts.claudeBin) return opts.claudeBin;
627
+ const recorded = readRunnerConfig(client.homeDir).claude_bin;
628
+ if (typeof recorded === 'string' && fs.existsSync(recorded)) return recorded;
629
+ return findExecutableOnPath('claude', (opts.env || process.env).PATH, fs, { platform: client.platform, env: opts.env || process.env }) ||
630
+ [path.join(client.homeDir, '.local', 'bin', 'claude.exe'), path.join((opts.env || process.env).APPDATA || path.join(client.homeDir, 'AppData', 'Roaming'), 'npm', 'claude.cmd')].find(candidate => client.platform === 'win32' && fs.existsSync(candidate)) || null;
631
+ }
632
+
633
+ function runClaudeMcp(client, args, opts) {
634
+ if (typeof opts.commandRunner !== 'function') return null;
635
+ const bin = claudeRunnerBin(client, opts);
636
+ if (!bin) return null;
637
+ try {
638
+ if (client.platform === 'win32' && /\.cmd$/i.test(bin)) {
639
+ const cli = path.join(path.dirname(bin), 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
640
+ if (!fs.existsSync(cli)) return null;
641
+ return opts.commandRunner(process.execPath, [cli, ...args], { encoding: 'utf8', timeout: 20000, windowsHide: true });
642
+ }
643
+ return opts.commandRunner(bin, args, { encoding: 'utf8', timeout: 20000, windowsHide: true });
644
+ } catch (error) { return { error, status: null }; }
645
+ }
646
+
647
+ function claudeMcpState(client, opts = {}, added = false) {
648
+ const result = runClaudeMcp(client, ['mcp', 'get', 'auxilo'], opts);
649
+ if (!result) return { connection: 'not-connected', registered: false, mcpPin: null };
650
+ const output = String(result.stdout || '') + String(result.stderr || '');
651
+ const absent = output.includes('No MCP server named');
652
+ const present = !absent && (added || mcpRegistrationPresent(client) || /Command:\s*\S/.test(output));
653
+ const connected = !absent && /\bConnected\b/.test(output);
654
+ const pinMatch = output.match(/\bauxilo-mcp@([^\s"']+)/);
655
+ return {
656
+ connection: connected ? 'connected' : present ? 'added' : 'not-connected',
657
+ registered: connected || present,
658
+ mcpPin: pinMatch ? pinMatch[1] : (present ? mcpPinnedVersion(client) || 'unpinned' : null),
659
+ };
660
+ }
661
+
662
+ function claudeAddArgs(entry) {
663
+ const envArgs = Object.entries(entry.env || {}).flatMap(([key, value]) => ['--env', `${key}=${value}`]);
664
+ return ['mcp', 'add', '--scope', 'user', ...envArgs, 'auxilo', '--', entry.command, ...entry.args];
665
+ }
666
+
667
+ function registerClaudeMcp(client, version, opts = {}) {
668
+ const target = client.platform === 'win32'
669
+ ? { command: 'cmd', args: ['/c', 'npx', `auxilo-mcp@${version}`] }
670
+ : mcpEntry(version);
671
+ const result = runClaudeMcp(client, claudeAddArgs({ ...(opts.previousEntry || {}), ...target }), opts);
672
+ if (!result || result.error || result.status !== 0) {
673
+ return { changed: false, status: 'not-connected', failed: true, ...claudeMcpState(client, opts), configPath: client.configPath };
674
+ }
675
+ const state = claudeMcpState(client, opts, true);
676
+ if (state.registered && client.legacyConfigPath && fs.existsSync(client.legacyConfigPath)) {
677
+ const legacy = readClientConfig(client.legacyConfigPath);
678
+ const old = legacy.mcpServers && legacy.mcpServers.auxilo;
679
+ if (old && (old.command === 'npx' || old.command === 'cmd') && isOwnedMcpArgs(old.args)) {
680
+ delete legacy.mcpServers.auxilo;
681
+ writeJsonAtomic(client.legacyConfigPath, legacy);
682
+ }
683
+ }
684
+ return { changed: true, status: state.connection, ...state, configPath: client.configPath };
685
+ }
686
+
687
+ function rewriteClaudeMcpPin(client, version, opts) {
688
+ const previous = readClientConfig(client.configPath).mcpServers?.auxilo;
689
+ if (!previous || !['npx', 'cmd'].includes(previous.command) || !isOwnedMcpArgs(previous.args)) return unchangedResult(client.configPath);
690
+ if (parseMcpPin(previous.args) === version) return unchangedResult(client.configPath);
691
+ if (!claudeMcpState(client, opts).registered) return unchangedResult(client.configPath);
692
+ const removed = runClaudeMcp(client, ['mcp', 'remove', '--scope', 'user', 'auxilo'], opts);
693
+ if (!removed || removed.error || removed.status !== 0) return unchangedResult(client.configPath);
694
+ const result = registerClaudeMcp(client, version, { ...opts, previousEntry: previous });
695
+ if (result.failed) {
696
+ const restored = runClaudeMcp(client, claudeAddArgs(previous), opts);
697
+ const state = claudeMcpState(client, opts);
698
+ const preserved = Boolean(restored && !restored.error && restored.status === 0 && state.registered && state.mcpPin === parseMcpPin(previous.args));
699
+ return preserved
700
+ ? { ...unchangedResult(client.configPath), ...state, restored: true }
701
+ : { changed: true, status: 'error', failed: true, restored: false, ...state, configPath: client.configPath };
702
+ }
703
+ return result;
704
+ }
705
+
608
706
  /** Result helpers shared by every registerMcp format writer. */
609
707
  function unchangedResult(configPath) {
610
708
  return { changed: false, status: 'already-registered', configPath };
@@ -633,10 +731,10 @@ function registeredResult(configPath) {
633
731
  function registerJsonMcpServers(client, version) {
634
732
  const config = readClientConfig(client.configPath);
635
733
  const existing = config.mcpServers && config.mcpServers.auxilo;
636
- if (existing && !(existing.command === MCP_ENTRY.command && isOwnedMcpArgs(existing.args))) {
734
+ if (existing && !(['npx', 'cmd'].includes(existing.command) && isOwnedMcpArgs(existing.args))) {
637
735
  return unchangedResult(client.configPath); // foreign entry — never touch
638
736
  }
639
- const target = mcpEntry(version);
737
+ const target = mcpEntry(version, client.platform);
640
738
  if (existing && existing.command === target.command &&
641
739
  JSON.stringify(existing.args) === JSON.stringify(target.args)) {
642
740
  return unchangedResult(client.configPath); // already pinned correctly
@@ -850,10 +948,11 @@ const MCP_WRITERS = Object.freeze({
850
948
  * @returns {{ changed: boolean, status: 'registered'|'already-registered', configPath: string }}
851
949
  * @throws on malformed existing JSON (B15 — caller skips that client loudly)
852
950
  */
853
- function registerMcp(client, version) {
951
+ function registerMcp(client, version, opts = {}) {
854
952
  if (!client || !client.configPath) {
855
953
  throw new Error(`registerMcp: client ${client && client.id} has no MCP config path`);
856
954
  }
955
+ if (client.id === 'claude-code') return registerClaudeMcp(client, version || packageVersion(), opts);
857
956
  const writer = MCP_WRITERS[client.format || 'json-mcpServers'];
858
957
  if (!writer) {
859
958
  throw new Error(`registerMcp: client ${client.id} has unknown config format "${client.format}"`);
@@ -956,7 +1055,7 @@ function rewriteMcpPins(homeDir, version, opts = {}) {
956
1055
  for (const client of detectClients(homeDir, opts).filter((c) => c.mcp)) {
957
1056
  if (!mcpRegistrationPresent(client)) continue; // not already registered — never first-register here
958
1057
  try {
959
- const result = registerMcp(client, version);
1058
+ const result = client.id === 'claude-code' ? rewriteClaudeMcpPin(client, version, opts) : registerMcp(client, version, opts);
960
1059
  results.push({ id: client.id, name: client.name, ...result });
961
1060
  } catch (err) {
962
1061
  // B15 discipline: malformed config — skip loudly, never overwrite,
@@ -1062,23 +1161,30 @@ function writeRunnerConfig(homeDir, patch) {
1062
1161
  }
1063
1162
 
1064
1163
  /** Find the first regular executable in PATH, preserving its symlink path. */
1065
- function findExecutableOnPath(name, envPath, fsImpl = fs) {
1164
+ function findExecutableOnPath(name, envPath, fsImpl = fs, opts = {}) {
1066
1165
  if (typeof envPath !== 'string' || !envPath) return null;
1067
- for (const dir of envPath.split(path.delimiter)) {
1166
+ const windows = (opts.platform || process.platform) === 'win32';
1167
+ const pathImpl = windows ? path.win32 : path;
1168
+ const extensions = windows && !pathImpl.extname(name)
1169
+ ? ((opts.env || process.env).PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
1170
+ : [''];
1171
+ for (const dir of envPath.split(windows ? ';' : path.delimiter)) {
1068
1172
  if (!dir) continue;
1069
- const candidate = path.resolve(dir, name);
1070
- try {
1071
- if (!fsImpl.statSync(candidate).isFile()) continue;
1072
- fsImpl.accessSync(candidate, fs.constants.X_OK);
1073
- return candidate;
1074
- } catch (_) { /* missing, inaccessible or non-executable — try the next entry */ }
1173
+ for (const extension of extensions) {
1174
+ const candidate = pathImpl.resolve(dir, name + extension.toLowerCase());
1175
+ try {
1176
+ if (!fsImpl.statSync(candidate).isFile()) continue;
1177
+ fsImpl.accessSync(candidate, windows ? fs.constants.F_OK : fs.constants.X_OK);
1178
+ return candidate;
1179
+ } catch (_) { /* try the next candidate */ }
1180
+ }
1075
1181
  }
1076
1182
  return null;
1077
1183
  }
1078
1184
 
1079
1185
  /** Setup only: record the user's CLI and confirm the best-effort write persisted. */
1080
- function recordClaudeBin(homeDir, envPath, fsImpl = fs) {
1081
- const bin = findExecutableOnPath('claude', envPath, fsImpl);
1186
+ function recordClaudeBin(homeDir, envPath, fsImpl = fs, opts = {}) {
1187
+ const bin = findExecutableOnPath('claude', envPath, fsImpl, opts);
1082
1188
  if (bin === null) return null;
1083
1189
  writeRunnerConfig(homeDir, { claude_bin: bin });
1084
1190
  return readRunnerConfig(homeDir).claude_bin === bin ? bin : null;
@@ -1331,10 +1437,13 @@ function installRunner(homeDir, opts = {}) {
1331
1437
  // the WRITE TARGET (hookPath/versionPath below) follows binRoot, so a
1332
1438
  // staged build never touches the real bin root until the caller renames
1333
1439
  // the whole staged directory into place.
1334
- const hookPath = path.join(binRoot, path.basename(hookScriptPathFor(homeDir)));
1335
- fs.writeFileSync(hookPath, renderHookScript(homeDir));
1336
- fs.chmodSync(hookPath, 0o755);
1337
- installed.push(hookPath);
1440
+ const hookPath = (opts.platform || process.platform) === 'win32'
1441
+ ? path.join(binRoot, 'scripts', 'capture-core.js') : path.join(binRoot, path.basename(hookScriptPathFor(homeDir)));
1442
+ if ((opts.platform || process.platform) !== 'win32') {
1443
+ fs.writeFileSync(hookPath, renderHookScript(homeDir));
1444
+ fs.chmodSync(hookPath, 0o755);
1445
+ installed.push(hookPath);
1446
+ }
1338
1447
 
1339
1448
  const version = packageVersion(packageRoot);
1340
1449
  const versionPath = path.join(binRoot, path.basename(runnerVersionPath(homeDir)));
@@ -1478,9 +1587,12 @@ function installRunnerAtomic(homeDir, opts = {}) {
1478
1587
  // pruneStaleStackFiles's doc comment.
1479
1588
  pruneStaleStackFiles(binRoot, installed);
1480
1589
 
1481
- const hookPath = path.join(binRoot, path.basename(hookScriptPathFor(homeDir)));
1482
- atomicWrite(hookPath, (tmp) => fs.writeFileSync(tmp, renderHookScript(homeDir)), 0o755);
1483
- installed.push(hookPath);
1590
+ const hookPath = (opts.platform || process.platform) === 'win32'
1591
+ ? path.join(binRoot, 'scripts', 'capture-core.js') : path.join(binRoot, path.basename(hookScriptPathFor(homeDir)));
1592
+ if ((opts.platform || process.platform) !== 'win32') {
1593
+ atomicWrite(hookPath, (tmp) => fs.writeFileSync(tmp, renderHookScript(homeDir)), 0o755);
1594
+ installed.push(hookPath);
1595
+ }
1484
1596
 
1485
1597
  const version = packageVersion(packageRoot);
1486
1598
  const versionPath = path.join(binRoot, path.basename(runnerVersionPath(homeDir)));
@@ -1534,10 +1646,15 @@ function runnerVersionSkew(homeDir, opts = {}) {
1534
1646
 
1535
1647
  // ─── Claude Code hook registration (spec §LW-12 step 4) ─────────────────────
1536
1648
 
1649
+ function nodeHookCommand(homeDir, script, source) {
1650
+ const target = path.join(binRootFor(homeDir), 'scripts', script).replace(/\\/g, '/');
1651
+ return `node "${target}"${source ? ` --source ${source}` : ''}`;
1652
+ }
1653
+
1537
1654
  /** True when an inner hook object is an Auxilo extraction command. */
1538
1655
  function isAuxiloCommandHook(h) {
1539
1656
  return h && typeof h === 'object' && typeof h.command === 'string' &&
1540
- h.command.includes('auxilo-extract');
1657
+ (h.command.includes('auxilo-extract') || isAuxiloNodeHook(h.command, 'capture-core.js', 'claude-code'));
1541
1658
  }
1542
1659
 
1543
1660
  /**
@@ -1558,10 +1675,10 @@ function isAuxiloCommandHook(h) {
1558
1675
  *
1559
1676
  * @returns {{ changed: boolean, hookCmd: string, removedLegacy: string[] }}
1560
1677
  */
1561
- function registerClaudeCodeHook(homeDir) {
1678
+ function registerClaudeCodeHook(homeDir, opts = {}) {
1562
1679
  if (!homeDir) throw new Error('registerClaudeCodeHook: homeDir is required');
1563
1680
  const settingsPath = path.join(homeDir, '.claude', 'settings.json');
1564
- const hookCmd = hookScriptPathFor(homeDir);
1681
+ const hookCmd = (opts.platform || process.platform) === 'win32' ? nodeHookCommand(homeDir, 'capture-core.js', 'claude-code') : hookScriptPathFor(homeDir);
1565
1682
 
1566
1683
  const settings = readClientConfig(settingsPath);
1567
1684
  if (!settings.hooks) settings.hooks = {};
@@ -1572,7 +1689,7 @@ function registerClaudeCodeHook(homeDir) {
1572
1689
  const kept = [];
1573
1690
 
1574
1691
  for (const entry of before) {
1575
- if (typeof entry === 'string' && entry.includes('auxilo-extract')) {
1692
+ if (typeof entry === 'string' && isAuxiloCommandHook({ command: entry })) {
1576
1693
  removedLegacy.push(entry); // includes 0.8.1's dead bare-string entry
1577
1694
  continue;
1578
1695
  }
@@ -1632,7 +1749,7 @@ function removeClaudeCodeHook(homeDir) {
1632
1749
  const removed = [];
1633
1750
  const kept = [];
1634
1751
  for (const entry of settings.hooks.SessionEnd) {
1635
- if (typeof entry === 'string' && entry.includes('auxilo-extract')) {
1752
+ if (typeof entry === 'string' && isAuxiloCommandHook({ command: entry })) {
1636
1753
  removed.push(entry);
1637
1754
  continue;
1638
1755
  }
@@ -1711,7 +1828,7 @@ function writeCaptureShim(homeDir, sourceId) {
1711
1828
 
1712
1829
  /** True when an entry's command string references an Auxilo capture shim. */
1713
1830
  function isAuxiloCaptureCommand(cmd) {
1714
- return typeof cmd === 'string' && cmd.includes('auxilo-capture');
1831
+ return typeof cmd === 'string' && (cmd.includes('auxilo-capture') || isAuxiloNodeHook(cmd, 'capture-core.js', true));
1715
1832
  }
1716
1833
 
1717
1834
  /**
@@ -1870,7 +1987,7 @@ const CAPTURE_WRITERS = Object.freeze({
1870
1987
  * @returns {{ changed: boolean, hookPath: string, configPath: string, notes?: string }}
1871
1988
  * @throws on malformed existing hook config JSON (B15 — caller skips loudly)
1872
1989
  */
1873
- function registerCaptureHook(client, homeDir) {
1990
+ function registerCaptureHook(client, homeDir, opts = {}) {
1874
1991
  if (!homeDir) throw new Error('registerCaptureHook: homeDir is required');
1875
1992
  if (!client || !client.captureHook || !client.captureConfigPath) {
1876
1993
  throw new Error(`registerCaptureHook: client ${client && client.id} has no capture-hook support`);
@@ -1880,13 +1997,14 @@ function registerCaptureHook(client, homeDir) {
1880
1997
  throw new Error(`registerCaptureHook: no capture writer for client "${client.id}"`);
1881
1998
  }
1882
1999
  const sourceId = client.sourceId || client.id;
1883
- const shimPath = captureShimPath(homeDir, sourceId);
2000
+ const windows = (opts.platform || client.platform || process.platform) === 'win32';
2001
+ const shimPath = windows ? nodeHookCommand(homeDir, 'capture-core.js', sourceId) : captureShimPath(homeDir, sourceId);
1884
2002
 
1885
2003
  // Codex skip-gotcha must not leave a dangling shim behind — let the writer
1886
2004
  // decide first, then write the shim only when the hook is actually wired.
1887
2005
  const result = writer(client, homeDir, shimPath);
1888
2006
  let shimChanged = false;
1889
- if (!(client.id === 'codex' && result.changed === false &&
2007
+ if (!windows && !(client.id === 'codex' && result.changed === false &&
1890
2008
  result.notes && result.notes.includes('config.toml already defines hooks'))) {
1891
2009
  shimChanged = writeCaptureShim(homeDir, sourceId);
1892
2010
  }
@@ -1904,15 +2022,18 @@ function registerCaptureHook(client, homeDir) {
1904
2022
  * registered when the shim file exists AND the hook config references it.
1905
2023
  * Never throws (missing/malformed config reads as "not registered").
1906
2024
  */
1907
- function captureHookRegistered(client, homeDir) {
2025
+ function captureHookRegistered(client, homeDir, opts = {}) {
1908
2026
  if (!client || !client.captureHook || !client.captureConfigPath) return false;
1909
- const shimPath = captureShimPath(homeDir, client.sourceId || client.id);
2027
+ const source = client.sourceId || client.id;
2028
+ const shimPath = captureShimPath(homeDir, source);
2029
+ const nodeCommand = nodeHookCommand(homeDir, 'capture-core.js', source);
2030
+ const includes = (value, target) => typeof value === 'string' ? value === target :
2031
+ value && typeof value === 'object' && Object.values(value).some(child => includes(child, target));
1910
2032
  try {
1911
- return fs.existsSync(shimPath) &&
1912
- fs.readFileSync(client.captureConfigPath, 'utf-8').includes(shimPath);
1913
- } catch {
1914
- return false;
1915
- }
2033
+ const config = JSON.parse(fs.readFileSync(client.captureConfigPath, 'utf8'));
2034
+ return (fs.existsSync(shimPath) && includes(config, shimPath)) ||
2035
+ (fs.existsSync(path.join(binRootFor(homeDir), 'scripts', 'capture-core.js')) && includes(config, nodeCommand));
2036
+ } catch { return false; }
1916
2037
  }
1917
2038
 
1918
2039
  /**
@@ -1949,7 +2070,7 @@ function installCaptureHooks(homeDir, clients, opts = {}) {
1949
2070
 
1950
2071
  for (const client of targets) {
1951
2072
  try {
1952
- const r = registerCaptureHook(client, homeDir);
2073
+ const r = registerCaptureHook(client, homeDir, opts);
1953
2074
  results.push({ id: client.id, name: client.name, event: client.captureEvent, ...r });
1954
2075
  } catch (err) {
1955
2076
  // B15 skip-loudly: collect the error, keep going for the other clients.
@@ -2071,7 +2192,7 @@ function noticeShimPath(homeDir) {
2071
2192
 
2072
2193
  /** Marker every notice entry carries (registration + removal + status probe). */
2073
2194
  function isAuxiloNoticeCommand(cmd) {
2074
- return typeof cmd === 'string' && cmd.includes('auxilo-review-notice');
2195
+ return typeof cmd === 'string' && (cmd.includes('auxilo-review-notice') || isAuxiloNodeHook(cmd, 'review-notice.js'));
2075
2196
  }
2076
2197
 
2077
2198
  /** Generate the notice shim body (fail-silent: always exits 0). */
@@ -2096,15 +2217,16 @@ exit 0
2096
2217
  *
2097
2218
  * @returns {{ changed: boolean, hookCmd: string }}
2098
2219
  */
2099
- function registerClaudeCodeSessionStartNotice(homeDir) {
2220
+ function registerClaudeCodeSessionStartNotice(homeDir, opts = {}) {
2100
2221
  if (!homeDir) throw new Error('registerClaudeCodeSessionStartNotice: homeDir is required');
2101
2222
  const settingsPath = path.join(homeDir, '.claude', 'settings.json');
2102
- const hookCmd = noticeShimPath(homeDir);
2223
+ const windows = (opts.platform || process.platform) === 'win32';
2224
+ const hookCmd = windows ? nodeHookCommand(homeDir, 'review-notice.js') : noticeShimPath(homeDir);
2103
2225
 
2104
2226
  // Shim first (its own idempotency: rewritten only when stale).
2105
2227
  const body = renderNoticeShim(homeDir);
2106
2228
  let shimChanged = false;
2107
- if (!fs.existsSync(hookCmd) || fs.readFileSync(hookCmd, 'utf-8') !== body) {
2229
+ if (!windows && (!fs.existsSync(hookCmd) || fs.readFileSync(hookCmd, 'utf-8') !== body)) {
2108
2230
  fs.mkdirSync(path.dirname(hookCmd), { recursive: true });
2109
2231
  fs.writeFileSync(hookCmd, body);
2110
2232
  fs.chmodSync(hookCmd, 0o755);
@@ -2146,9 +2268,9 @@ function registerClaudeCodeSessionStartNotice(homeDir) {
2146
2268
  * Read-only probe for `auxilo status`: notice counts as registered when the
2147
2269
  * shim exists AND a STRUCTURED SessionStart entry references it. Never throws.
2148
2270
  */
2149
- function sessionStartNoticeRegistered(homeDir) {
2271
+ function sessionStartNoticeRegistered(homeDir, opts = {}) {
2150
2272
  try {
2151
- if (!fs.existsSync(noticeShimPath(homeDir))) return false;
2273
+ if (!fs.existsSync(noticeShimPath(homeDir)) && !fs.existsSync(path.join(binRootFor(homeDir), 'scripts', 'review-notice.js'))) return false;
2152
2274
  const settings = JSON.parse(
2153
2275
  fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8')
2154
2276
  );
@@ -2244,10 +2366,11 @@ async function getStatus(homeDir, opts = {}) {
2244
2366
  // 1. Clients: detected + whether MCP registration is present
2245
2367
  const clients = detectClients(homeDir, opts).map((c) => {
2246
2368
  // null = not applicable (no MCP config); per-format probe otherwise (UC-0).
2247
- const registered = c.mcp ? mcpRegistrationPresent(c) : null;
2248
- const base = { id: c.id, name: c.name, mcp: c.mcp, registered };
2369
+ const cc = c.id === 'claude-code' ? claudeMcpState(c, opts) : null;
2370
+ const registered = cc ? cc.registered : c.mcp ? mcpRegistrationPresent(c) : null;
2371
+ const base = { id: c.id, name: c.name, mcp: c.mcp, registered, ...(cc || {}) };
2249
2372
  if (c.mcp && registered) {
2250
- const pin = mcpPinnedVersion(c);
2373
+ const pin = cc ? cc.mcpPin : mcpPinnedVersion(c);
2251
2374
  base.mcpPin = pin; // exact version string, 'unpinned' (legacy), or null (foreign entry)
2252
2375
  base.mcpPinStale = Boolean(pin && pin !== 'unpinned' && runnerVersion && pin !== runnerVersion);
2253
2376
  }
@@ -2284,7 +2407,7 @@ async function getStatus(homeDir, opts = {}) {
2284
2407
  }
2285
2408
 
2286
2409
  // 4. Sentinel + runner + hook install state
2287
- const hookPath = hookScriptPathFor(homeDir);
2410
+ const hookPath = (opts.platform || process.platform) === 'win32' ? path.join(binRootFor(homeDir), 'scripts', 'capture-core.js') : hookScriptPathFor(homeDir);
2288
2411
  const runnerInstalled = fs.existsSync(path.join(binRootFor(homeDir), 'scripts', 'runner.js'));
2289
2412
  let hookInstalled = false;
2290
2413
  let hookRegistered = false;
@@ -2416,6 +2539,10 @@ module.exports = {
2416
2539
  detectClients,
2417
2540
  clientHasCaptureHookMode,
2418
2541
  registerMcp,
2542
+ nodeHookCommand,
2543
+ claudeMcpState,
2544
+ claudeMcpCommand,
2545
+ claudeMcpStatusLine,
2419
2546
  mcpRegistrationPresent,
2420
2547
  mcpPinnedVersion,
2421
2548
  rewriteMcpPins,
package/lib/review.js CHANGED
@@ -48,7 +48,7 @@ async function fetchPending(opts = {}) {
48
48
 
49
49
  const res = await fetchImpl(`${baseUrl}/account/pending${suffix}`, {
50
50
  method: 'GET',
51
- headers: { 'X-API-Key': apiKey },
51
+ headers: { 'X-API-Key': apiKey, 'X-Auxilo-Client': `cli/${require('../package.json').version}` },
52
52
  });
53
53
  let body = {};
54
54
  try { body = await res.json(); } catch { /* non-JSON error body */ }
@@ -82,7 +82,7 @@ async function submitDecision(opts = {}) {
82
82
 
83
83
  const init = {
84
84
  method: 'POST',
85
- headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
85
+ headers: { 'X-API-Key': apiKey, 'X-Auxilo-Client': `cli/${require('../package.json').version}`, 'Content-Type': 'application/json' },
86
86
  };
87
87
  // reject accepts an optional reason; approve takes no body.
88
88
  if (decision === 'reject') {
@@ -154,7 +154,7 @@ async function fetchPendingSummary(opts = {}) {
154
154
 
155
155
  const res = await fetchImpl(`${baseUrl}/account/pending/summary`, {
156
156
  method: 'GET',
157
- headers: { 'X-API-Key': apiKey },
157
+ headers: { 'X-API-Key': apiKey, 'X-Auxilo-Client': `cli/${require('../package.json').version}` },
158
158
  });
159
159
  let body = {};
160
160
  try { body = await res.json(); } catch { /* non-JSON error body */ }
@@ -188,7 +188,7 @@ async function submitBulk(opts = {}) {
188
188
 
189
189
  const res = await fetchImpl(`${baseUrl}/account/pending/bulk`, {
190
190
  method: 'POST',
191
- headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
191
+ headers: { 'X-API-Key': apiKey, 'X-Auxilo-Client': `cli/${require('../package.json').version}`, 'Content-Type': 'application/json' },
192
192
  body: JSON.stringify({ decisions, confirm_count: decisions.length }),
193
193
  });
194
194
  let body = {};
@@ -626,7 +626,7 @@ function stageAndSwap(homeDir, extractedPackageDir, installer) {
626
626
  // fixture's minimal installer stub) simply skips the rewrite.
627
627
  if (typeof extractedInstaller.rewriteMcpPins === 'function') {
628
628
  try {
629
- extractedInstaller.rewriteMcpPins(homeDir, result.version);
629
+ extractedInstaller.rewriteMcpPins(homeDir, result.version, { commandRunner: require('child_process').spawnSync });
630
630
  } catch { /* best-effort — the runner swap itself already succeeded */ }
631
631
  }
632
632
 
package/mcp-server.js CHANGED
@@ -198,7 +198,7 @@ async function postBulkChunks(headers, decisions) {
198
198
  }
199
199
 
200
200
  const server = new Server(
201
- { name: 'auxilo', version: '0.9.27' },
201
+ { name: 'auxilo', version: '0.9.29' },
202
202
  {
203
203
  capabilities: { tools: {} },
204
204
  instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
@@ -687,6 +687,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
687
687
  const headers = baseHeaders(
688
688
  args.session_token ? { 'Authorization': `Bearer ${args.session_token}` } : {}
689
689
  );
690
+ headers['X-Auxilo-Client'] = `mcp/${require('./package.json').version}`;
690
691
 
691
692
  if (args.action === 'list') {
692
693
  const resp = await fetch(`${AUXILO_BASE}/account/pending/summary`, { headers });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.9.27",
3
+ "version": "0.9.29",
4
4
  "mcpName": "io.github.silent-architects/auxilo",
5
5
  "description": "MCP server for Auxilo. Your agent stops solving the same problem twice: auto-extracted learnings, free self-unlocks, and earnings when other agents unlock yours.",
6
6
  "main": "mcp-server.js",
@@ -99,12 +99,17 @@ function transcriptRoots(homeDir) {
99
99
  /** True iff realPath sits under a known transcript root AND has a text extension.
100
100
  * Roots are symlink-resolved too, so the comparison is realpath-vs-realpath
101
101
  * (macOS /var → /private/var would otherwise defeat a correct path). */
102
- function transcriptPathAllowed(realPath, homeDir) {
102
+ function transcriptPathAllowed(realPath, homeDir, opts = {}) {
103
+ const windows = (opts.platform || process.platform) === 'win32';
104
+ const normalize = value => windows ? value.replace(/\\/g, '/').toLowerCase() : value;
105
+ const realpath = opts.realpathSync || (windows ? fs.realpathSync.native : fs.realpathSync);
106
+ const candidate = normalize(realPath);
103
107
  if (!TRANSCRIPT_EXTS.includes(path.extname(realPath).toLowerCase())) return false;
104
108
  return transcriptRoots(homeDir).some((root) => {
105
109
  let realRoot = root;
106
- try { realRoot = fs.realpathSync(root); } catch { /* root may not exist yet */ }
107
- return realPath === realRoot || realPath.startsWith(realRoot + path.sep);
110
+ try { realRoot = realpath(root); } catch { /* root may not exist yet */ }
111
+ realRoot = normalize(realRoot);
112
+ return candidate === realRoot || candidate.startsWith(realRoot + (windows ? '/' : path.sep));
108
113
  });
109
114
  }
110
115
 
@@ -146,9 +151,13 @@ function readStdin() {
146
151
  });
147
152
  }
148
153
 
154
+ function captureHome(platform = process.platform, env = process.env, homedir = os.homedir) {
155
+ return platform === 'win32' ? homedir() : env.HOME || homedir();
156
+ }
157
+
149
158
  async function main() {
150
159
  const args = parseArgs(process.argv);
151
- const homeDir = args.home || process.env.HOME || os.homedir();
160
+ const homeDir = args.home || captureHome();
152
161
  const auxiloDir = path.join(homeDir, '.auxilo');
153
162
 
154
163
  // 1. Consent sentinel — kill-switch shared by every capture class (UC §6).
@@ -167,7 +176,7 @@ async function main() {
167
176
  // GOV-3 M4: resolve symlinks BEFORE validating, and use the resolved path
168
177
  // from here on — a symlink under an allowed root must not reach outside it.
169
178
  let transcriptPath;
170
- try { transcriptPath = fs.realpathSync(namedPath); } catch { return; }
179
+ try { transcriptPath = (process.platform === 'win32' ? fs.realpathSync.native : fs.realpathSync)(namedPath); } catch { return; }
171
180
  if (!transcriptPathAllowed(transcriptPath, homeDir)) return;
172
181
 
173
182
  let stat;
@@ -193,7 +202,7 @@ async function main() {
193
202
 
194
203
  const child = spawn(process.execPath,
195
204
  [runnerPath, '--transcript', transcriptPath, '--source', args.source],
196
- { detached: true, stdio: ['ignore', 'ignore', stderrFd], env });
205
+ { detached: true, stdio: ['ignore', 'ignore', stderrFd], env, ...(process.platform === 'win32' ? { windowsHide: true } : {}) });
197
206
  child.on('error', () => { /* fail-silent */ });
198
207
  child.unref();
199
208
 
@@ -204,6 +213,7 @@ async function main() {
204
213
 
205
214
  module.exports = {
206
215
  parseArgs,
216
+ captureHome,
207
217
  extractTranscriptPath,
208
218
  transcriptPathAllowed,
209
219
  transcriptRoots,
@@ -314,11 +314,33 @@ function baseUrlFor(vendor, configured) {
314
314
  * than silently trusting a permission state it could not verify. Never
315
315
  * throws.
316
316
  */
317
+ function providersFileInsideProfile(target, opts = {}) {
318
+ const profile = opts.homeDir || (opts.env || process.env).USERPROFILE || os.homedir();
319
+ const realpath = opts.realpathSyncImpl || fs.realpathSync.native;
320
+ try {
321
+ let current = target;
322
+ const suffix = [];
323
+ let realTarget;
324
+ for (;;) {
325
+ try { realTarget = path.join(realpath(current), ...suffix); break; }
326
+ catch (error) {
327
+ if (error.code !== 'ENOENT' || path.dirname(current) === current) return false;
328
+ suffix.unshift(path.basename(current));
329
+ current = path.dirname(current);
330
+ }
331
+ }
332
+ const root = realpath(profile).replace(/\\/g, '/').replace(/\/$/, '').toLowerCase();
333
+ const file = realTarget.replace(/\\/g, '/').toLowerCase();
334
+ return file.startsWith(root + '/');
335
+ } catch { return false; }
336
+ }
337
+
317
338
  function isProvidersFileModeUnsafe(opts = {}) {
318
339
  // EXTRACTION-LOW-FOLLOWUPS item 2 (TOCTOU, accepted on the record): a
319
340
  // window exists between this check and readByoConfig()'s read below; only the same uid could win that race, and that uid already owns the key on disk, so it is accepted rather than replaced with an fd-based check-then-read.
320
341
  const target = statePath(opts);
321
342
  if (isHomeUnresolved(target)) return true; // can't even name the file — fail closed
343
+ if ((opts.platform || process.platform) === 'win32') return !providersFileInsideProfile(target, opts);
322
344
  const statSyncImpl = typeof opts.statSyncImpl === 'function' ? opts.statSyncImpl : fs.statSync;
323
345
  let stat;
324
346
  try {
@@ -551,7 +573,7 @@ async function runModel(opts = {}) {
551
573
  ok: false,
552
574
  text: '',
553
575
  usage: null,
554
- reason: '~/.auxilo/providers.json is not owner-read-only; refusing to use the stored key until its permissions are fixed (chmod 600 ~/.auxilo/providers.json) or the file is removed (`auxilo provider clear`)',
576
+ reason: (opts.platform || process.platform) === 'win32' ? 'The provider key file must be inside your Windows user profile folder.' : '~/.auxilo/providers.json is not owner-read-only; refusing to use the stored key until its permissions are fixed (chmod 600 ~/.auxilo/providers.json) or the file is removed (`auxilo provider clear`)',
555
577
  reasonCode: 'providers-file-mode-unsafe',
556
578
  refusal: 'pre-invocation',
557
579
  authStatus: 'unknown',
@@ -712,6 +734,7 @@ module.exports = {
712
734
  // isProvidersFileModeUnsafe before trusting a persisted `selected` value
713
735
  // it reads (GOV-3 item 2, "read too, not just write").
714
736
  isProvidersFileModeUnsafe,
737
+ providersFileInsideProfile,
715
738
  isHomeUnresolved,
716
739
  isBaseUrlInsecure,
717
740
  writeProvidersStateAtomic,
@@ -41,7 +41,13 @@ function resolveClaudeBin(opts = {}) {
41
41
  const homeDir = typeof opts.homeDir === 'string' ? opts.homeDir : os.homedir();
42
42
  const existsSync = typeof opts.existsSync === 'function' ? opts.existsSync : fs.existsSync;
43
43
  const readFileSyncImpl = typeof opts.readFileSyncImpl === 'function' ? opts.readFileSyncImpl : fs.readFileSync;
44
- const candidates = [
44
+ const windows = (opts.platform || process.platform) === 'win32';
45
+ const env = opts.env || process.env;
46
+ const candidates = windows ? [
47
+ path.join(homeDir, '.local', 'bin', 'claude.exe'),
48
+ path.join(env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), 'npm', 'claude.cmd'),
49
+ ...(env.PATH || '').split(';').filter(Boolean).flatMap(dir => (env.PATHEXT || '.EXE;.CMD').split(';').filter(Boolean).map(ext => path.join(dir, 'claude' + ext.toLowerCase()))),
50
+ ] : [
45
51
  path.join(homeDir, '.claude', 'local', 'claude'),
46
52
  '/usr/local/bin/claude',
47
53
  '/opt/homebrew/bin/claude',
@@ -52,7 +58,7 @@ function resolveClaudeBin(opts = {}) {
52
58
  try {
53
59
  const config = JSON.parse(readFileSyncImpl(path.join(homeDir, '.auxilo', 'runner-config.json'), 'utf8'));
54
60
  const recorded = config && config.claude_bin;
55
- if (typeof recorded === 'string' && path.isAbsolute(recorded) && path.basename(recorded) === 'claude') {
61
+ if (typeof recorded === 'string' && path.isAbsolute(recorded) && (windows ? /^claude\.(exe|cmd)$/i.test(path.basename(recorded)) : path.basename(recorded) === 'claude')) {
56
62
  candidates.unshift(recorded);
57
63
  }
58
64
  } catch (_) { /* missing/malformed config means no recorded candidate */ }
@@ -75,6 +81,14 @@ function resolveClaudeBin(opts = {}) {
75
81
  return newest || firstExisting || 'claude';
76
82
  }
77
83
 
84
+ /** Resolve npm command shims without invoking a shell or the shim itself. */
85
+ function claudeSpawnTarget(bin, opts = {}) {
86
+ if ((opts.platform || process.platform) !== 'win32' || !/\.cmd$/i.test(bin)) return { command: bin, prefix: [] };
87
+ const cli = path.join(path.dirname(bin), 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
88
+ const exists = opts.existsSync || fs.existsSync;
89
+ return exists(cli) ? { command: process.execPath, prefix: [cli] } : null;
90
+ }
91
+
78
92
  // ─── Child settings/hooks isolation (EXTRACTION-CHILD-HOOKS, PUNCH-LIST P1,
79
93
  // 0.9.15) ────────────────────────────────────────────────────────────────
80
94
  //
@@ -154,7 +168,9 @@ function getClaudeCliVersion(bin, opts = {}) {
154
168
  const realpathSyncImpl = typeof opts.realpathSyncImpl === 'function' ? opts.realpathSyncImpl : fs.realpathSync;
155
169
  const readFileSyncImpl = typeof opts.readFileSyncImpl === 'function' ? opts.readFileSyncImpl : fs.readFileSync;
156
170
  try {
157
- const real = realpathSyncImpl(bin);
171
+ const target = claudeSpawnTarget(bin, opts);
172
+ if (!target) return null;
173
+ const real = realpathSyncImpl(target.prefix[0] || target.command);
158
174
  const dir = path.dirname(real);
159
175
  for (const [pkgDir, requireName] of [[dir, false], [path.dirname(dir), true]]) {
160
176
  try {
@@ -402,12 +418,15 @@ function detectBillingHelperConfigured(opts = {}) {
402
418
  function checkAuthStatus(opts = {}) {
403
419
  const spawnSyncImpl = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
404
420
  const bin = typeof opts.claudeBin === 'string' ? opts.claudeBin : resolveClaudeBin(opts);
421
+ const target = claudeSpawnTarget(bin, opts);
422
+ if (!target) return 'unknown';
405
423
  const version = versionParts(getClaudeCliVersion(bin, opts));
406
424
  // Unknown and old versions can interpret this probe as a model prompt.
407
425
  if (!version || compareVersions(version, [2, 1, 41]) < 0) return 'unknown';
408
426
  let res;
409
427
  try {
410
- res = spawnSyncImpl(bin, ['auth', 'status'], {
428
+ res = spawnSyncImpl(target.command, [...target.prefix, 'auth', 'status'], {
429
+ ...((opts.platform || process.platform) === 'win32' ? { windowsHide: true } : {}),
411
430
  encoding: 'utf-8',
412
431
  env: claudeChildEnv(),
413
432
  timeout: 5000,
@@ -446,6 +465,7 @@ function checkAuthStatus(opts = {}) {
446
465
  function detect(opts = {}) {
447
466
  if (detectBillingHelperConfigured(opts)) return false;
448
467
  const bin = resolveClaudeBin(opts);
468
+ if (!claudeSpawnTarget(bin, opts)) return false;
449
469
  const status = checkAuthStatus({ ...opts, claudeBin: bin });
450
470
  return status === 'logged-in' || status === 'unknown';
451
471
  }
@@ -516,9 +536,15 @@ function wrapperIdentity(wrapper, cliVersion) {
516
536
  ...(observed === null && { identity_unresolved: models.length ? 'ambiguous' : 'missing' }) };
517
537
  }
518
538
 
539
+ // Exact production CLI wording (verified 2026-09-27..10-01). Add another alternative only for
540
+ // another CONFIRMED real CLI message, never a guessed synonym.
541
+ const OAUTH_EXPIRED_PATTERN = /Failed to authenticate:\s*OAuth session expired and could not be refreshed/i;
542
+
519
543
  function runCliMode(opts, mode) {
520
544
  const spawn = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
521
545
  const bin = typeof opts.claudeBin === 'string' ? opts.claudeBin : resolveClaudeBin(opts);
546
+ const target = claudeSpawnTarget(bin, opts);
547
+ if (!target) return { ok: false, text: '', usage: null, reasonCode: 'cli-not-installed', reason: 'local model invocation failed', refusal: 'pre-invocation', authStatus: 'unknown' };
522
548
  if (cachedSettingSourcesUnsupported) return { ...settingSourcesIsolationUnsupportedResult('unknown'), refusal: 'pre-invocation' };
523
549
  const authStatus = mode === 'extract' ? checkAuthStatus({ ...opts, claudeBin: bin }) : 'unknown';
524
550
  const authReason = 'local model not authenticated in this context (run `claude auth login` once); skipping deterministic extraction';
@@ -532,7 +558,8 @@ function runCliMode(opts, mode) {
532
558
  if (gate) return { ...gate, ...meta };
533
559
  let res;
534
560
  try {
535
- res = spawn(bin, argv, {
561
+ res = spawn(target.command, [...target.prefix, ...argv], {
562
+ ...((opts.platform || process.platform) === 'win32' ? { windowsHide: true } : {}),
536
563
  input: (typeof opts.prompt === 'string' ? opts.prompt : '') + (mode === 'extract' ? String(opts.input || '').slice(0, 200000) : ''),
537
564
  encoding: 'utf-8', env: claudeChildEnv(), timeout: opts.timeoutMs || 120000, maxBuffer: 20 * 1024 * 1024,
538
565
  });
@@ -552,6 +579,14 @@ function runCliMode(opts, mode) {
552
579
  if ((!decoded.wrapper && authPattern.test(stdout)) || authPattern.test(String(res.stderr || ''))) {
553
580
  return { ...fail('cli-unauthenticated', authReason), ...(authStatus === 'logged-in' && { authDiscrepancy: true }) };
554
581
  }
582
+ // EXT-0806c: an expired, unrefreshable OAuth session exits nonzero with this exact CLI
583
+ // message on either stream. Nonzero exit only, and stdout only when no result wrapper
584
+ // decoded, so a successful run whose own text discusses an auth bug never matches. Like
585
+ // the check above it is a post-spawn outcome: no `refusal`, so it never permits fallback.
586
+ if (Number.isInteger(res.status) && res.status !== 0
587
+ && ((!decoded.wrapper && OAUTH_EXPIRED_PATTERN.test(stdout)) || OAUTH_EXPIRED_PATTERN.test(String(res.stderr || '')))) {
588
+ return { ...fail('cli-unauthenticated', authReason), ...(authStatus === 'logged-in' && { authDiscrepancy: true }) };
589
+ }
555
590
  if (res.status !== 0) return fail('model-error', 'local model exited unsuccessfully');
556
591
  if (decoded.malformed) return fail('model-error', mode === 'judge' ? 'local judge returned malformed JSON wrapper' : 'local model returned malformed JSON wrapper');
557
592
  if (decoded.unsuccessful) return fail('model-error', 'local model returned no successful result');
@@ -654,6 +689,7 @@ module.exports = {
654
689
  extractWithClaudeCode,
655
690
  checkClaudeAuthStatus,
656
691
  resolveClaudeBin,
692
+ claudeSpawnTarget,
657
693
  // Exported for direct unit coverage (test/claude-code-provider.test.js) and for
658
694
  // bin/auxilo-cli.js's cmdStatus provider line.
659
695
  claudeChildEnv,
@@ -676,4 +712,5 @@ module.exports = {
676
712
  getClaudeCliVersion,
677
713
  looksLikeUnsupportedSettingSourcesFlag,
678
714
  _resetSettingSourcesCacheForTests,
715
+ OAUTH_EXPIRED_PATTERN,
679
716
  };
@@ -366,6 +366,7 @@ async function runModel(opts = {}) {
366
366
  const order = [record.route, ...AUTOMATIC_PROVIDER_ORDER.filter(id =>
367
367
  (id !== record.route || record.route === 'byo-key') && (id !== 'byo-key' || configuredByo))];
368
368
  const attempts = [];
369
+ let firstFailure = null;
369
370
  for (const id of order) {
370
371
  if (id !== store.record.route) {
371
372
  const updated = store.mutate(routeMetadata(id, opts, 'auto'));
@@ -394,6 +395,7 @@ async function runModel(opts = {}) {
394
395
  if (standalone) result = { ...result, ...finishJob(context) };
395
396
  return result;
396
397
  }
398
+ if (!attempts.length) firstFailure = result;
397
399
  attempts.push(id + '=' + (NON_RETRYABLE_FOR_THIS_PROVIDER.has(result.reasonCode) ? result.reasonCode : 'unavailable'));
398
400
  if (result.refusal !== 'pre-invocation' || !store.canFallback()) {
399
401
  const release = store.hold('pinned-route-unusable');
@@ -405,7 +407,13 @@ async function runModel(opts = {}) {
405
407
  const released = store.hold('pinned-route-unusable');
406
408
  context.hold = released.hold || 'pinned-route-unusable';
407
409
  if (!configuredByo && !attempts.some(attempt => attempt.startsWith('byo-key='))) attempts.push('byo-key=provider-not-configured');
408
- return { ok: false, text: '', usage: null, authStatus: 'unknown', reasonCode: 'no-usable-provider',
410
+ // EXT-0806c: a generic 'no-usable-provider' reads as UNKNOWN downstream and never alerts. When the
411
+ // first-choice provider was refused for an unauthenticated CLI, the chain already knows the
412
+ // actionable cause, so surface it; the attempts list stays in `reason`. Read from the first
413
+ // attempt's result object, never from the attempts strings.
414
+ const unauthenticated = Boolean(firstFailure) && firstFailure.reasonCode === 'cli-unauthenticated';
415
+ return { ok: false, text: '', usage: null, authStatus: unauthenticated ? 'logged-out' : 'unknown',
416
+ reasonCode: unauthenticated ? 'cli-unauthenticated' : 'no-usable-provider',
409
417
  reason: 'no usable extraction provider (tried: ' + attempts.join('; ') + ')', hold: context.hold };
410
418
  }
411
419
 
@@ -90,7 +90,7 @@ function writeState(homeDir, now = Date.now()) {
90
90
 
91
91
  /** The one line. Count only — see the header contract. */
92
92
  function renderNotice(count) {
93
- return `Auxilo: ${count} learning(s) held for your review — run auxilo_review (MCP) or \`npx auxilo review\`.`;
93
+ return `Auxilo: ${count} ${count === 1 ? 'learning is' : 'learnings are'} waiting for your review. Run auxilo_review (MCP) or \`npx auxilo review\`.`;
94
94
  }
95
95
 
96
96
  /**
package/scripts/runner.js CHANGED
@@ -364,10 +364,11 @@ async function finalizeExtractionRun(outcomes, opts = {}) {
364
364
  : loadExtractionSkipState(opts);
365
365
  if (rows.length === 0) return state;
366
366
 
367
- // A completed real result or a concrete model/API failure proves the local
368
- // model was attempted. Either resets both visibility streaks.
369
- const resetsAuthStreak = rows.some((row) => !isSkippedExtraction(row)
370
- || row.reasonCode === 'model-error');
367
+ // Only a completed real extraction (a row that is not a skipped extraction)
368
+ // resets both visibility streaks. A 'model-error' skip is counted as an
369
+ // unknown skip, never a reset: an expired-OAuth outage once classified as
370
+ // model-error reset its own alarm on every sweep (EXT-0806c).
371
+ const resetsAuthStreak = rows.some((row) => !isSkippedExtraction(row));
371
372
  if (resetsAuthStreak) {
372
373
  const reset = {
373
374
  ...zeroExtractionSkipState(),
@@ -383,8 +384,7 @@ async function finalizeExtractionRun(outcomes, opts = {}) {
383
384
  const hasUnauthenticated = rows.some((row) => isSkippedExtraction(row)
384
385
  && row.reasonCode === 'cli-unauthenticated');
385
386
  if (!hasUnauthenticated) {
386
- const hasUnknown = rows.some((row) => isSkippedExtraction(row)
387
- && row.reasonCode !== 'model-error');
387
+ const hasUnknown = rows.some((row) => isSkippedExtraction(row));
388
388
  if (!hasUnknown) return state;
389
389
  const unknown = {
390
390
  ...state,
@@ -764,17 +764,21 @@ function renderExtractionResult(detailed, opts = {}) {
764
764
  * swallowed; a broken notifier must never fail the run. AUXILO_NO_NOTIFY=1
765
765
  * disables (tests, headless CI).
766
766
  */
767
- function notifyHeld(count) {
767
+ function notifyHeld(count, opts = {}) {
768
768
  try {
769
- if (!count || count <= 0) return;
770
- if (process.platform !== 'darwin') return;
771
- if (process.env.AUXILO_NO_NOTIFY === '1') return;
772
- const msg = `${count} learning(s) held for your review — run npx auxilo review`;
773
- const child = spawn('/usr/bin/osascript', [
774
- '-e', `display notification ${JSON.stringify(msg)} with title "Auxilo"`,
775
- ], { stdio: 'ignore', detached: true });
776
- child.unref();
769
+ if (!Number.isSafeInteger(count) || count <= 0) return;
770
+ const platform = opts.platform || process.platform;
771
+ if (!['darwin', 'win32'].includes(platform)) return;
772
+ if ((opts.env || process.env).AUXILO_NO_NOTIFY === '1') return;
773
+ const msg = `${count} ${count === 1 ? 'learning is' : 'learnings are'} waiting for your review. Run npx auxilo review`;
774
+ const spawnImpl = opts.spawnImpl || spawn;
775
+ const child = platform === 'win32'
776
+ ? spawnImpl('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
777
+ `Add-Type -AssemblyName System.Windows.Forms; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(10000, 'Auxilo', '${msg}', [System.Windows.Forms.ToolTipIcon]::Info); Start-Sleep -Seconds 10; $n.Dispose()`],
778
+ { stdio: 'ignore', detached: true, windowsHide: true })
779
+ : spawnImpl('/usr/bin/osascript', ['-e', `display notification ${JSON.stringify(msg)} with title "Auxilo"`], { stdio: 'ignore', detached: true });
777
780
  child.on('error', () => { /* fail-silent */ });
781
+ child.unref();
778
782
  } catch { /* fail-silent */ }
779
783
  }
780
784
 
@@ -1066,6 +1070,8 @@ function installDigest() {
1066
1070
 
1067
1071
  // ─── Status (B14) ───────────────────────────────────────────────────────────
1068
1072
 
1073
+ const EXTRACTION_STALE_MS = 48 * 60 * 60 * 1000;
1074
+
1069
1075
  function renderExtractionStatus(opts = {}) {
1070
1076
  const state = normalizeExtractionSkipState(opts.state) || zeroExtractionSkipState();
1071
1077
  const ledger = opts.ledger && typeof opts.ledger === 'object' ? opts.ledger : {};
@@ -1086,19 +1092,28 @@ function renderExtractionStatus(opts = {}) {
1086
1092
 
1087
1093
  const timestamp = ledger.lastRealExtractionAt;
1088
1094
  if (typeof timestamp === 'string' && isCanonicalIsoTimestamp(timestamp)) {
1095
+ const rawNow = typeof opts.now === 'function' ? opts.now() : opts.now;
1096
+ const nowMs = rawNow === undefined ? Date.now()
1097
+ : rawNow instanceof Date ? rawNow.getTime()
1098
+ : typeof rawNow === 'string' ? Date.parse(rawNow) : Number(rawNow);
1099
+ const ageMs = nowMs - Date.parse(timestamp);
1100
+ if (Number.isFinite(ageMs) && ageMs > EXTRACTION_STALE_MS) {
1101
+ return `Extraction: STALE (no real extraction for ${Math.floor(ageMs / 3600000)}h, last ${timestamp}). ` +
1102
+ 'Run `claude auth login` and check extract.log';
1103
+ }
1089
1104
  return `Extraction: OK (last real extraction ${timestamp})`;
1090
1105
  }
1091
1106
  return 'Extraction: OK (last real extraction unavailable)';
1092
1107
  }
1093
1108
 
1094
- function currentExtractionStatusLine(ledger) {
1109
+ function currentExtractionStatusLine(ledger, now) {
1095
1110
  const state = loadExtractionSkipState();
1096
1111
  let authStatus = 'unknown';
1097
1112
  try {
1098
1113
  const { checkClaudeAuthStatus } = require('./extract-local.js');
1099
1114
  authStatus = checkClaudeAuthStatus();
1100
1115
  } catch { /* missing/broken local CLI state is UNKNOWN, never authenticated */ }
1101
- return renderExtractionStatus({ state, ledger, authStatus });
1116
+ return renderExtractionStatus({ state, ledger, authStatus, now });
1102
1117
  }
1103
1118
 
1104
1119
  async function printStatus() {
@@ -1146,7 +1161,7 @@ async function printStatus() {
1146
1161
  console.log(`Pending queue: ${pendingCount} file(s)`);
1147
1162
 
1148
1163
  // 7. Extraction health — durable skip state plus the authoritative CLI auth probe.
1149
- console.log(currentExtractionStatusLine(ledger));
1164
+ console.log(currentExtractionStatusLine(ledger, Date.now()));
1150
1165
  }
1151
1166
 
1152
1167
  // ─── Scrub + Verify ─────────────────────────────────────────────────────────