Skip to content

Commit 33b0c0e

Browse files
committed
fix(cli): address Pi provider review findings in doctor and setup
- Add `probeAuthJsonExists` wrapper to doctor.ts so the existsSync call can be properly spied in tests (ESM named-import rebinding limitation, consistent with `probeFileExists` pattern in setup.ts) - Fix `checkPi` false positive: shared keys like ANTHROPIC_API_KEY no longer count as Pi evidence unless DEFAULT_AI_ASSISTANT=pi; Claude-only users no longer see a spurious "pass" or "Pi: Configured" status - Fix `writeHomePiModelConfig` to use `pathsGetArchonHome` from @archon/paths instead of the local `getArchonHome()`, so Docker environments (/.archon) are handled correctly - Replace `includes('pi:')` substring check with `/^\s*pi\s*:/m` regex to prevent false positives from substrings like `api:` - Fix `log.warn` → `log.warning` for consistency with rest of setup.ts - Add `err.code` to the Pi model config write-failure warning message - Update `checkPiModule` docstring to avoid "mirrors Claude binary check pattern" phrasing that misled maintainers - Update `writeHomePiModelConfig` branch-1 docstring from "user-edited" to "idempotent" to accurately describe the skip condition - Add `writeHomePiModelConfig` test suite covering all three branches, quote escaping, and the api:/pi: regex fix - Update checkPi tests to spy on `probeAuthJsonExists` via doctorModule instead of fsModule.existsSync; add regression tests for M2 false positive - Add `# Pi Authentication` section header assertion to mixed Claude+Pi generateEnvContent test
1 parent c161443 commit 33b0c0e

4 files changed

Lines changed: 122 additions & 20 deletions

File tree

