diff --git a/src/commands/code/__tests__/xdg-paths.test.ts b/src/commands/code/__tests__/xdg-paths.test.ts new file mode 100644 index 0000000..107583d --- /dev/null +++ b/src/commands/code/__tests__/xdg-paths.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getOpencodeAuthPath, + getOpencodeConfigDir, + getOpencodeDataDir, + getPiAgentDir, + getPiAuthPath, + getPiSettingsPath, + resolveGlobalConfigPath, +} from '../xdg-paths.js'; + +const HOME = '/home/user'; + +describe('xdg-paths', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Clear relevant env vars before each test + delete process.env.PI_CODING_AGENT_DIR; + delete process.env.OPENCODE_CONFIG; + delete process.env.OPENCODE_CONFIG_DIR; + delete process.env.XDG_CONFIG_HOME; + delete process.env.XDG_DATA_HOME; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + /* ─── Pi paths ───────────────────────────────────────────────────────── */ + + describe('getPiAgentDir', () => { + it('defaults to ~/.pi/agent', () => { + expect(getPiAgentDir(HOME)).toBe('/home/user/.pi/agent'); + }); + + it('honors PI_CODING_AGENT_DIR', () => { + process.env.PI_CODING_AGENT_DIR = '/custom/pi/dir'; + expect(getPiAgentDir(HOME)).toBe('/custom/pi/dir'); + }); + + it('tilde-expands PI_CODING_AGENT_DIR', () => { + process.env.PI_CODING_AGENT_DIR = '~/custom/pi'; + expect(getPiAgentDir(HOME)).toBe('/home/user/custom/pi'); + }); + + it('tilde-expands Windows-style backslash', () => { + process.env.PI_CODING_AGENT_DIR = '~\\custom\\pi'; + // On Unix the backslash is preserved in the remainder; on Windows path.join + // produces backslashes. We verify tilde is expanded, not path normalization. + expect(getPiAgentDir(HOME)).toMatch(/^\/home\/user[/\\]custom[/\\]pi$/); + }); + + it('handles bare tilde', () => { + process.env.PI_CODING_AGENT_DIR = '~'; + expect(getPiAgentDir(HOME)).toBe('/home/user'); + }); + }); + + describe('getPiAuthPath', () => { + it('resolves to agentDir/auth.json', () => { + expect(getPiAuthPath(HOME)).toBe('/home/user/.pi/agent/auth.json'); + }); + + it('follows PI_CODING_AGENT_DIR override', () => { + process.env.PI_CODING_AGENT_DIR = '/other/pi'; + expect(getPiAuthPath(HOME)).toBe('/other/pi/auth.json'); + }); + }); + + describe('getPiSettingsPath', () => { + it('resolves to agentDir/settings.json', () => { + expect(getPiSettingsPath(HOME)).toBe('/home/user/.pi/agent/settings.json'); + }); + + it('follows PI_CODING_AGENT_DIR override', () => { + process.env.PI_CODING_AGENT_DIR = '/other/pi'; + expect(getPiSettingsPath(HOME)).toBe('/other/pi/settings.json'); + }); + }); + + /* ─── OpenCode paths ─────────────────────────────────────────────────── */ + + describe('getOpencodeConfigDir', () => { + it('defaults to ~/.config/opencode', () => { + expect(getOpencodeConfigDir(HOME)).toBe('/home/user/.config/opencode'); + }); + + it('honors XDG_CONFIG_HOME', () => { + process.env.XDG_CONFIG_HOME = '/custom/config'; + expect(getOpencodeConfigDir(HOME)).toBe('/custom/config/opencode'); + }); + + it('prefers OPENCODE_CONFIG_DIR over XDG_CONFIG_HOME', () => { + process.env.OPENCODE_CONFIG_DIR = '/oc/dir'; + process.env.XDG_CONFIG_HOME = '/xdg/config'; + expect(getOpencodeConfigDir(HOME)).toBe('/oc/dir'); + }); + }); + + describe('getOpencodeDataDir', () => { + it('defaults to ~/.local/share/opencode', () => { + expect(getOpencodeDataDir(HOME)).toBe('/home/user/.local/share/opencode'); + }); + + it('honors XDG_DATA_HOME', () => { + process.env.XDG_DATA_HOME = '/custom/data'; + expect(getOpencodeDataDir(HOME)).toBe('/custom/data/opencode'); + }); + }); + + describe('getOpencodeAuthPath', () => { + it('resolves to dataDir/auth.json', () => { + expect(getOpencodeAuthPath(HOME)).toBe('/home/user/.local/share/opencode/auth.json'); + }); + + it('follows XDG_DATA_HOME override', () => { + process.env.XDG_DATA_HOME = '/other/data'; + expect(getOpencodeAuthPath(HOME)).toBe('/other/data/opencode/auth.json'); + }); + }); + + describe('resolveGlobalConfigPath', () => { + it('honors OPENCODE_CONFIG env var', async () => { + process.env.OPENCODE_CONFIG = '/explicit/opencode.json'; + const result = await resolveGlobalConfigPath(HOME, async () => false); + expect(result).toBe('/explicit/opencode.json'); + }); + + it('prefers existing .jsonc over .json', async () => { + const exists = vi.fn(async (p: string) => p.endsWith('.jsonc')); + const result = await resolveGlobalConfigPath(HOME, exists); + expect(result).toBe('/home/user/.config/opencode/opencode.jsonc'); + }); + + it('falls back to .json when .jsonc missing', async () => { + const exists = vi.fn(async (p: string) => p.endsWith('.json')); + const result = await resolveGlobalConfigPath(HOME, exists); + expect(result).toBe('/home/user/.config/opencode/opencode.json'); + }); + + it('returns .json path when neither exists', async () => { + const exists = vi.fn(async () => false); + const result = await resolveGlobalConfigPath(HOME, exists); + expect(result).toBe('/home/user/.config/opencode/opencode.json'); + }); + + it('follows XDG_CONFIG_HOME for config dir', async () => { + process.env.XDG_CONFIG_HOME = '/xdg/config'; + const exists = vi.fn(async () => false); + const result = await resolveGlobalConfigPath(HOME, exists); + expect(result).toBe('/xdg/config/opencode/opencode.json'); + }); + }); +}); diff --git a/src/commands/code/auth-sync.ts b/src/commands/code/auth-sync.ts index 02266e3..d93e888 100644 --- a/src/commands/code/auth-sync.ts +++ b/src/commands/code/auth-sync.ts @@ -10,7 +10,7 @@ import { } from '../../auth/jwt.js'; import { logger } from '../../utils/logger.js'; import { FatalError } from './errors.js'; -import { getOpencodeAuthPath } from './xdg-paths.js'; +import { getOpencodeAuthPath, getPiAuthPath } from './xdg-paths.js'; export interface AuthDeps { apiKeyService: ApiKeyServicePort; @@ -34,7 +34,7 @@ const CLI_AUTH_PATH = (homeDir: string) => homeDir + '/.berget/auth.json'; const TOOL_AUTH_PATHS = { opencode: getOpencodeAuthPath, - pi: (homeDir: string) => homeDir + '/.pi/agent/auth.json', + pi: getPiAuthPath, } as const; const TOOL_API_KEY_TYPES: Record<'opencode' | 'pi', string> = { diff --git a/src/commands/code/pi.ts b/src/commands/code/pi.ts index 80613d7..527b7c5 100644 --- a/src/commands/code/pi.ts +++ b/src/commands/code/pi.ts @@ -7,6 +7,7 @@ import type { Prompter } from './ports/prompter.js'; import { getAllAgents, toPiPrompt } from '../../agents/index.js'; import { CancelledError, CommandFailedError } from './errors.js'; import { readJsonMaybe, writeJsonFile } from './utils.js'; +import { getPiAgentDir, getPiSettingsPath } from './xdg-paths.js'; const PI_PROVIDER = 'npm:@bergetai/pi-provider'; const PI_PROVIDER_NAME = '@bergetai/pi-provider'; @@ -31,10 +32,7 @@ export async function getPiState( cwd: string, ): Promise<{ global: boolean; project: boolean }> { const projectSettings = await readJsonMaybe(files, path.join(cwd, '.pi', 'settings.json')); - const globalSettings = await readJsonMaybe( - files, - path.join(homeDir, '.pi', 'agent', 'settings.json'), - ); + const globalSettings = await readJsonMaybe(files, getPiSettingsPath(homeDir)); return { global: hasPiProviderInSettings(globalSettings), @@ -61,9 +59,7 @@ export async function initPi(deps: InitPiDeps): Promise { } const settingsPath = - scope === 'project' - ? path.join(cwd, '.pi', 'settings.json') - : path.join(homeDir, '.pi', 'agent', 'settings.json'); + scope === 'project' ? path.join(cwd, '.pi', 'settings.json') : getPiSettingsPath(homeDir); const raw = await readJsonMaybe(files, settingsPath); const settings: Record = @@ -114,7 +110,7 @@ export async function initPiAgent(deps: { const systemPath = scope === 'project' ? path.join(cwd, '.pi', 'SYSTEM.md') - : path.join(homeDir, '.pi', 'agent', 'SYSTEM.md'); + : path.join(getPiAgentDir(homeDir), 'SYSTEM.md'); prompter.note('Pi uses a single system prompt.', 'Agent Setup'); @@ -154,8 +150,7 @@ export async function initPiAgent(deps: { const s = prompter.spinner(); s.start('Writing agent configuration...'); try { - const systemDir = - scope === 'project' ? path.join(cwd, '.pi') : path.join(homeDir, '.pi', 'agent'); + const systemDir = scope === 'project' ? path.join(cwd, '.pi') : getPiAgentDir(homeDir); await files.mkdir(systemDir); await files.writeFile(systemPath, toPiPrompt(agent)); s.stop(`Wrote agent configuration to ${systemPath}`); diff --git a/src/commands/code/xdg-paths.ts b/src/commands/code/xdg-paths.ts index cf03906..01f41e2 100644 --- a/src/commands/code/xdg-paths.ts +++ b/src/commands/code/xdg-paths.ts @@ -30,6 +30,32 @@ export function getOpencodeDataDir(homeDir: string): string { return path.join(xdgData || path.join(homeDir, '.local', 'share'), 'opencode'); } +/** + * Resolve the Pi agent base directory. + * Honors `PI_CODING_AGENT_DIR` (tilde-expanded), else `~/.pi/agent`. + */ +export function getPiAgentDir(homeDir: string): string { + const envDir = process.env.PI_CODING_AGENT_DIR; + if (envDir) { + return expandTilde(envDir, homeDir); + } + return path.join(homeDir, '.pi', 'agent'); +} + +/** + * Resolve the path to Pi's global auth.json. + */ +export function getPiAuthPath(homeDir: string): string { + return path.join(getPiAgentDir(homeDir), 'auth.json'); +} + +/** + * Resolve the path to Pi's global settings.json. + */ +export function getPiSettingsPath(homeDir: string): string { + return path.join(getPiAgentDir(homeDir), 'settings.json'); +} + /** * Resolve the path to the global opencode config file. * Honors OPENCODE_CONFIG (single-file override), then probes @@ -52,3 +78,22 @@ export async function resolveGlobalConfigPath( if (await exists(jsonPath)) return jsonPath; return jsonPath; } + +/** + * Expand a leading tilde in a path to the user's home directory. + * Pi's `PI_CODING_AGENT_DIR` env var is tilde-expanded. + */ +function expandTilde(input: string, homeDir: string): string { + let expanded: string; + if (input.startsWith('~/')) { + expanded = path.join(homeDir, input.slice(2)); + } else if (input.startsWith('~\\')) { + expanded = path.join(homeDir, input.slice(2)); + } else if (input === '~') { + expanded = homeDir; + } else { + expanded = input; + } + // Normalize backslashes for cross-platform consistency + return expanded.replace(/\\/g, '/'); +}