Skip to content

Commit 6d6b82f

Browse files
authored
fix: honor PI_CODING_AGENT_DIR env override for Pi paths (#74)
Pi uses a single agent directory (`~/.pi/agent`) and does not use XDG. The base dir can be overridden with the PI_CODING_AGENT_DIR env var (tilde-expanded, including ~\ on Windows). Previous code hardcoded ~/.pi/agent for Pi auth, settings, and SYSTEM.md, ignoring the env override. It also used string concatenation for the Pi auth path, producing mixed separators on Windows. Changes: - Add Pi path resolvers to xdg-paths.ts (getPiAgentDir, getPiAuthPath, getPiSettingsPath) - Add expandTilde helper for PI_CODING_AGENT_DIR tilde expansion - Update pi.ts to use new resolvers for settings.json and SYSTEM.md paths - Update auth-sync.ts to use getPiAuthPath instead of string concatenation - Add comprehensive tests for xdg-paths.ts (21 tests) All 244 tests pass.
1 parent 4ae77ac commit 6d6b82f

4 files changed

Lines changed: 208 additions & 12 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import {
4+
getOpencodeAuthPath,
5+
getOpencodeConfigDir,
6+
getOpencodeDataDir,
7+
getPiAgentDir,
8+
getPiAuthPath,
9+
getPiSettingsPath,
10+
resolveGlobalConfigPath,
11+
} from '../xdg-paths.js';
12+
13+
const HOME = '/home/user';
14+
15+
describe('xdg-paths', () => {
16+
const originalEnv = process.env;
17+
18+
beforeEach(() => {
19+
// Clear relevant env vars before each test
20+
delete process.env.PI_CODING_AGENT_DIR;
21+
delete process.env.OPENCODE_CONFIG;
22+
delete process.env.OPENCODE_CONFIG_DIR;
23+
delete process.env.XDG_CONFIG_HOME;
24+
delete process.env.XDG_DATA_HOME;
25+
});
26+
27+
afterEach(() => {
28+
process.env = originalEnv;
29+
});
30+
31+
/* ─── Pi paths ───────────────────────────────────────────────────────── */
32+
33+
describe('getPiAgentDir', () => {
34+
it('defaults to ~/.pi/agent', () => {
35+
expect(getPiAgentDir(HOME)).toBe('/home/user/.pi/agent');
36+
});
37+
38+
it('honors PI_CODING_AGENT_DIR', () => {
39+
process.env.PI_CODING_AGENT_DIR = '/custom/pi/dir';
40+
expect(getPiAgentDir(HOME)).toBe('/custom/pi/dir');
41+
});
42+
43+
it('tilde-expands PI_CODING_AGENT_DIR', () => {
44+
process.env.PI_CODING_AGENT_DIR = '~/custom/pi';
45+
expect(getPiAgentDir(HOME)).toBe('/home/user/custom/pi');
46+
});
47+
48+
it('tilde-expands Windows-style backslash', () => {
49+
process.env.PI_CODING_AGENT_DIR = '~\\custom\\pi';
50+
// On Unix the backslash is preserved in the remainder; on Windows path.join
51+
// produces backslashes. We verify tilde is expanded, not path normalization.
52+
expect(getPiAgentDir(HOME)).toMatch(/^\/home\/user[/\\]custom[/\\]pi$/);
53+
});
54+
55+
it('handles bare tilde', () => {
56+
process.env.PI_CODING_AGENT_DIR = '~';
57+
expect(getPiAgentDir(HOME)).toBe('/home/user');
58+
});
59+
});
60+
61+
describe('getPiAuthPath', () => {
62+
it('resolves to agentDir/auth.json', () => {
63+
expect(getPiAuthPath(HOME)).toBe('/home/user/.pi/agent/auth.json');
64+
});
65+
66+
it('follows PI_CODING_AGENT_DIR override', () => {
67+
process.env.PI_CODING_AGENT_DIR = '/other/pi';
68+
expect(getPiAuthPath(HOME)).toBe('/other/pi/auth.json');
69+
});
70+
});
71+
72+
describe('getPiSettingsPath', () => {
73+
it('resolves to agentDir/settings.json', () => {
74+
expect(getPiSettingsPath(HOME)).toBe('/home/user/.pi/agent/settings.json');
75+
});
76+
77+
it('follows PI_CODING_AGENT_DIR override', () => {
78+
process.env.PI_CODING_AGENT_DIR = '/other/pi';
79+
expect(getPiSettingsPath(HOME)).toBe('/other/pi/settings.json');
80+
});
81+
});
82+
83+
/* ─── OpenCode paths ─────────────────────────────────────────────────── */
84+
85+
describe('getOpencodeConfigDir', () => {
86+
it('defaults to ~/.config/opencode', () => {
87+
expect(getOpencodeConfigDir(HOME)).toBe('/home/user/.config/opencode');
88+
});
89+
90+
it('honors XDG_CONFIG_HOME', () => {
91+
process.env.XDG_CONFIG_HOME = '/custom/config';
92+
expect(getOpencodeConfigDir(HOME)).toBe('/custom/config/opencode');
93+
});
94+
95+
it('prefers OPENCODE_CONFIG_DIR over XDG_CONFIG_HOME', () => {
96+
process.env.OPENCODE_CONFIG_DIR = '/oc/dir';
97+
process.env.XDG_CONFIG_HOME = '/xdg/config';
98+
expect(getOpencodeConfigDir(HOME)).toBe('/oc/dir');
99+
});
100+
});
101+
102+
describe('getOpencodeDataDir', () => {
103+
it('defaults to ~/.local/share/opencode', () => {
104+
expect(getOpencodeDataDir(HOME)).toBe('/home/user/.local/share/opencode');
105+
});
106+
107+
it('honors XDG_DATA_HOME', () => {
108+
process.env.XDG_DATA_HOME = '/custom/data';
109+
expect(getOpencodeDataDir(HOME)).toBe('/custom/data/opencode');
110+
});
111+
});
112+
113+
describe('getOpencodeAuthPath', () => {
114+
it('resolves to dataDir/auth.json', () => {
115+
expect(getOpencodeAuthPath(HOME)).toBe('/home/user/.local/share/opencode/auth.json');
116+
});
117+
118+
it('follows XDG_DATA_HOME override', () => {
119+
process.env.XDG_DATA_HOME = '/other/data';
120+
expect(getOpencodeAuthPath(HOME)).toBe('/other/data/opencode/auth.json');
121+
});
122+
});
123+
124+
describe('resolveGlobalConfigPath', () => {
125+
it('honors OPENCODE_CONFIG env var', async () => {
126+
process.env.OPENCODE_CONFIG = '/explicit/opencode.json';
127+
const result = await resolveGlobalConfigPath(HOME, async () => false);
128+
expect(result).toBe('/explicit/opencode.json');
129+
});
130+
131+
it('prefers existing .jsonc over .json', async () => {
132+
const exists = vi.fn(async (p: string) => p.endsWith('.jsonc'));
133+
const result = await resolveGlobalConfigPath(HOME, exists);
134+
expect(result).toBe('/home/user/.config/opencode/opencode.jsonc');
135+
});
136+
137+
it('falls back to .json when .jsonc missing', async () => {
138+
const exists = vi.fn(async (p: string) => p.endsWith('.json'));
139+
const result = await resolveGlobalConfigPath(HOME, exists);
140+
expect(result).toBe('/home/user/.config/opencode/opencode.json');
141+
});
142+
143+
it('returns .json path when neither exists', async () => {
144+
const exists = vi.fn(async () => false);
145+
const result = await resolveGlobalConfigPath(HOME, exists);
146+
expect(result).toBe('/home/user/.config/opencode/opencode.json');
147+
});
148+
149+
it('follows XDG_CONFIG_HOME for config dir', async () => {
150+
process.env.XDG_CONFIG_HOME = '/xdg/config';
151+
const exists = vi.fn(async () => false);
152+
const result = await resolveGlobalConfigPath(HOME, exists);
153+
expect(result).toBe('/xdg/config/opencode/opencode.json');
154+
});
155+
});
156+
});

src/commands/code/auth-sync.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from '../../auth/jwt.js';
1111
import { logger } from '../../utils/logger.js';
1212
import { FatalError } from './errors.js';
13-
import { getOpencodeAuthPath } from './xdg-paths.js';
13+
import { getOpencodeAuthPath, getPiAuthPath } from './xdg-paths.js';
1414

1515
export interface AuthDeps {
1616
apiKeyService: ApiKeyServicePort;
@@ -34,7 +34,7 @@ const CLI_AUTH_PATH = (homeDir: string) => homeDir + '/.berget/auth.json';
3434

3535
const TOOL_AUTH_PATHS = {
3636
opencode: getOpencodeAuthPath,
37-
pi: (homeDir: string) => homeDir + '/.pi/agent/auth.json',
37+
pi: getPiAuthPath,
3838
} as const;
3939

4040
const TOOL_API_KEY_TYPES: Record<'opencode' | 'pi', string> = {

src/commands/code/pi.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Prompter } from './ports/prompter.js';
77
import { getAllAgents, toPiPrompt } from '../../agents/index.js';
88
import { CancelledError, CommandFailedError } from './errors.js';
99
import { readJsonMaybe, writeJsonFile } from './utils.js';
10+
import { getPiAgentDir, getPiSettingsPath } from './xdg-paths.js';
1011

1112
const PI_PROVIDER = 'npm:@bergetai/pi-provider';
1213
const PI_PROVIDER_NAME = '@bergetai/pi-provider';
@@ -31,10 +32,7 @@ export async function getPiState(
3132
cwd: string,
3233
): Promise<{ global: boolean; project: boolean }> {
3334
const projectSettings = await readJsonMaybe(files, path.join(cwd, '.pi', 'settings.json'));
34-
const globalSettings = await readJsonMaybe(
35-
files,
36-
path.join(homeDir, '.pi', 'agent', 'settings.json'),
37-
);
35+
const globalSettings = await readJsonMaybe(files, getPiSettingsPath(homeDir));
3836

3937
return {
4038
global: hasPiProviderInSettings(globalSettings),
@@ -61,9 +59,7 @@ export async function initPi(deps: InitPiDeps): Promise<void> {
6159
}
6260

6361
const settingsPath =
64-
scope === 'project'
65-
? path.join(cwd, '.pi', 'settings.json')
66-
: path.join(homeDir, '.pi', 'agent', 'settings.json');
62+
scope === 'project' ? path.join(cwd, '.pi', 'settings.json') : getPiSettingsPath(homeDir);
6763

6864
const raw = await readJsonMaybe(files, settingsPath);
6965
const settings: Record<string, unknown> =
@@ -114,7 +110,7 @@ export async function initPiAgent(deps: {
114110
const systemPath =
115111
scope === 'project'
116112
? path.join(cwd, '.pi', 'SYSTEM.md')
117-
: path.join(homeDir, '.pi', 'agent', 'SYSTEM.md');
113+
: path.join(getPiAgentDir(homeDir), 'SYSTEM.md');
118114

119115
prompter.note('Pi uses a single system prompt.', 'Agent Setup');
120116

@@ -154,8 +150,7 @@ export async function initPiAgent(deps: {
154150
const s = prompter.spinner();
155151
s.start('Writing agent configuration...');
156152
try {
157-
const systemDir =
158-
scope === 'project' ? path.join(cwd, '.pi') : path.join(homeDir, '.pi', 'agent');
153+
const systemDir = scope === 'project' ? path.join(cwd, '.pi') : getPiAgentDir(homeDir);
159154
await files.mkdir(systemDir);
160155
await files.writeFile(systemPath, toPiPrompt(agent));
161156
s.stop(`Wrote agent configuration to ${systemPath}`);

src/commands/code/xdg-paths.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,32 @@ export function getOpencodeDataDir(homeDir: string): string {
3030
return path.join(xdgData || path.join(homeDir, '.local', 'share'), 'opencode');
3131
}
3232

33+
/**
34+
* Resolve the Pi agent base directory.
35+
* Honors `PI_CODING_AGENT_DIR` (tilde-expanded), else `~/.pi/agent`.
36+
*/
37+
export function getPiAgentDir(homeDir: string): string {
38+
const envDir = process.env.PI_CODING_AGENT_DIR;
39+
if (envDir) {
40+
return expandTilde(envDir, homeDir);
41+
}
42+
return path.join(homeDir, '.pi', 'agent');
43+
}
44+
45+
/**
46+
* Resolve the path to Pi's global auth.json.
47+
*/
48+
export function getPiAuthPath(homeDir: string): string {
49+
return path.join(getPiAgentDir(homeDir), 'auth.json');
50+
}
51+
52+
/**
53+
* Resolve the path to Pi's global settings.json.
54+
*/
55+
export function getPiSettingsPath(homeDir: string): string {
56+
return path.join(getPiAgentDir(homeDir), 'settings.json');
57+
}
58+
3359
/**
3460
* Resolve the path to the global opencode config file.
3561
* Honors OPENCODE_CONFIG (single-file override), then probes
@@ -52,3 +78,22 @@ export async function resolveGlobalConfigPath(
5278
if (await exists(jsonPath)) return jsonPath;
5379
return jsonPath;
5480
}
81+
82+
/**
83+
* Expand a leading tilde in a path to the user's home directory.
84+
* Pi's `PI_CODING_AGENT_DIR` env var is tilde-expanded.
85+
*/
86+
function expandTilde(input: string, homeDir: string): string {
87+
let expanded: string;
88+
if (input.startsWith('~/')) {
89+
expanded = path.join(homeDir, input.slice(2));
90+
} else if (input.startsWith('~\\')) {
91+
expanded = path.join(homeDir, input.slice(2));
92+
} else if (input === '~') {
93+
expanded = homeDir;
94+
} else {
95+
expanded = input;
96+
}
97+
// Normalize backslashes for cross-platform consistency
98+
return expanded.replace(/\\/g, '/');
99+
}

0 commit comments

Comments
 (0)