amicus 1.7.4 → 1.7.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.4",
3
+ "version": "1.7.5",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": { "name": "Christian Wagner" },
6
6
  "homepage": "https://bourbondog.github.io/amicus/",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.5] - 2026-07-01
9
+
10
+ A batch of fixes from an independent DeepSeek V4 Pro code review, each verified against source.
11
+
12
+ ### Fixed
13
+ - **Long prompts no longer truncate on Windows.** The `amicus_start` and `amicus_continue` MCP
14
+ handlers passed the full prompt inline on the spawned command line, which silently truncated once
15
+ it crossed Windows's ~32 KB argument cap — so a sidecar could run against a corrupted briefing with
16
+ no error. Both paths now write the prompt to a `briefing.md` in the session directory and pass
17
+ `--prompt-file`, matching the existing fanout handler; the CLI `continue` command learned
18
+ `--prompt-file` as well.
19
+ - **`getMessages` no longer masks SDK error responses.** An error-shaped response with no `data`
20
+ array was indistinguishable from "zero messages" in the poll loop; it now logs a warning carrying
21
+ the session id and surfaced error while still returning `[]`.
22
+ - **`getSessionDir` rejects path-traversal task ids.** A defense-in-depth containment guard (the same
23
+ check style used elsewhere in the codebase) throws on a task id that would escape the sessions dir.
24
+ - **Cross-platform `auth.json` discovery.** The one-time OpenCode key-import path was hardcoded to the
25
+ Unix XDG location; it now probes `$XDG_DATA_HOME`, `~/.local/share`, and `%APPDATA%` (Windows) and
26
+ uses the first that exists.
27
+
28
+ ### Changed
29
+ - **The fold-completion marker is harder to spoof.** A bare `[SIDECAR_FOLD]` echoed mid-output (e.g. a
30
+ model reproducing these instructions or summarizing a prior sidecar session) no longer forces a
31
+ premature fold — the marker now completes a run only when it is the final non-empty line of output,
32
+ with the existing idle/timeout fallbacks unchanged so a run can never hang.
33
+ - **The conversation-mirror tool-call buffer is bounded.** Capped at 2000 entries with a separate
34
+ dedup set, so a very long tool-heavy session can't grow it without limit.
35
+ - **Unknown `--no-*` flags are treated as boolean.** They no longer swallow the following positional
36
+ argument (`--no-x=value` still records its inline value; allowlisted flags are unchanged).
37
+ - **`--prompt-file` validation is order-independent.** `validateStartArgs` now resolves the prompt
38
+ source itself, so validation no longer depends on the handler having resolved it first.
39
+
40
+ ### Docs
41
+ - **Corrected the `tiktoken` dependency note.** It is declared but unused; token sizing uses a
42
+ `length/4` heuristic. Added caveat comments at both estimators. (Removing the unused dependency is
43
+ tracked as a follow-up.)
44
+
8
45
  ## [1.7.4] - 2026-06-30
9
46
 
10
47
  ### Fixed
package/bin/amicus.js CHANGED
@@ -215,6 +215,19 @@ async function handleContinue(args) {
215
215
  process.exit(1);
216
216
  }
217
217
 
