Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions src/commands/code/__tests__/xdg-paths.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
4 changes: 2 additions & 2 deletions src/commands/code/auth-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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> = {
Expand Down
15 changes: 5 additions & 10 deletions src/commands/code/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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),
Expand All @@ -61,9 +59,7 @@ export async function initPi(deps: InitPiDeps): Promise<void> {
}

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<string, unknown> =
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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}`);
Expand Down
45 changes: 45 additions & 0 deletions src/commands/code/xdg-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, '/');
}
Loading