auxilo-mcp 0.9.12 → 0.9.13

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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Your agent already solved this. It found the fix, shipped, and lost it when the session ended. Next run it hits the same wall and burns the time and tokens you already paid for, while you sit and watch. Auxilo stops that. Your agent stops solving the same problem twice.
8
8
 
9
- Auxilo is an MCP server that auto-extracts operational learnings from your coding agent's sessions, gives your agent its own learnings back in every later session, and lists them in a marketplace where other agents pay to unlock them. Your agent's own learnings always come back at $0. When another agent unlocks what yours figured out, you earn.
9
+ Auxilo is an MCP server that auto-extracts operational learnings from your coding agent's sessions, gives your agent its own learnings back in every later session, and lists them in a marketplace where other agents pay to unlock them. Your account's own learnings come back at $0 to any agent signed in with its API key. A wallet-only x402 unlock is charged normally, even for your own learning. When another agent unlocks what yours figured out, you earn.
10
10
 
11
11
  ## The problem
12
12
 
@@ -22,7 +22,7 @@ And this is different from memory tools: mem0 is a memory you build. Auxilo is a
22
22
  npx auxilo setup
23
23
  ```
24
24
 
25
- One command. It finds your installed MCP clients, registers the server in each, and signs you in with a device code. At the end it asks whether to enable background extraction. That prompt defaults to no. Decline and you still have every marketplace tool; extraction stays off until you opt in.
25
+ One command. It finds your installed MCP clients, registers the server in each, and signs you in with a device code. At the end it asks whether to enable background extraction. That prompt defaults to no. Decline and you still have every Auxilo tool; extraction stays off until you opt in.
26
26
 
27
27
  Then ask your agent: "Search Auxilo for Firecrawl rate limit learnings" or "Contribute what we just figured out to Auxilo."
28
28
 
@@ -101,8 +101,8 @@ The same block works anywhere MCP configs are read. The installer also detects C
101
101
 
102
102
  | Tool | What it does | Cost |
103
103
  |---|---|---|
104
- | `auxilo_knowledge` | Search marketplace learnings; returns snippets and unlock prices | Free |
105
- | `auxilo_unlock` | Read a learning's full content | $0.05 to $50, set per learning; your own learnings $0 |
104
+ | `auxilo_knowledge` | Search Auxilo marketplace learnings; returns snippets and unlock prices | Free |
105
+ | `auxilo_unlock` | Read a learning's full content | between $0.05 and $50, set per learning; your own learnings $0 with an API key |
106
106
  | `auxilo_contribute` | Submit a learning from the current session | Free |
107
107
  | `auxilo_review` | List, approve, or reject your own pending-review learnings | Free |
108
108
  | `auxilo_rate` | Rate a learning 1 to 5 after applying it | Free |
@@ -110,7 +110,7 @@ The same block works anywhere MCP configs are read. The installer also detects C
110
110
  | `auxilo_skill` | Connection details, auth, and pricing for one skill | Free |
111
111
  | `auxilo_categories` | List categories with counts | Free |
112
112
  | `auxilo_stats` | Registry statistics | Free |
113
- | `get_knowledge_stats` | Marketplace statistics | Free |
113
+ | `get_knowledge_stats` | Auxilo statistics | Free |
114
114
  | `auxilo_contributor` | Earnings for a contributor wallet | Free |
115
115
  | `auxilo_account_earnings` | Earnings and pending balance for your account | Free |
116
116
  | `auxilo_verify_wallet` | Prove control of a wallet by signing a challenge | Free |
@@ -123,7 +123,7 @@ The same block works anywhere MCP configs are read. The installer also detects C
123
123
 
124
124
  - Search is free.
125
125
  - Contributing is free.
126
- - Self-unlocks are $0: your agent's own learnings come back free, in any later session.
126
+ - Self-unlocks are $0 with an API key. Your account's own learnings come back free to any agent signed in to it, in any later session. A wallet-only x402 unlock is charged normally, even for your own learning.
127
127
  - Unlocking another agent's learning costs $0.05 to $50. The contributor sets the price.
128
128
  - Contributor split: 70% on direct unlocks, 60% when Auxilo discovery surfaced the learning.
129
129
 
@@ -131,14 +131,14 @@ The same block works anywhere MCP configs are read. The installer also detects C
131
131
 
132
132
  Learnings you approve are listed at their unlock price. When another agent unlocks one directly, 70% of the price is yours; when discovery surfaced it, 60%. Earnings accrue from the first unlock. Withdrawals open soon.
133
133
 
134
- Check your balance with `auxilo_account_earnings` or the account dashboard at [auxilo.io](https://auxilo.io). Live marketplace numbers: [auxilo.io/knowledge/stats](https://auxilo.io/knowledge/stats).
134
+ Check your balance with `auxilo_account_earnings` or the account dashboard at [auxilo.io](https://auxilo.io). Live Auxilo numbers: [auxilo.io/knowledge/stats](https://auxilo.io/knowledge/stats).
135
135
 
136
136
  ## HTTP API
137
137
 
138
138
  The MCP server fronts a plain HTTP API at `https://auxilo.io`. Same catalog, same prices.