218
+ // BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
219
+ // follow-up prompt via file, dodging the ~32KB Windows command-line cap.
220
+ if (args['prompt-file'] !== undefined) {
221
+ const { resolvePromptSource } = require('../src/utils/prompt-source');
222
+ const promptRes = resolvePromptSource(args);
223
+ if (promptRes.error) {
224
+ console.error(promptRes.error);
225
+ process.exit(1);
226
+ }
227
+ args.prompt = promptRes.prompt;
228
+ delete args['prompt-file'];
229
+ }
230
+
218
231
  if (!args.prompt && !args.briefing) {
219
232
  console.error('Error: --prompt is required for continue');
220
233
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.4",
3
+ "version": "1.7.5",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "keywords": [
6
6
  "claude",
@@ -28,6 +28,10 @@ async function handleStart(args) {
28
28
  const promptRes = resolvePromptSource(args);
29
29
  if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
30
30
  args.prompt = promptRes.prompt;
31
+ // Drop --prompt-file now that it's resolved: validateStartArgs' self-contained
32
+ // guard would otherwise re-run resolvePromptSource with both prompt and
33
+ // prompt-file set and trip its mutually-exclusive branch.
34
+ delete args['prompt-file'];
31
35
  }
32
36
  if (args.json && !args['no-ui']) {
33
37
  process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  validateApiKey,
17
17
  validateThinkingLevel
18
18
  } = require('./utils/validators');
19
+ const { resolvePromptSource } = require('./utils/prompt-source');
19
20
  const { logger } = require('./utils/logger');
20
21
 
21
22
  /**
@@ -78,6 +79,14 @@ function parseArgs(argv) {
78
79
  continue;
79
80
  }
80
81
 
82
+ // Unknown negation flags: known --no-* flags are already handled by
83
+ // isBooleanFlag() above; treat any *unregistered* --no-* token as a
84
+ // boolean so it can never swallow the following positional as a value.
85
+ if (key.startsWith('no-')) {
86
+ result[key] = true;
87
+ continue;
88
+ }
89
+
81
90
  // Options with values
82
91
  if (next && !next.startsWith('--')) {
83
92
  result[key] = parseValue(key, next);
@@ -153,6 +162,19 @@ function parseValue(key, value) {
153
162
  * @returns {{ valid: boolean, error?: string }}
154
163
  */
155
164
  function validateStartArgs(args) {
165
+ // Resolve the prompt source (--prompt XOR --prompt-file) here so validation
166
+ // is self-contained and order-independent: a caller need not have run
167
+ // resolvePromptSource() first. Skipped when args.prompt is already a plain
168
+ // string (the classic path / already-resolved by handleStart), so the empty
169
+ // '' case still falls through to the presence/content checks below.
170
+ if (args['prompt-file'] !== undefined || args.prompt === undefined || args.prompt === true) {
171
+ const res = resolvePromptSource(args);
172
+ if (res.error) {
173
+ return { valid: false, code: 'MISSING_PROMPT', error: res.error };
174
+ }
175
+ args.prompt = res.prompt;
176
+ }
177
+
156
178
  // Required: --prompt (presence check)
157
179
  if (!args.prompt) {
158
180
  return { valid: false, error: 'Error: --prompt is required' };
@@ -28,6 +28,8 @@ function estimateTokenCount(text) {
28
28
  return 0;
29
29
  }
30
30
 
31
+ // Intentional cheap heuristic — not a real BPE tokenizer; under/over-counts
32
+ // for CJK, code, and punctuation-dense text. (ceil, vs floor in context.js.)
31
33
  return Math.ceil(text.length / 4);
32
34
  }
33
35
 
package/src/context.js CHANGED
@@ -52,7 +52,8 @@ function estimateTokens(text) {
52
52
  if (!text || typeof text !== 'string') {
53
53
  return 0;
54
54
  }
55
- // Spec specifies ~4 chars per token
55
+ // Intentional cheap heuristic (spec §5.3: ~4 chars/token). Not a real BPE
56
+ // tokenizer — under/over-counts for CJK, code, and punctuation-dense text.
56
57
  return Math.floor(text.length / 4);
57
58
  }
58
59
 
package/src/headless.js CHANGED
@@ -21,6 +21,27 @@ const { createMirrorState, mirrorMessages, logMessage } = require('./sidecar/con
21
21
  const FOLD_MARKER = '[SIDECAR_FOLD]';
22
22
  const COMPLETE_MARKER = FOLD_MARKER; // backward compat
23
23
 
24
+ /**
25
+ * #BL-7: the fold marker is the fixed public string [SIDECAR_FOLD]. A model can
26
+ * legitimately emit it on its own line mid-output — summarizing a prior sidecar,
27
+ * reproducing these instructions, or from scraped content — which used to force a
28
+ * PREMATURE fold. Harden by requiring the marker to be the FINAL non-empty line
29
+ * of the output: a standalone marker followed by MORE content is treated as
30
+ * echoed prose, not a completion signal. Only the true trailing marker folds.
31
+ *
32
+ * @param {string} output - Accumulated assistant output
33
+ * @returns {number} char index where the trailing marker line begins, or -1
34
+ */
35
+ function findTrailingFoldMarker(output) {
36
+ if (!output) { return -1; }
37
+ // The marker must be the last non-empty line: it sits alone on its line
38
+ // (only intra-line whitespace around it) and NOTHING but whitespace follows
39
+ // to the end of the string. The `(?![\s\S]*\S)` lookahead pins it to the true
40
+ // end — a bare marker followed by more prose is echoed content, not a signal.
41
+ const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
42
+ return m ? m.index : -1;
43
+ }
44
+
24
45
  /**
25
46
  * Default timeout: 15 minutes per spec §6.2
26
47
  */
@@ -399,10 +420,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
399
420
  elapsed: Date.now() - startTime
400
421
  });
401
422
 
402
- // Check for completion marker on its own line (not inline in prose).
403
- // Models may mention [SIDECAR_FOLD] when describing code only treat
404
- // it as a signal when it appears as a standalone line.
405
- if (/^\s*\[SIDECAR_FOLD\]\s*$/m.test(mirror.output)) {
423
+ // Check for the completion marker as the FINAL non-empty line (#BL-7).
424
+ // Models may emit [SIDECAR_FOLD] on its own line mid-output (echoing a
425
+ // prior sidecar, these instructions, or scraped content) only treat
426
+ // it as a completion signal when nothing but blank lines follow it.
427
+ if (findTrailingFoldMarker(mirror.output) !== -1) {
406
428
  completed = true;
407
429
  break;
408
430
  }
@@ -608,7 +630,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
608
630
  }
609
631
 
610
632
  /**
611
- * Extract summary from output (everything before [SIDECAR_FOLD])
633
+ * Extract summary from output (everything before the trailing [SIDECAR_FOLD])
612
634
  * Spec Reference: §6.2 - Return summary (everything before [SIDECAR_FOLD])
613
635
  *
614
636
  * @param {string} output - Raw output from OpenCode
@@ -619,13 +641,13 @@ function extractSummary(output) {
619
641
  return '';
620
642
  }
621
643
 
622
- // Split on the fold marker only when it appears on its own line.
623
- // Models may mention [SIDECAR_FOLD] inline when describing code
624
- // only treat it as a delimiter when standalone.
625
- const markerRegex = /^\s*\[SIDECAR_FOLD\]\s*$/m;
626
- const match = output.match(markerRegex);
627
- if (match) {
628
- return output.slice(0, match.index).trim();
644
+ // Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
645
+ // A [SIDECAR_FOLD] echoed mid-output (describing code, reproducing these
646
+ // instructions, or from scraped content) is NOT a delimiter keep it as
647
+ // content. Only the true trailing marker is stripped.
648
+ const idx = findTrailingFoldMarker(output);
649
+ if (idx !== -1) {
650
+ return output.slice(0, idx).trim();
629
651
  }
630
652
  return output.trim();
631
653
  }
@@ -659,6 +681,7 @@ module.exports = {
659
681
  waitForServer,
660
682
  withTimeout,
661
683
  extractSummary,
684
+ findTrailingFoldMarker,
662
685
  formatFoldOutput,
663
686
  DEFAULT_TIMEOUT,
664
687
  FOLD_MARKER,
package/src/mcp-server.js CHANGED
@@ -211,7 +211,17 @@ const handlers = {
211
211
  const { generateTaskId } = require('./sidecar/start');
212
212
  const taskId = generateTaskId();
213
213
 
214
- const args = ['start', '--prompt', input.prompt, '--task-id', taskId, '--client', 'cowork'];
214
+ // New session canonical amicus dir (writes).
215
+ const sessionDir = getSessionDir(cwd, taskId);
216
+
217
+ // BL-1: the prompt goes via file, not inline. A long prompt passed as a CLI
218
+ // arg silently truncates/corrupts on Windows (~32KB command-line cap). Mirror
219
+ // the amicus_fanout briefing-file pattern; the spawn command line must NOT
220
+ // carry the prompt. --prompt-file is resolved by handleStart/resolvePromptSource.
221
+ // The file itself is written just before the spawn fallback below (the
222
+ // shared-server path passes the prompt in-process and never reads args).
223
+ const briefingPath = path.join(sessionDir, 'briefing.md');
224
+ const args = ['start', '--prompt-file', briefingPath, '--task-id', taskId, '--client', 'cowork'];
215
225
  if (resolvedModel) { args.push('--model', resolvedModel); }
216
226
  const agent = (input.noUi && (!input.agent || input.agent.toLowerCase() === 'chat'))
217
227
  ? 'build' : input.agent;
@@ -229,9 +239,6 @@ const handlers = {
229
239
  if (input.windowPosition) { args.push('--position', input.windowPosition); }
230
240
  args.push('--cwd', cwd);
231
241
 
232
- // New session → canonical amicus dir (writes).
233
- const sessionDir = getSessionDir(cwd, taskId);
234
-
235
242
  if (sharedServer.enabled && input.noUi) {
236
243
  // Shared server path: headless only, delegates to runHeadless()
237
244
  let sessionId;
@@ -349,14 +356,20 @@ const handlers = {
349
356
  }
350
357
  }
351
358
 
352
- // Feature flag disabled (or shared server failed): fall back to per-process spawn
359
+ // Feature flag disabled (or shared server failed): fall back to per-process spawn.
360
+ // BL-1: create the session dir and write the prompt to briefing.md BEFORE the
361
+ // spawn so --prompt-file (built above) resolves to a real file, keeping the
362
+ // full prompt off the ~32KB-capped Windows command line.
353
363
  let child;
354
- try { child = spawnSidecarProcess(args, sessionDir); } catch (err) {
364
+ try {
365
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
366
+ fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
367
+ child = spawnSidecarProcess(args, sessionDir);
368
+ } catch (err) {
355
369
  return textResult(`Failed to start Amicus: ${err.message}`, true);
356
370
  }
357
371
 
358
372
  if (child && child.pid) {
359
- fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
360
373
  recordSession(taskId, cwd); // #40: global index for cross-project lookup
361
374
  const metaPath = path.join(sessionDir, 'metadata.json');
362
375
  if (!fs.existsSync(metaPath)) {
@@ -631,14 +644,22 @@ const handlers = {
631
644
  // New continuation session → canonical amicus dir (writes).
632
645
  const sessionDir = getSessionDir(cwd, newTaskId);
633
646
 
634
- const args = ['continue', input.taskId, '--prompt', input.prompt,
647
+ // BL-1: the follow-up prompt goes via file, not inline, so a long prompt is
648
+ // never truncated by the ~32KB Windows command-line cap. handleContinue reads
649
+ // --prompt-file. The briefing is written into the NEW session dir below.
650
+ const briefingPath = path.join(sessionDir, 'briefing.md');
651
+ const args = ['continue', input.taskId, '--prompt-file', briefingPath,
635
652
  '--task-id', newTaskId, '--client', 'cowork', '--cwd', cwd];
636
653
  if (input.model) { args.push('--model', input.model); }
637
654
  if (input.noUi) { args.push('--no-ui', '--agent', 'build'); }
638
655
  if (input.timeout) { args.push('--timeout', String(input.timeout)); }
639
656
  if (input.contextTurns) { args.push('--context-turns', String(input.contextTurns)); }
640
657
  if (input.contextMaxTokens) { args.push('--context-max-tokens', String(input.contextMaxTokens)); }
641
- try { spawnSidecarProcess(args, sessionDir); } catch (err) {
658
+ try {
659
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
660
+ fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
661
+ spawnSidecarProcess(args, sessionDir);
662
+ } catch (err) {
642
663
  return textResult(`Failed to continue: ${err.message}`, true);
643
664
  }
644
665
  recordSession(newTaskId, cwd); // #40: global index for cross-project lookup
@@ -261,7 +261,21 @@ async function getMessages(client, sessionId, directory) {
261
261
  ...directoryQuery(directory)
262
262
  });
263
263
 
264
- return result.data || [];
264
+ // A well-formed response carries a data array (possibly empty). If it's
265
+ // absent, the SDK returned an error shape (e.g. a 5xx with { error }) that
266
+ // would otherwise be indistinguishable from "0 messages" — surface it so the
267
+ // poll loop's diagnostics aren't blind. Return contract unchanged: an array.
268
+ if (!Array.isArray(result.data)) {
269
+ const { logger } = require('./utils/logger');
270
+ logger.warn('getMessages: SDK response had no data array', {
271
+ sessionId,
272
+ status: (result.response && result.response.status) || (result.error && result.error.status),
273
+ error: result.error && (result.error.message || JSON.stringify(result.error))
274
+ });
275
+ return [];
276
+ }
277
+
278
+ return result.data;
265
279
  }
266
280
 
267
281
  /**
@@ -37,7 +37,15 @@ const LEGACY_SESSIONS_DIR = 'sidecar_sessions';
37
37
  * // Returns: '/path/to/project/.claude/amicus_sessions/abc123'
38
38
  */
39
39
  function getSessionDir(projectDir, taskId) {
40
- return path.join(projectDir, '.claude', SESSIONS_DIR, taskId);
40
+ const sessionDir = path.join(projectDir, '.claude', SESSIONS_DIR, taskId);
41
+ // Defense-in-depth: reject a taskId that would escape the sessions dir
42
+ // (path separators / '..'). Callers pre-validate today; this is a backstop.
43
+ const base = path.resolve(projectDir, '.claude', SESSIONS_DIR);
44
+ const resolved = path.resolve(sessionDir);
45
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
46
+ throw new Error('Invalid task ID: path traversal detected');
47
+ }
48
+ return sessionDir;
41
49
  }
42
50
 
43
51
  /**
@@ -9,11 +9,20 @@
9
9
  * except the injectable `now`.
10
10
  */
11
11
 
12
+ // Bound the unbounded toolCalls accumulator (BL-4). This array holds {id,name,input}
13
+ // objects whose `input` can be large; it is the only mirror-state member that grows
14
+ // per tool call with no natural bound and carries heavy payloads. Keep the most recent
15
+ // N, dropping the oldest. Dedup identity lives in the separate seenToolCallIds Set so
16
+ // dropping an old array entry never causes a re-append or a spurious toolCalls.length
17
+ // bump in the headless idle detector.
18
+ const MAX_TOOL_CALLS = 2000;
19
+
12
20
  /** Fresh cursor for a session's mirror. */
13
21
  function createMirrorState() {
14
22
  return {
15
23
  seenTextParts: new Map(), // partId -> last captured text length
16
- toolCalls: [], // [{id,name,input}]
24
+ toolCalls: [], // [{id,name,input}] — capped at MAX_TOOL_CALLS (most-recent-N)
25
+ seenToolCallIds: new Set(), // stable dedup identity for tool calls (survives the cap)
17
26
  seenToolResultIds: new Set(),
18
27
  receivingReported: false,
19
28
  output: '', // accumulated assistant text
@@ -75,9 +84,13 @@ function mirrorMessages(messages, state, opts = {}) {
75
84
  progressUpdates.push({ stage: 'receiving', extra: { messagesReceived: 1 } });
76
85
  }
77
86
  }
78
- } else if ((part.type === 'tool_use' || part.type === 'tool') && !state.toolCalls.find(t => t.id === part.id)) {
87
+ } else if ((part.type === 'tool_use' || part.type === 'tool') && !state.seenToolCallIds.has(part.id)) {
79
88
  const toolCall = { id: part.id, name: part.name, input: part.input };
89
+ state.seenToolCallIds.add(part.id);
80
90
  state.toolCalls.push(toolCall);
91
+ // Bound growth: keep the most recent N tool-call payloads (BL-4). Dedup is the
92
+ // Set above, so dropping the oldest here never causes a re-append.
93
+ if (state.toolCalls.length > MAX_TOOL_CALLS) { state.toolCalls.shift(); }
81
94
  appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
82
95
 
83
96
  // Update progress on tool_use detection
@@ -11,7 +11,43 @@ const os = require('os');
11
11
  const path = require('path');
12
12
  const { logger } = require('./logger');
13
13
 
14
- const AUTH_JSON_PATH = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
14
+ /**
15
+ * Ordered, de-duplicated candidate locations for OpenCode's auth.json, most
16
+ * specific first. OpenCode uses XDG-style data dirs; on Windows it still writes
17
+ * to ~/.local/share/opencode (verified), so that path stays FIRST after XDG.
18
+ * Mirrors the cross-platform precedence pattern in src/sidecar/electron-cache.js.
19
+ * @param {NodeJS.ProcessEnv} [env] - Environment (injectable for tests)
20
+ * @returns {string[]}
21
+ */
22
+ function authJsonCandidates(env = process.env) {
23
+ const home = os.homedir();
24
+ const candidates = [];
25
+ if (env.XDG_DATA_HOME) { candidates.push(path.join(env.XDG_DATA_HOME, 'opencode', 'auth.json')); }
26
+ candidates.push(path.join(home, '.local', 'share', 'opencode', 'auth.json'));
27
+ if (process.platform === 'win32') {
28
+ const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
29
+ candidates.push(path.join(appData, 'opencode', 'auth.json'));
30
+ }
31
+ return [...new Set(candidates)];
32
+ }
33
+
34
+ /**
35
+ * Resolve the auth.json path to use: first existing candidate, else the primary
36
+ * (~/.local/share) path so callers/writers have a stable default.
37
+ * @param {NodeJS.ProcessEnv} [env]
38
+ * @returns {string}
39
+ */
40
+ function resolveAuthJsonPath(env = process.env) {
41
+ const candidates = authJsonCandidates(env);
42
+ for (const c of candidates) {
43
+ if (fs.existsSync(c)) { return c; }
44
+ }
45
+ const localShare = path.join('.local', 'share');
46
+ return candidates.find((c) => c.includes(localShare)) || candidates[0];
47
+ }
48
+
49
+ // Backward-compat export: the resolved path at module load time.
50
+ const AUTH_JSON_PATH = resolveAuthJsonPath();
15
51
 
16
52
  /** Known provider IDs that map to sidecar's PROVIDER_ENV_MAP */
17
53
  const KNOWN_PROVIDERS = ['openrouter', 'google', 'openai', 'anthropic', 'deepseek'];
@@ -37,10 +73,11 @@ function extractKey(entry) {
37
73
  * @returns {Object<string, string>} Map of provider -> key string (only providers with keys)
38
74
  */
39
75
  function readAuthJsonKeys() {
40
- if (!fs.existsSync(AUTH_JSON_PATH)) { return {}; }
76
+ const authPath = resolveAuthJsonPath();
77
+ if (!fs.existsSync(authPath)) { return {}; }
41
78
  let parsed;
42
79
  try {
43
- parsed = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf-8'));
80
+ parsed = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
44
81
  } catch (_err) {
45
82
  logger.debug('auth.json is malformed, skipping import');
46
83
  return {};
@@ -89,11 +126,12 @@ function checkAuthJson(provider) {
89
126
  */
90
127
  function removeFromAuthJson(provider) {
91
128
  try {
92
- if (!fs.existsSync(AUTH_JSON_PATH)) { return; }
93
- const parsed = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf-8'));
129
+ const authPath = resolveAuthJsonPath();
130
+ if (!fs.existsSync(authPath)) { return; }
131
+ const parsed = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
94
132
  if (!parsed[provider]) { return; }
95
133
  delete parsed[provider];
96
- fs.writeFileSync(AUTH_JSON_PATH, JSON.stringify(parsed, null, 2), 'utf-8');
134
+ fs.writeFileSync(authPath, JSON.stringify(parsed, null, 2), 'utf-8');
97
135
  } catch (_err) {
98
136
  logger.debug('Failed to remove provider from auth.json', { provider });
99
137
  }
@@ -105,5 +143,7 @@ module.exports = {
105
143
  checkAuthJson,
106
144
  removeFromAuthJson,
107
145
  AUTH_JSON_PATH,
108
- KNOWN_PROVIDERS
146
+ KNOWN_PROVIDERS,
147
+ resolveAuthJsonPath,
148
+ authJsonCandidates
109
149
  };