packages/cli/src/commands/doctor.test.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { describe, it, expect, spyOn, afterEach, beforeEach } from 'bun:test';
1111
import { tmpdir } from 'os';
1212
import { join } from 'path';
1313
import { mkdirSync, rmSync } from 'fs';
14-
import * as fsModule from 'fs';
1514
import * as git from '@archon/git';
1615
import {
1716
checkClaudeBinary,
@@ -25,6 +24,7 @@ import {
2524
doctorCommand,
2625
type DatabaseDeps,
2726
} from './doctor';
27+
import * as doctorModule from './doctor';
2828
import { checkPiModule } from './setup';
2929

3030
describe('checkClaudeBinary', () => {
@@ -110,14 +110,18 @@ describe('checkGhAuth', () => {
110110
});
111111

112112
describe('checkPi', () => {
113-
let existsSpy: ReturnType<typeof spyOn<typeof fsModule, 'existsSync'>>;
113+
// Spy on the exported `probeAuthJsonExists` wrapper rather than `fsModule.existsSync`.
114+
// Named imports from 'fs' cannot be intercepted by spying on the namespace object
115+
// due to ESM rebinding — the wrapper pattern (same as `probeFileExists` in setup.ts)
116+
// is the correct way to make this testable.
117+
let authJsonSpy: ReturnType<typeof spyOn<typeof doctorModule, 'probeAuthJsonExists'>>;
114118

115119
beforeEach(() => {
116-
existsSpy = spyOn(fsModule, 'existsSync');
120+
authJsonSpy = spyOn(doctorModule, 'probeAuthJsonExists');
117121
});
118122

119123
afterEach(() => {
120-
existsSpy.mockRestore();
124+
authJsonSpy.mockRestore();
121125
});
122126

123127
it('returns skip when Pi is not configured', async () => {
@@ -128,14 +132,14 @@ describe('checkPi', () => {
128132
});
129133

130134
it('returns pass when ~/.pi/agent/auth.json exists', async () => {
131-
existsSpy.mockReturnValue(true);
135+
authJsonSpy.mockReturnValue(true);
132136
const result = await checkPi({ DEFAULT_AI_ASSISTANT: 'pi' });
133137
expect(result.status).toBe('pass');
134138
expect(result.message).toContain('auth.json');
135139
});
136140

137141
it('returns pass when a Pi API key env var is set', async () => {
138-
existsSpy.mockReturnValue(false);
142+
authJsonSpy.mockReturnValue(false);
139143
const result = await checkPi({
140144
DEFAULT_AI_ASSISTANT: 'pi',
141145
ANTHROPIC_API_KEY: 'sk-ant-test',
@@ -145,17 +149,26 @@ describe('checkPi', () => {
145149
});
146150

147151
it('returns fail when DEFAULT_AI_ASSISTANT=pi but no auth found', async () => {
148-
existsSpy.mockReturnValue(false);
152+
authJsonSpy.mockReturnValue(false);
149153
const result = await checkPi({ DEFAULT_AI_ASSISTANT: 'pi' });
150154
expect(result.status).toBe('fail');
151155
expect(result.message).toContain('pi /login');
152156
});
153157

154-
it('returns pass when a Pi API key is set even when Pi is not the default', async () => {
155-
existsSpy.mockReturnValue(false);
158+
it('returns skip for Claude-only users who have ANTHROPIC_API_KEY but Pi is not default', async () => {
159+
// Regression guard for M2: shared keys like ANTHROPIC_API_KEY must not be treated
160+
// as Pi evidence unless DEFAULT_AI_ASSISTANT=pi.
161+
authJsonSpy.mockReturnValue(false);
162+
const result = await checkPi({ ANTHROPIC_API_KEY: 'sk-ant-test' });
163+
expect(result.status).toBe('skip');
164+
expect(result.message).toContain('not configured');
165+
});
166+
167+
it('returns skip for users with OPENROUTER_API_KEY set but Pi not configured as default', async () => {
168+
authJsonSpy.mockReturnValue(false);
156169
const result = await checkPi({ OPENROUTER_API_KEY: 'or-key' });
157-
expect(result.status).toBe('pass');
158-
expect(result.message).toContain('OPENROUTER_API_KEY');
170+
expect(result.status).toBe('skip');
171+
expect(result.message).toContain('not configured');
159172
});
160173
});
161174

packages/cli/src/commands/doctor.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,22 @@ export async function checkGhAuth(env: NodeJS.ProcessEnv): Promise<CheckResult>
8686
}
8787
}
8888

89+
/**
90+
* Thin wrapper around `existsSync` so tests can spy on it by name without
91+
* fighting ESM named-import rebinding limitations. Matches the `probeFileExists`
92+
* pattern in `setup.ts`.
93+
*/
94+
export function probeAuthJsonExists(path: string): boolean {
95+
return existsSync(path);
96+
}
97+
8998
export async function checkPi(env: NodeJS.ProcessEnv): Promise<CheckResult> {
9099
const label = 'Pi provider';
91100
const isDefault = env.DEFAULT_AI_ASSISTANT === 'pi';
92-
const hasApiKey = PI_API_KEY_VARS.some(v => (env[v] ?? '').trim().length > 0);
101+
// Only treat a shared key (e.g. ANTHROPIC_API_KEY) as Pi evidence when Pi is
102+
// actually configured as the default — otherwise Claude-only users who happen
103+
// to have ANTHROPIC_API_KEY set would get a false-positive "pass" here.
104+
const hasApiKey = isDefault && PI_API_KEY_VARS.some(v => (env[v] ?? '').trim().length > 0);
93105

94106
// Skip for users without Pi configured — same pattern as checkGhAuth.
95107
if (!isDefault && !hasApiKey) {
@@ -99,7 +111,7 @@ export async function checkPi(env: NodeJS.ProcessEnv): Promise<CheckResult> {
99111
// Pi reads OAuth credentials from ~/.pi/agent/auth.json (written by `pi /login`)
100112
// or API key env vars; either path is sufficient.
101113
const authJsonPath = join(homedir(), '.pi', 'agent', 'auth.json');
102-
if (existsSync(authJsonPath)) {
114+
if (probeAuthJsonExists(authJsonPath)) {
103115
return { label, status: 'pass', message: '~/.pi/agent/auth.json found' };
104116
}
105117

packages/cli/src/commands/setup.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Tests for setup command utility functions
33
*/
44
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
5-
import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
5+
import { existsSync, readFileSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
66
import { join } from 'path';
77
import { tmpdir } from 'os';
88
import {
@@ -15,6 +15,7 @@ import {
1515
writeScopedEnv,
1616
serializeEnv,
1717
resolveScopedEnvPath,
18+
writeHomePiModelConfig,
1819
} from './setup';
1920
import * as setupModule from './setup';
2021
import { copyArchonSkill } from './skill';
@@ -390,6 +391,7 @@ CODEX_ACCOUNT_ID=account1
390391
expect(content).toContain('CLAUDE_USE_GLOBAL_AUTH=true');
391392
expect(content).toContain('OPENROUTER_API_KEY=or-key');
392393
expect(content).toContain('DEFAULT_AI_ASSISTANT=claude');
394+
expect(content).toContain('# Pi Authentication');
393395
});
394396
});
395397

@@ -802,3 +804,71 @@ describe('writeScopedEnv (#1303)', () => {
802804
expect(result.preservedKeys).not.toContain('API_KEY');
803805
});
804806
});
807+
808+
describe('writeHomePiModelConfig', () => {
809+
let tmpDir: string;
810+
let originalHome: string | undefined;
811+
812+
beforeEach(() => {
813+
tmpDir = mkdtempSync(join(tmpdir(), 'archon-pi-config-'));
814+
originalHome = process.env.ARCHON_HOME;
815+
process.env.ARCHON_HOME = tmpDir;
816+
});
817+
818+
afterEach(() => {
819+
rmSync(tmpDir, { recursive: true, force: true });
820+
if (originalHome !== undefined) {
821+
process.env.ARCHON_HOME = originalHome;
822+
} else {
823+
delete process.env.ARCHON_HOME;
824+
}
825+
});
826+
827+
it('writes fresh assistants.pi.model block when config is empty', () => {
828+
writeHomePiModelConfig('anthropic/claude-haiku-4-5');
829+
const content = readFileSync(join(tmpDir, 'config.yaml'), 'utf-8');
830+
expect(content).toContain('assistants:');
831+
expect(content).toContain('pi:');
832+
expect(content).toContain('model: "anthropic/claude-haiku-4-5"');
833+
});
834+
835+
it('writes fresh block when config does not exist yet', () => {
836+
// No config.yaml pre-created — function must create it.
837+
writeHomePiModelConfig('openai/gpt-4o');
838+
expect(existsSync(join(tmpDir, 'config.yaml'))).toBe(true);
839+
const content = readFileSync(join(tmpDir, 'config.yaml'), 'utf-8');
840+
expect(content).toContain('pi:');
841+
expect(content).toContain('model: "openai/gpt-4o"');
842+
});
843+
844+
it('skips write when existing config already contains pi: (idempotent)', () => {
845+
writeFileSync(join(tmpDir, 'config.yaml'), 'assistants:\n pi:\n model: "old"\n');
846+
writeHomePiModelConfig('anthropic/claude-haiku-4-5');
847+
const content = readFileSync(join(tmpDir, 'config.yaml'), 'utf-8');
848+
expect(content).toContain('model: "old"');
849+
expect(content).not.toContain('claude-haiku-4-5');
850+
});
851+
852+
it('does not write pi: block when config has assistants: but no pi: (shows note instead)', () => {
853+
writeFileSync(join(tmpDir, 'config.yaml'), 'assistants:\n claude:\n model: sonnet\n');
854+
writeHomePiModelConfig('openai/gpt-4o');
855+
const content = readFileSync(join(tmpDir, 'config.yaml'), 'utf-8');
856+
// Should NOT have injected a pi: key — only a note() was shown.
857+
expect(content).not.toContain('pi:');
858+
});
859+
860+
it('escapes double quotes in the model name', () => {
861+
writeHomePiModelConfig('vendor/"weird-model"');
862+
const content = readFileSync(join(tmpDir, 'config.yaml'), 'utf-8');
863+
expect(content).toContain('\\"weird-model\\"');
864+
});
865+
866+
it('does not false-positive on api: substring (regex guard for includes bug)', () => {
867+
// A config with `api:` but no `pi:` should fall through to the append branch,
868+
// not the idempotent-skip branch.
869+
writeFileSync(join(tmpDir, 'config.yaml'), '# config\napi:\n key: abc\n');
870+
writeHomePiModelConfig('openai/gpt-4o');
871+
const content = readFileSync(join(tmpDir, 'config.yaml'), 'utf-8');
872+
expect(content).toContain('pi:');
873+
});
874+
});

packages/cli/src/commands/setup.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { getRegisteredProviders } from '@archon/providers';
4646
import {
4747
getArchonEnvPath as pathsGetArchonEnvPath,
4848
getRepoArchonEnvPath as pathsGetRepoArchonEnvPath,
49+
getArchonHome as pathsGetArchonHome,
4950
} from '@archon/paths';
5051

5152
// =============================================================================
@@ -512,8 +513,8 @@ async function collectPiConfig(): Promise<{
512513

513514
/**
514515
* Verify the Pi npm module is loadable. Pi is bundled as a transitive dep of
515-
* `@archon/providers` so this should always pass, but it mirrors the Claude
516-
* binary check pattern and catches broken compiled builds.
516+
* `@archon/providers` so this should always pass, but it serves the same
517+
* doctor-step role as `checkClaudeBinary` and catches broken compiled builds.
517518
*
518519
* The `loader` parameter is injected in tests so we don't need
519520
* `mock.module()` on `@archon/providers` (which would pollute other tests).
@@ -1574,18 +1575,22 @@ export function bootstrapProjectConfig(projectPath: string): BootstrapProjectCon
15741575
/**
15751576
* Write the Pi model ref to `~/.archon/config.yaml` so Pi knows which backend
15761577
* to use by default. Three branches:
1577-
* 1. File already contains `pi:` — skip (user-edited; don't overwrite).
1578+
* 1. File already contains `pi:` — skip (idempotent; avoids duplicate blocks
1579+
* on re-runs or when the user has already configured this manually).
15781580
* 2. File contains `assistants:` but no `pi:` — show a manual `note()`
15791581
* because we can't safely splice into existing YAML indentation.
15801582
* 3. Otherwise — append a fresh `assistants: pi: model:` block.
15811583
*/
15821584
export function writeHomePiModelConfig(model: string): void {
1583-
const home = getArchonHome();
1585+
// Use the paths-package version of getArchonHome so Docker (/.archon) is
1586+
// handled correctly — the local getArchonHome() always returns ~/.archon.
1587+
const home = pathsGetArchonHome();
15841588
mkdirSync(home, { recursive: true });
15851589
const configPath = join(home, 'config.yaml');
15861590
const existing = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '';
15871591

1588-
if (existing.includes('pi:')) {
1592+
// Use a regex to avoid false positives from substrings like `api:`.
1593+
if (/^\s*pi\s*:/m.test(existing)) {
15891594
log.info(
15901595
`Pi model already present in ${configPath} — edit assistants.pi.model manually to change.`
15911596
);
@@ -2050,7 +2055,9 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
20502055
} catch (err) {
20512056
// Non-fatal: env write already succeeded, so the user can hand-edit
20522057
// ~/.archon/config.yaml later. Surface the error so it's not silent.
2053-
log.warn(`Could not write Pi model config: ${(err as NodeJS.ErrnoException).message}`);
2058+
const e = err as NodeJS.ErrnoException;
2059+
const code = e.code ? ` (${e.code})` : '';
2060+
log.warning(`Could not write Pi model config: ${e.message}${code}`);
20542061
}
20552062
}
20562063

0 commit comments

Comments
 (0)