139
139
 
140
140
  ```bash
141
- # marketplace stats, free
141
+ # Auxilo marketplace stats, free
142
142
  curl https://auxilo.io/knowledge/stats
143
143
 
144
144
  # search learnings, free
package/bin/auxilo-cli.js CHANGED
@@ -15,12 +15,15 @@
15
15
  * Supersedes `auxilo-mcp setup` and `auxilo-mcp login` (Change 3/4).
16
16
  */
17
17
 
18
+ const fs = require('fs');
18
19
  const os = require('os');
19
20
  const path = require('path');
20
21
  const readline = require('readline');
21
22
  const { exec } = require('child_process');
22
23
  const installer = require('../lib/installer.js');
23
24
  const review = require('../lib/review.js');
25
+ const providers = require('../scripts/providers/index.js');
26
+ const byoKeyProvider = require('../scripts/providers/byo-key.js');
24
27
 
25
28
  const HOME = os.homedir();
26
29
 
@@ -82,6 +85,50 @@ async function askYesNo(question, defaultYes = false) {
82
85
  return answer === 'y' || answer === 'yes';
83
86
  }
84
87
 
88
+ /**
89
+ * Hidden-input prompt (PART C, `auxilo provider set`'s key entry) — the
90
+ * typed key is never echoed to the terminal. Reuses the SAME shared
91
+ * readline interface / line-buffering scheme as `ask()` (LW-17: a second
92
+ * independent readline interface loses whatever the first already buffered
93
+ * on piped stdin), and suppresses only the per-keystroke echo `_writeToOutput`
94
+ * would otherwise perform — the question text itself is written directly,
95
+ * and the terminating newline is always passed through so the cursor
96
+ * advances normally. `_writeToOutput` is an internal Node readline hook (not
97
+ * a documented public API) — the same technique widely used before a
98
+ * dedicated password-prompt package existed; degrades safely to a normal
99
+ * (echoed) prompt on any Node build where the hook is absent.
100
+ */
101
+ function askHidden(question) {
102
+ const rl = getRl();
103
+ process.stdout.write(question);
104
+ let restore = () => {};
105
+ if (process.stdin.isTTY && typeof rl._writeToOutput === 'function') {
106
+ const original = rl._writeToOutput.bind(rl);
107
+ rl._writeToOutput = (stringToWrite) => {
108
+ if (stringToWrite === '\r\n' || stringToWrite === '\n' || stringToWrite === '\r') {
109
+ original(stringToWrite);
110
+ }
111
+ // else: swallow the echoed keystroke.
112
+ };
113
+ restore = () => { rl._writeToOutput = original; };
114
+ }
115
+ if (bufferedLines.length > 0) {
116
+ const line = bufferedLines.shift();
117
+ restore();
118
+ return Promise.resolve(line);
119
+ }
120
+ if (readlineEnded) {
121
+ restore();
122
+ return Promise.resolve('');
123
+ }
124
+ return new Promise((resolve) => {
125
+ lineWaiters.push((answer) => {
126
+ restore();
127
+ resolve(answer);
128
+ });
129
+ });
130
+ }
131
+
85
132
  function openBrowser(url) {
86
133
  const cmd = process.platform === 'darwin' ? 'open'
87
134
  : process.platform === 'win32' ? 'start ""'
@@ -119,21 +166,29 @@ const CONSENT_TEXT = `
119
166
  • READS the session transcript on your machine,
120
167
  • SCRUBS it locally (sensitivity filter: API keys, tokens, emails, PII
121
168
  are redacted first),
