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 +27 -8
- package/lib/hook-status.js +15 -3
- package/lib/installer.js +180 -53
- package/lib/review.js +4 -4
- package/lib/runner-autoupdate.js +1 -1
- package/mcp-server.js +2 -1
- package/package.json +1 -1
- package/scripts/capture-core.js +16 -6
- package/scripts/providers/byo-key.js +24 -1
- package/scripts/providers/claude-code.js +42 -5
- package/scripts/providers/index.js +9 -1
- package/scripts/review-notice.js +1 -1
- package/scripts/runner.js +33 -18
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(
|
|
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,
|
package/lib/hook-status.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
239
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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 =
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
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 =
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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
|
|
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
|
|
2248
|
-
const
|
|
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 = {};
|
package/lib/runner-autoupdate.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|
package/scripts/capture-core.js
CHANGED
|
@@ -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 =
|
|
107
|
-
|
|
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 ||
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
package/scripts/review-notice.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
771
|
-
if (
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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 ─────────────────────────────────────────────────────────
|