122
- • EXTRACTS reusable learnings locally using your own claude CLI (your
123
- existing subscription). For this step your transcript is processed
124
- only by your own model provider the same way your normal sessions
125
- are, and is never sent to Auxilo, raw or scrubbed.
169
+ • EXTRACTS reusable learnings locally through the first model client you
170
+ have installed (Claude Code, then Codex) or, when neither is
171
+ available, a provider key you set yourself. For this step your
172
+ scrubbed transcript goes only to that provider, under your own
173
+ account with them, and any use is charged to that account, never to
174
+ Auxilo. It is never sent to Auxilo, raw or scrubbed.
126
175
  • UPLOADS only the finished learning drafts (title, body, category,
127
- tags, task context, outcome) to Auxilo (${'POST /learn'}). A draft
128
- that passes every screen publishes to the marketplace immediately
129
- under your account, and you can retract it for 7 days. A draft that
130
- any screen flags (sensitive, duplicate, uncertain quality) waits in
131
- your private queue for \`auxilo review\`. Manual mode (approve first,
132
- for everything) is available in your account settings. You earn 70%
133
- of sales.
176
+ tags, task context, outcome) to Auxilo (${'POST /learn'}). Everything
177
+ waits in your review queue until you approve it, one learning at a
178
+ time or in advance in your dashboard. Your first public learning
179
+ waits for operator review. A draft that any screen flags (sensitive,
180
+ duplicate, uncertain quality) waits in your private queue for
181
+ \`auxilo review\`. Auto-publish for learnings that pass every screen is
182
+ off unless you turn it on in your dashboard. Your share of a paid
183
+ unlock by another agent goes to your Auxilo account, 70% of what they
184
+ paid on a direct unlock and 60% via discovery. A repeat unlock by the
185
+ same buyer within 30 days earns nothing. Earnings depend on whether
186
+ other agents unlock your learnings and are not guaranteed. Earnings
187
+ accrue now. Withdrawals open soon, and auxilo.io/status shows where
188
+ things stand.
134
189
  You can stop any time with \`auxilo disable\` (local kill-switch) and review
135
- every run in ~/.auxilo/extract.log. Saying No installs the MCP server only
136
- no session-end capture hook is written into any client config unless you say
190
+ every run in ~/.auxilo/extract.log. Saying No installs the MCP server only.
191
+ No session-end capture hook is written into any client config unless you say
137
192
  Yes, and any capture hooks left by an earlier install are removed.
138
193
  `;
139
194
 
@@ -497,6 +552,16 @@ async function cmdStatus() {
497
552
  const line = runnerSkewLine(installer.runnerVersionSkew(HOME));
498
553
  if (line) console.log(line);
499
554
  }
555
+ console.log(extractionProviderLine(await providers.resolveProvider({})));
556
+ // Lazy require: scripts/runner.js is a heavier module (sources, sensitivity
557
+ // filter, ops-alert) than this one status line needs at require-time for
558
+ // every CLI invocation.
559
+ let skipState = null;
560
+ try {
561
+ skipState = require('../scripts/runner.js').loadExtractionSkipState();
562
+ } catch { /* status must never throw on a missing/corrupt skip-state file */ }
563
+ const skipLine = extractionSkipReasonLine(skipState);
564
+ if (skipLine) console.log(skipLine);
500
565
  console.log(`SessionEnd hook: ${s.hookInstalled ? 'installed' : 'not installed'}${s.hookRegistered ? ', registered in Claude Code settings' : ''}`);
501
566
  for (const c of s.clients.filter((c) => c.captureHook)) {
502
567
  console.log(`Capture hooks: ${c.name} (${c.captureEvent}, ${c.captureRegistered ? 'registered' : 'not registered'})`);
@@ -516,6 +581,65 @@ function runnerSkewLine(skew) {
516
581
  return ` ⚠ Installed runner is ${installed} (package v${skew.package}) — run: npx auxilo setup`;
517
582
  }
518
583
 
584
+ /**
585
+ * Mirrors lib/clean-lane.js's CLEAN_LANE_CALIBRATED_PROVIDERS — that module
586
+ * is server-side and not in the published package's files[] (same reason
587
+ * CLEAN_LANE_AFFIRMATION below is a literal mirror, not an import; see
588
+ * test/clean-lane-phase-a.test.js's "the CLI must not require the unshipped
589
+ * server module" pin). test/clean-lane-calibration.test.js pins the two
590
+ * arrays equal.
591
+ */
592
+ const CLI_CLEAN_LANE_CALIBRATED_PROVIDERS = ['claude-code'];
593
+
594
+ /**
595
+ * EXTRACT-PER-CLIENT W1 PART A/C — one unconditional line naming which
596
+ * extraction model provider resolves, why (env override vs auto-detected),
597
+ * and (PART C) whether that provider's submissions can reach the clean-lane
598
+ * auto-publish path at all (server-side gate: lib/clean-lane.js's
599
+ * CLEAN_LANE_CALIBRATED_PROVIDERS, mirrored above).
600
+ */
601
+ function extractionProviderLine(resolution) {
602
+ if (resolution && resolution.ok) {
603
+ const via = process.env.AUXILO_EXTRACTION_PROVIDER
604
+ ? 'env override AUXILO_EXTRACTION_PROVIDER'
605
+ : 'auto-detected';
606
+ const calibration = CLI_CLEAN_LANE_CALIBRATED_PROVIDERS.includes(resolution.id)
607
+ ? 'clean-lane calibrated'
608
+ : 'review-lane only';
609
+ return `Extraction model provider: ${resolution.id} (${via}, ${calibration})`;
610
+ }
611
+ const reason = (resolution && resolution.reason) || 'no provider available';
612
+ return `Extraction model provider: none (${reason})`;
613
+ }
614
+
615
+ /**
616
+ * EXTRACT-PER-CLIENT W1 PART C — the companion conditional line PART A left
617
+ * unimplemented (see its report): printed ONLY when the last recorded
618
+ * extraction skip reasonCode (runner.js's normalizeExtractionSkipState,
619
+ * last_reason_code field, added in this part) is one of the names below;
620
+ * null (nothing printed) for every other state, including "no state file
621
+ * yet" and "last outcome was a real success."
622
+ *
623
+ * 'no-usable-provider' added in the W1 P1 fix (PUNCH-LIST): distinct from
624
+ * 'no-model-provider-available' (nothing even LOOKED usable at the detect()
625
+ * stage) — this is the selection-fall-through exhaustion code from
626
+ * scripts/providers/index.js's runModel(), where every provider in
627
+ * PROVIDER_ORDER was actually tried and each failed for its own reason.
628
+ */
629
+ const STATUS_WORTHY_SKIP_REASON_CODES = Object.freeze([
630
+ 'cli-billing-helper-configured',
631
+ 'cli-unauthenticated',
632
+ 'no-model-provider-available',
633
+ 'no-usable-provider',
634
+ ]);
635
+
636
+ /** Pure render, mirroring runnerSkewLine(skew) above — the caller loads the
637
+ * state; this only decides whether/what to print. */
638
+ function extractionSkipReasonLine(state) {
639
+ if (!state || !STATUS_WORTHY_SKIP_REASON_CODES.includes(state.last_reason_code)) return null;
640
+ return ` ⚠ Last extraction attempt: ${state.last_reason_code}`;
641
+ }
642
+
519
643
  // ─── auxilo disable ─────────────────────────────────────────────────────────
520
644
 
521
645
  async function cmdDisable(flags) {
@@ -893,6 +1017,23 @@ async function cmdReview(flags) {
893
1017
  // byte-equal. The consent VERSION is never a client literal — it always comes
894
1018
  // from GET /account/clean-lane (consent_version_current).
895
1019
  const CLEAN_LANE_AFFIRMATION = 'I understand and choose auto-publish for qualifying extracted learnings.';
1020
+
1021
+ /**
1022
+ * EXTRACT-PER-CLIENT W1 PART C — `auxilo provider set`'s consent sentence.
1023
+ * SITE-PM-authored, verbatim. States: this is the builder's OWN key; where
1024
+ * it lives on disk and at what permission (~/.auxilo/providers.json, owner-
1025
+ * read-only); that Auxilo never receives it; what it is used for (drafting
1026
+ * learnings from the builder's own scrubbed sessions); that drafting sends
1027
+ * sessions to the builder's chosen provider under the builder's own account,
1028
+ * billed to that account and never to Auxilo; and how to remove it
1029
+ * (`auxilo provider clear`). `cmdProvider('set')` refuses to run at all
1030
+ * (reasonCode 'consent-sentence-missing') were this ever empty again — see
1031
+ * the test asserting that refusal — and, before ever printing this sentence
1032
+ * or storing anything, verifies any existing providers.json is actually
1033
+ * owner-read-only (reasonCode 'providers-file-mode-unsafe' if not), so the
1034
+ * "readable only by your user account" claim below is never printed false.
1035
+ */
1036
+ 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.';
896
1037
  const CLEAN_LANE_UNAVAILABLE = 'Auto-publish for clean learnings is not yet available on this account.';
897
1038
  // CLEAN-LANE-FLIP Phase B (legal; DRAFT pending Tyler): the full text of ToS
898
1039
  // §5.9.3(g) (plus its ratchet paragraph) prints ABOVE the affirmation prompt —
@@ -1107,6 +1248,182 @@ async function cmdCleanLane(flags) {
1107
1248
  console.log(' Turn it off any time: npx auxilo clean-lane revoke');
1108
1249
  }
1109
1250
 
1251
+ // ─── auxilo provider (EXTRACT-PER-CLIENT W1 PART C: BYO provider key) ───────
1252
+ //
1253
+ // Mirrors cmdCleanLane's grant discipline exactly: `set` runs ONLY on a TTY,
1254
+ // refuses piped input, and requires typing the consent sentence verbatim —
1255
+ // no confirmation flag, no bypass. Unlike clean-lane, nothing here ever reaches
1256
+ // the Auxilo server: the key is the builder's own, read from a hidden
1257
+ // prompt (never argv, never env), and written straight to
1258
+ // ~/.auxilo/providers.json (0600) via scripts/providers/byo-key.js.
1259
+ //
1260
+ // PROVIDER_KEY_CONSENT_SENTENCE is a SITE-PM string slot (see the module
1261
+ // comment on its declaration below) — `set` refuses to run at all while it
1262
+ // is empty, with reasonCode 'consent-sentence-missing'. This keeps the path
1263
+ // disabled end-to-end until that copy is written, rather than shipping a
1264
+ // silent placeholder sentence nobody actually reviewed.
1265
+ const PROVIDER_VENDORS = ['openai', 'anthropic', 'gemini'];
1266
+
1267
+ /**
1268
+ * PROVIDER_KEY_CONSENT_SENTENCE promises the stored key is "readable only
1269
+ * by your user account." Before `set` ever prints that sentence, or stores
1270
+ * anything, verify the promise is actually true of any providers.json
1271
+ * already on disk. Owner-read-only means no group/other bits at all
1272
+ * (mode & 0o077 === 0); a file that fails this predates this discipline
1273
+ * (e.g. survived an umask that widened it) and must be fixed or removed
1274
+ * before `set` is allowed to run — never fail open on a false claim.
1275
+ * No file yet is not unsafe: writeByoConfig always writes 0600 itself.
1276
+ */
1277
+ /**
1278
+ * (EXTRACT-PER-CLIENT W1 FIX, GOV-3 should-fix item 10): a stat error other
1279
+ * than ENOENT (e.g. EACCES) now fails CLOSED — returns true (unsafe) — the
1280
+ * same "cannot verify, so don't trust it" discipline
1281
+ * byo-key.js's own isProvidersFileModeUnsafe documents and follows. The old
1282
+ * rethrow here let a raw fs error propagate out of cmdProvider uncaught,
1283
+ * printing whatever bubbled to run()'s generic catch instead of a clean
1284
+ * reason. Never throws now.
1285
+ */
1286
+ function providersFileModeUnsafe(target) {
1287
+ let stat;
1288
+ try {
1289
+ stat = fs.statSync(target);
1290
+ } catch (err) {
1291
+ if (err && err.code === 'ENOENT') return false;
1292
+ return true; // cannot verify permissions — fail closed, not open
1293
+ }
1294
+ return (stat.mode & 0o077) !== 0;
1295
+ }
1296
+
1297
+ async function cmdProvider(flags) {
1298
+ const sub = process.argv[3];
1299
+ if (!['status', 'set', 'clear'].includes(sub)) {
1300
+ if (sub) console.error(`Unknown provider subcommand: ${sub}`);
1301
+ usage('provider');
1302
+ process.exit(sub ? 1 : 0);
1303
+ }
1304
+
1305
+ if (sub === 'status') {
1306
+ const config = byoKeyProvider.readByoConfig();
1307
+ if (!config) {
1308
+ console.log('BYO provider key: none configured');
1309
+ return;
1310
+ }
1311
+ console.log(`BYO provider key: configured (vendor: ${config.provider}, model: ${config.model}, key: present)`);
1312
+ return;
1313
+ }
1314
+
1315
+ if (sub === 'clear') {
1316
+ // clearProvidersFile() never throws (GOV-3 should-fix item 10) — every
1317
+ // outcome is a plain string, handled explicitly below rather than
1318
+ // falling into the generic success message for a case that isn't one.
1319
+ const result = byoKeyProvider.clearProvidersFile();
1320
+ if (result === 'removed-file') {
1321
+ console.log('✓ ~/.auxilo/providers.json removed (nothing left to keep).');
1322
+ } else if (result === 'removed-byo') {
1323
+ console.log('✓ BYO provider key cleared. providers.json kept (your auto-detected provider selection, if any, is unchanged).');
1324
+ } else if (result === 'unreadable') {
1325
+ console.error('auxilo provider clear could not read ~/.auxilo/providers.json (reasonCode: providers-file-unreadable). Check its permissions and try again.');
1326
+ process.exit(1);
1327
+ } else if (result === 'unresolved') {
1328
+ console.error('auxilo provider clear could not resolve your home directory (reasonCode: provider-home-unresolved).');
1329
+ process.exit(1);
1330
+ } else {
1331
+ console.log('✓ Nothing to remove (no BYO provider key configured).');
1332
+ }
1333
+ return;
1334
+ }
1335
+
1336
+ // sub === 'set' below.
1337
+ if (PROVIDER_KEY_CONSENT_SENTENCE === '') {
1338
+ console.error('auxilo provider set is not available yet: the operator has not configured the consent sentence for this build (reasonCode: consent-sentence-missing).');
1339
+ process.exit(1);
1340
+ }
1341
+
1342
+ // Fail closed BEFORE printing the sentence or storing anything: an
1343
+ // existing providers.json that is not owner-read-only would make the
1344
+ // sentence's "readable only by your user account" claim false.
1345
+ if (providersFileModeUnsafe(byoKeyProvider.DEFAULT_PROVIDERS_STATE_PATH)) {
1346
+ 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.');
1347
+ process.exit(1);
1348
+ }
1349
+
1350
+ // The TTY gate runs BEFORE any prompt: a piped or scripted stdin can never
1351
+ // reach the hidden key prompt or the typed affirmation.
1352
+ if (!process.stdin.isTTY) {
1353
+ console.error('auxilo provider set must be run by a person in an interactive terminal. It does not accept piped input, and there is no flag that skips typing the consent sentence or the key.');
1354
+ process.exit(1);
1355
+ }
1356
+
1357
+ let vendor = String(flags.vendor || '').toLowerCase();
1358
+ while (!PROVIDER_VENDORS.includes(vendor)) {
1359
+ vendor = (await ask(`Vendor [${PROVIDER_VENDORS.join('|')}]: `)).toLowerCase();
1360
+ if (readlineEnded) { console.log('Aborted. Nothing changed.'); return; }
1361
+ }
1362
+
1363
+ // GOV-3 item 3: base_url must be https:// — a plaintext endpoint would
1364
+ // send the transcript, and for two of the three vendors the key itself,
1365
+ // in cleartext. Checked here (set time) AND again at read time inside
1366
+ // byo-key.js's runModel/detect (reasonCode provider-base-url-insecure) —
1367
+ // a hand-edited providers.json can't bypass either.
1368
+ let baseUrl = flags['base-url'] ? String(flags['base-url']) : '';
1369
+ if (baseUrl && byoKeyProvider.isBaseUrlInsecure(baseUrl)) {
1370
+ console.error(`auxilo provider set refuses --base-url "${baseUrl}": it must be https:// (reasonCode: provider-base-url-insecure).`);
1371
+ process.exit(1);
1372
+ }
1373
+ if (!flags['base-url']) {
1374
+ for (;;) {
1375
+ baseUrl = await ask(`Base URL (optional — press Enter for the ${vendor} default; must be https:// if given): `);
1376
+ if (readlineEnded) { console.log('Aborted. Nothing changed.'); return; }
1377
+ if (!baseUrl || !byoKeyProvider.isBaseUrlInsecure(baseUrl)) break;
1378
+ console.log(`"${baseUrl}" is not https:// — try again, or press Enter for the ${vendor} default.`);
1379
+ }
1380
+ }
1381
+
1382
+ let model = flags.model ? String(flags.model) : '';
1383
+ while (!model) {
1384
+ model = await ask('Model (e.g. gpt-4o-mini, claude-sonnet-4-5, gemini-2.5-flash): ');
1385
+ if (readlineEnded) { console.log('Aborted. Nothing changed.'); return; }
1386
+ }
1387
+
1388
+ // Printed in FULL, word-wrapped to the terminal width — never truncated —
1389
+ // before the (hidden) key prompt further below.
1390
+ console.log(`\n${wrapForTerminal(PROVIDER_KEY_CONSENT_SENTENCE)}\n`);
1391
+ console.log('To continue, type this sentence exactly as written, then press Enter:');
1392
+ console.log(`\n${wrapForTerminal(PROVIDER_KEY_CONSENT_SENTENCE)}\n`);
1393
+ const typed = await ask('> ');
1394
+ if (typed !== PROVIDER_KEY_CONSENT_SENTENCE) {
1395
+ console.log('The sentence did not match. Aborted. Nothing changed.');
1396
+ return;
1397
+ }
1398
+
1399
+ const apiKey = await askHidden('API key (input hidden): ');
1400
+ if (readlineEnded || !apiKey) {
1401
+ console.log('Aborted. Nothing changed.');
1402
+ return;
1403
+ }
1404
+
1405
+ // writeByoConfig throws ONLY on an unresolved home directory (GOV-3 item
1406
+ // 13) — caught here so that reaches a clean reason + exit(1), never a raw
1407
+ // stack (should-fix item 10), matching the fail-closed contract every
1408
+ // other providers.json entry point in this file now follows.
1409
+ let written;
1410
+ try {
1411
+ written = byoKeyProvider.writeByoConfig({
1412
+ provider: vendor,
1413
+ ...(baseUrl && { base_url: baseUrl }),
1414
+ model,
1415
+ api_key: apiKey,
1416
+ });
1417
+ } catch (err) {
1418
+ if (err && err.reasonCode === 'provider-home-unresolved') {
1419
+ console.error(`auxilo provider set could not resolve your home directory (reasonCode: provider-home-unresolved). ${err.message}`);
1420
+ process.exit(1);
1421
+ }
1422
+ throw err;
1423
+ }
1424
+ console.log(`\n✓ Saved to ${written} (mode 0600). This machine will use your own ${vendor} key for extraction when no earlier provider in the order is available.`);
1425
+ }
1426
+
1110
1427
  // ─── Entry point ────────────────────────────────────────────────────────────
1111
1428
 
1112
1429
  function usage(command) {
@@ -1162,6 +1479,19 @@ available on your account every subcommand says so and changes nothing.
1162
1479
  this for you.
1163
1480
  revoke Turn it off (one step, no confirmation). Already-published
1164
1481
  learnings keep their 7-day retraction window.`,
1482
+ provider: `Usage: auxilo provider <status|set|clear>
1483
+
1484
+ Configure a bring-your-own (BYO) model provider key for local extraction —
1485
+ used only when no earlier provider in the fixed order (claude-code,
1486
+ codex-cli) is available. Auxilo never sees or bills this key.
1487
+
1488
+ status Show the configured vendor and model (never the key itself).
1489
+ set Configure a vendor, model, and key. Interactive ONLY: the key is
1490
+ read from a hidden prompt (never a flag, never an env var), and
1491
+ you must type the consent sentence exactly. No flag skips this.
1492
+ clear Remove your BYO key. Keeps your auto-detected provider selection
1493
+ (\`selected\`), if any — only deletes the file outright when
1494
+ nothing but the key was in it.`,
1165
1495
  };
1166
1496
  if (command && blocks[command]) {
1167
1497
  console.log(`\n${blocks[command]}\n`);
@@ -1213,6 +1543,10 @@ Commands:
1213
1543
  clean-lane <status|grant|revoke>
1214
1544
  Auto-publish clean learnings (standing consent). grant is
1215
1545
  interactive only: you type the consent sentence yourself.
1546
+ provider <status|set|clear>
1547
+ Configure a bring-your-own model provider key for local
1548
+ extraction. set is interactive only: hidden key prompt, typed
1549
+ consent sentence.
1216
1550
 
1217
1551
  Docs: https://auxilo.io · API: ${installer.DEFAULT_BASE_URL}
1218
1552
  `);
@@ -1221,7 +1555,7 @@ Docs: https://auxilo.io · API: ${installer.DEFAULT_BASE_URL}
1221
1555
  async function main() {
1222
1556
  const cmd = process.argv[2];
1223
1557
  const subcommandHelp = ['help', '--help', '-h'].includes(process.argv[3]);
1224
- if (['setup', 'init', 'status', 'review', 'disable', 'clean-lane'].includes(cmd) && subcommandHelp) {
1558
+ if (['setup', 'init', 'status', 'review', 'disable', 'clean-lane', 'provider'].includes(cmd) && subcommandHelp) {
1225
1559
  return usage(cmd);
1226
1560
  }
1227
1561
  const flags = parseFlags(process.argv);
@@ -1232,6 +1566,7 @@ async function main() {
1232
1566
  case 'review': return cmdReview(flags);
1233
1567
  case 'disable': return cmdDisable(flags);
1234
1568
  case 'clean-lane': return cmdCleanLane(flags);
1569
+ case 'provider': return cmdProvider(flags);
1235
1570
  case 'help': case '--help': case '-h': case undefined: return usage();
1236
1571
  default:
1237
1572
  console.error(`Unknown command: ${cmd}`);
@@ -1260,6 +1595,7 @@ if (require.main === module) {
1260
1595
  module.exports = {
1261
1596
  parseFlags,
1262
1597
  runnerSkewLine,
1598
+ extractionProviderLine,
1263
1599
  resolveBaseUrl,
1264
1600
  shortFlags,
1265
1601
  groupSummaryRows,
@@ -1272,4 +1608,10 @@ module.exports = {
1272
1608
  CLEAN_LANE_TERMS_G2,
1273
1609
  CLEAN_LANE_NO_EMAIL_LINE,
1274
1610
  wrapForTerminal,
1611
+ PROVIDER_KEY_CONSENT_SENTENCE,
1612
+ CLI_CLEAN_LANE_CALIBRATED_PROVIDERS,
1613
+ STATUS_WORTHY_SKIP_REASON_CODES,
1614
+ extractionSkipReasonLine,
1615
+ cmdProvider,
1616
+ providersFileModeUnsafe,
1275
1617
  };
package/lib/installer.js CHANGED
@@ -55,6 +55,44 @@ function sourceAdapterRows(packageRoot = PACKAGE_ROOT) {
55
55
  }
56
56
  }
57
57
 
58
+ // EXTRACT-PER-CLIENT W1 PART A: extract-local.js now requires
59
+ // './providers/index.js', which requires './claude-code.js' (and will require
60
+ // './codex-cli.js' / './byo-key.js' once PART B/C land) — every file under
61
+ // scripts/providers/ must ship the same way scripts/sources/ does, or an
62
+ // npm-installed user's first extraction is a MODULE_NOT_FOUND
63
+ // (test/runner-packaging-closure.test.js guards this).
64
+ function providerAdapterRows(packageRoot = PACKAGE_ROOT) {
65
+ try {
66
+ return fs.readdirSync(path.join(packageRoot, 'scripts', 'providers'))
67
+ .filter((f) => f.endsWith('.js'))
68
+ .sort()
69
+ .map((f) => [`scripts/providers/${f}`, `scripts/providers/${f}`, 0o644]);
70
+ } catch {
71
+ return []; // missing dir surfaces as installRunner missing-file errors
72
+ }
73
+ }
74
+
75
+ // EXTRACT-PER-CLIENT W1 PART B: codex-cli.js reads its two JSON-Schema hint
76
+ // files at runtime via a path relative to itself (scripts/providers/schemas/
77
+ // *.schema.json), NOT via require() — providerAdapterRows() above only
78
+ // enumerates *.js in scripts/providers/ itself (non-recursive), so the
79
+ // schemas/ subdirectory would silently NOT reach an npm-installed user's
80
+ // ~/.auxilo/bin without its own enumeration here. installRunner copies files
81
+ // one at a time (no directory copy), so every schema file needs its own row,
82
+ // same as providerAdapterRows()'s .js rows — this is the "check whether it
83
+ // enumerates the directory or a list" case turning out to be "neither yet;
84
+ // add the enumeration."
85
+ function providerSchemaRows(packageRoot = PACKAGE_ROOT) {
86
+ try {
87
+ return fs.readdirSync(path.join(packageRoot, 'scripts', 'providers', 'schemas'))
88
+ .filter((f) => f.endsWith('.json'))
89
+ .sort()
90
+ .map((f) => [`scripts/providers/schemas/${f}`, `scripts/providers/schemas/${f}`, 0o644]);
91
+ } catch {
92
+ return []; // missing dir surfaces as installRunner missing-file errors
93
+ }
94
+ }
95
+
58
96
  /**
59
97
  * Runner stack shipped in the npm tarball and copied into <home>/.auxilo/bin,
60
98
  * preserving relative layout so runner.js's requires resolve
@@ -73,6 +111,8 @@ const RUNNER_STACK = Object.freeze([
73
111
  // LW-18 layer 1b: SessionStart held-count notice (shim target).
74
112
  ['scripts/review-notice.js', 'scripts/review-notice.js', 0o755],
75
113
  ...sourceAdapterRows(),
114
+ ...providerAdapterRows(),
115
+ ...providerSchemaRows(),
76
116
  ['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
77
117
  ['lib/extraction-index.js', 'lib/extraction-index.js', 0o644],
78
118
  ['lib/similarity.js', 'lib/similarity.js', 0o644],