Skip to content

Commit d28b8ec

Browse files
committed
feat(cli): honor agent config env overrides
1 parent 0292912 commit d28b8ec

7 files changed

Lines changed: 301 additions & 78 deletions

File tree

packages/cli/src/adapters/claude-code.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CanonicalEvent, FileActivityRecord, MetricBag } from '@codetime/shared'
2-
import type { AgentAdapter, InstallEntry } from './types.js'
2+
import type { AdapterEnv, AgentAdapter, InstallEntry } from './types.js'
33
import { readFile, stat } from 'node:fs/promises'
44
import os from 'node:os'
55
import path from 'node:path'
@@ -613,40 +613,53 @@ function hookConfig(): object {
613613

614614
// ── Adapter factory ──
615615

616-
export function createClaudeCodeAdapter(): AgentAdapter {
617-
const CLAUDE_PATH = '.claude'
616+
// Resolve the effective Claude config directory. Claude Code uses
617+
// CLAUDE_CONFIG_DIR to relocate the entire `.claude` tree (settings, projects,
618+
// sessions); honor it so codetime can find sessions in non-default locations.
619+
function claudeConfigDir(home: string, env?: AdapterEnv): string {
620+
const override = env?.CLAUDE_CONFIG_DIR
621+
if (override && override.trim()) {
622+
return path.resolve(override)
623+
}
624+
return path.join(home, '.claude')
625+
}
618626

627+
export function createClaudeCodeAdapter(): AgentAdapter {
619628
return {
620629
id: 'claude-code',
621630
label: 'Claude Code',
622631
agentName: 'claude',
623632
kind: 'agent',
624633

625-
detectPath(home: string) {
626-
return path.join(home, CLAUDE_PATH)
634+
detectPath(home: string, env?: AdapterEnv) {
635+
return claudeConfigDir(home, env)
627636
},
628-
installedPath(home: string) {
629-
return path.join(home, CLAUDE_PATH, 'settings.json')
637+
installedPath(home: string, env?: AdapterEnv) {
638+
return path.join(claudeConfigDir(home, env), 'settings.json')
630639
},
631640

632-
async isInstalled(home: string) {
641+
async isInstalled(home: string, env?: AdapterEnv) {
633642
return isHooksJsonInstalled(
634-
path.join(home, CLAUDE_PATH, 'settings.json'),
643+
path.join(claudeConfigDir(home, env), 'settings.json'),
635644
'codetime hook --agent claude',
636645
)
637646
},
638647

639-
installEntries(home: string): InstallEntry[] {
648+
installEntries(home: string, env?: AdapterEnv): InstallEntry[] {
640649
return [{
641650
kind: 'hooks-json',
642-
path: path.join(home, CLAUDE_PATH, 'settings.json'),
651+
path: path.join(claudeConfigDir(home, env), 'settings.json'),
643652
content: hookConfig(),
644653
}]
645654
},
646655

647-
sourcePaths(home: string): string[] {
656+
sourcePaths(home: string, env?: AdapterEnv): string[] {
657+
const base = claudeConfigDir(home, env)
658+
// .claude.json (project trust/state) historically lived alongside the
659+
// home dir, but CLAUDE_CONFIG_DIR also relocates it.
648660
return [
649-
path.join(home, '.claude', 'projects'),
661+
path.join(base, 'projects'),
662+
path.join(base, '.claude.json'),
650663
path.join(home, '.claude.json'),
651664
]
652665
},

packages/cli/src/adapters/codex.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CanonicalEvent } from '@codetime/shared'
2-
import type { AgentAdapter, InstallEntry } from './types.js'
2+
import type { AdapterEnv, AgentAdapter, InstallEntry } from './types.js'
33
import { readFile } from 'node:fs/promises'
44
import path from 'node:path'
55
import {
@@ -514,41 +514,50 @@ function hookConfig(): object {
514514

515515
// ── Adapter factory ──
516516

517-
export function createCodexAdapter(): AgentAdapter {
518-
const CODE_PATH = '.codex'
517+
// Codex relocates its entire data dir via CODEX_HOME (config.toml, auth.json,
518+
// sessions/, history.jsonl). Honor it for both install and backfill paths.
519+
function codexHome(home: string, env?: AdapterEnv): string {
520+
const override = env?.CODEX_HOME
521+
if (override && override.trim()) {
522+
return path.resolve(override)
523+
}
524+
return path.join(home, '.codex')
525+
}
519526

527+
export function createCodexAdapter(): AgentAdapter {
520528
return {
521529
id: 'codex',
522530
label: 'Codex',
523531
agentName: 'codex',
524532
kind: 'agent',
525533

526-
detectPath(home: string) {
527-
return path.join(home, CODE_PATH)
534+
detectPath(home: string, env?: AdapterEnv) {
535+
return codexHome(home, env)
528536
},
529-
installedPath(home: string) {
530-
return path.join(home, CODE_PATH, 'hooks.json')
537+
installedPath(home: string, env?: AdapterEnv) {
538+
return path.join(codexHome(home, env), 'hooks.json')
531539
},
532540

533-
async isInstalled(home: string) {
541+
async isInstalled(home: string, env?: AdapterEnv) {
534542
return isHooksJsonInstalled(
535-
path.join(home, CODE_PATH, 'hooks.json'),
543+
path.join(codexHome(home, env), 'hooks.json'),
536544
'codetime hook --agent codex',
537545
)
538546
},
539547

540-
installEntries(home: string): InstallEntry[] {
548+
installEntries(home: string, env?: AdapterEnv): InstallEntry[] {
541549
return [{
542550
kind: 'hooks-json',
543-
path: path.join(home, CODE_PATH, 'hooks.json'),
551+
path: path.join(codexHome(home, env), 'hooks.json'),
544552
content: hookConfig(),
545553
}]
546554
},
547555

548-
sourcePaths(home: string): string[] {
556+
sourcePaths(home: string, env?: AdapterEnv): string[] {
557+
const base = codexHome(home, env)
549558
return [
550-
path.join(home, '.codex', 'sessions'),
551-
path.join(home, '.codex', 'history.jsonl'),
559+
path.join(base, 'sessions'),
560+
path.join(base, 'history.jsonl'),
552561
]
553562
},
554563

packages/cli/src/adapters/opencode.ts

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CanonicalEvent, MetricBag } from '@codetime/shared'
2-
import type { AgentAdapter, InstallEntry } from './types.js'
2+
import type { AdapterEnv, AgentAdapter, InstallEntry } from './types.js'
33
import os from 'node:os'
44
import path from 'node:path'
55
import {
@@ -422,10 +422,40 @@ function opencodeUsageFromInfo(info: Record<string, unknown>): OpenCodeUsage | u
422422
}
423423
}
424424

425+
// ── Path resolution ──
426+
427+
// OpenCode follows XDG for its config (~/.config/opencode) and data
428+
// (~/.local/share/opencode), but also reads OPENCODE_CONFIG_DIR for the former.
429+
// Both can move independently — agents, plugins, and history.
430+
431+
function opencodeConfigDir(home: string, env?: AdapterEnv): string {
432+
const override = env?.OPENCODE_CONFIG_DIR
433+
if (override && override.trim()) {
434+
return path.resolve(override)
435+
}
436+
const xdgConfig = env?.XDG_CONFIG_HOME
437+
if (xdgConfig && xdgConfig.trim()) {
438+
return path.join(path.resolve(xdgConfig), 'opencode')
439+
}
440+
return path.join(home, '.config', 'opencode')
441+
}
442+
443+
function opencodeDataCandidates(home: string, env?: AdapterEnv): string[] {
444+
const xdgData = env?.XDG_DATA_HOME
445+
const primary = xdgData && xdgData.trim()
446+
? path.join(path.resolve(xdgData), 'opencode', 'opencode.db')
447+
: path.join(home, '.local', 'share', 'opencode', 'opencode.db')
448+
// Keep the legacy ~/.opencode/opencode.db location as a fallback for older
449+
// installs that haven't migrated to the XDG data dir.
450+
return [primary, path.join(home, '.opencode', 'opencode.db')]
451+
}
452+
425453
// ── Backfill file discovery (special: OpenCode uses SQLite, not JSONL) ──
426454

427455
export async function opencodeBackfillFiles(
428456
sourceRoot?: string,
457+
home: string = os.homedir(),
458+
env?: AdapterEnv,
429459
): Promise<Array<{ path: string, modifiedAt: string }>> {
430460
const { stat } = await import('node:fs/promises')
431461
if (sourceRoot) {
@@ -439,11 +469,7 @@ export async function opencodeBackfillFiles(
439469
return [{ path: sourceRoot, modifiedAt: info.mtime.toISOString() }]
440470
}
441471

442-
const candidates = [
443-
path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db'),
444-
path.join(os.homedir(), '.opencode', 'opencode.db'),
445-
]
446-
for (const candidatePath of candidates) {
472+
for (const candidatePath of opencodeDataCandidates(home, env)) {
447473
const info = await stat(candidatePath).catch(() => null)
448474
if (info) {
449475
return [{ path: candidatePath, modifiedAt: info.mtime.toISOString() }]
@@ -503,7 +529,6 @@ export const AgentTime = async ({ $, directory }) => {
503529
// ── Adapter factory ──
504530

505531
export function createOpenCodeAdapter(): AgentAdapter {
506-
const OPENCODE_CONFIG = '.config/opencode'
507532
const PLUGIN_PATH = 'plugins/codetime.mjs'
508533

509534
return {
@@ -512,37 +537,34 @@ export function createOpenCodeAdapter(): AgentAdapter {
512537
agentName: 'opencode',
513538
kind: 'agent',
514539

515-
detectPath(home: string) {
516-
return path.join(home, OPENCODE_CONFIG)
540+
detectPath(home: string, env?: AdapterEnv) {
541+
return opencodeConfigDir(home, env)
517542
},
518-
installedPath(home: string) {
519-
return path.join(home, OPENCODE_CONFIG, PLUGIN_PATH)
543+
installedPath(home: string, env?: AdapterEnv) {
544+
return path.join(opencodeConfigDir(home, env), PLUGIN_PATH)
520545
},
521546

522-
async isInstalled(home: string) {
547+
async isInstalled(home: string, env?: AdapterEnv) {
523548
try {
524549
const { pathExists } = await import('../lib/fs.js')
525-
return await pathExists(path.join(home, OPENCODE_CONFIG, PLUGIN_PATH))
550+
return await pathExists(path.join(opencodeConfigDir(home, env), PLUGIN_PATH))
526551
|| await pathExists(path.join('.opencode', PLUGIN_PATH))
527552
}
528553
catch {
529554
return false
530555
}
531556
},
532557

533-
installEntries(home: string): InstallEntry[] {
558+
installEntries(home: string, env?: AdapterEnv): InstallEntry[] {
534559
return [{
535560
kind: 'file',
536-
path: path.join(home, OPENCODE_CONFIG, PLUGIN_PATH),
561+
path: path.join(opencodeConfigDir(home, env), PLUGIN_PATH),
537562
content: opencodePluginContent(),
538563
}]
539564
},
540565

541-
sourcePaths(home: string): string[] {
542-
return [
543-
path.join(home, '.local', 'share', 'opencode', 'opencode.db'),
544-
path.join(home, '.opencode', 'opencode.db'),
545-
]
566+
sourcePaths(home: string, env?: AdapterEnv): string[] {
567+
return opencodeDataCandidates(home, env)
546568
},
547569

548570
parseSessionFile: parseOpenCodeSessionFile,

packages/cli/src/adapters/pi.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CanonicalEvent, FileActivityRecord, MetricBag } from '@codetime/shared'
2-
import type { AgentAdapter, InstallEntry } from './types.js'
2+
import type { AdapterEnv, AgentAdapter, InstallEntry } from './types.js'
33
import { readFile } from 'node:fs/promises'
44
import path from 'node:path'
55
import {
@@ -528,42 +528,59 @@ export default function (pi: ExtensionAPI) {
528528

529529
// ── Adapter factory ──
530530

531-
export function createPiAdapter(): AgentAdapter {
532-
const PI_EXTENSIONS_DIR = '.pi/agent/extensions'
531+
// Pi exposes two independent overrides: PI_CODING_AGENT_DIR moves the whole
532+
// agent dir (extensions, config), while PI_CODING_AGENT_SESSION_DIR can shift
533+
// just the sessions folder. Resolve them separately so codetime can find each.
534+
function piAgentDir(home: string, env?: AdapterEnv): string {
535+
const override = env?.PI_CODING_AGENT_DIR
536+
if (override && override.trim()) {
537+
return path.resolve(override)
538+
}
539+
return path.join(home, '.pi', 'agent')
540+
}
533541

542+
function piSessionDir(home: string, env?: AdapterEnv): string {
543+
const override = env?.PI_CODING_AGENT_SESSION_DIR
544+
if (override && override.trim()) {
545+
return path.resolve(override)
546+
}
547+
return path.join(piAgentDir(home, env), 'sessions')
548+
}
549+
550+
export function createPiAdapter(): AgentAdapter {
534551
return {
535552
id: 'pi',
536553
label: 'Pi',
537554
agentName: 'pi',
538555
kind: 'agent',
539556

540-
detectPath(home: string) {
541-
return path.join(home, '.pi', 'agent')
557+
detectPath(home: string, env?: AdapterEnv) {
558+
return piAgentDir(home, env)
542559
},
543-
installedPath(home: string) {
544-
return path.join(home, PI_EXTENSIONS_DIR, 'codetime.ts')
560+
installedPath(home: string, env?: AdapterEnv) {
561+
return path.join(piAgentDir(home, env), 'extensions', 'codetime.ts')
545562
},
546563

547-
async isInstalled(home: string) {
564+
async isInstalled(home: string, env?: AdapterEnv) {
548565
try {
549566
const { pathExists } = await import('../lib/fs.js')
550-
return await pathExists(path.join(home, PI_EXTENSIONS_DIR, 'codetime.ts'))
567+
return await pathExists(path.join(piAgentDir(home, env), 'extensions', 'codetime.ts'))
551568
}
552569
catch {
553570
return false
554571
}
555572
},
556573

557-
installEntries(home: string): InstallEntry[] {
574+
installEntries(home: string, env?: AdapterEnv): InstallEntry[] {
558575
return [{
559576
kind: 'file',
560-
path: path.join(home, PI_EXTENSIONS_DIR, 'codetime.ts'),
577+
path: path.join(piAgentDir(home, env), 'extensions', 'codetime.ts'),
561578
content: piExtensionContent(),
562579
}]
563580
},
564581

565-
sourcePaths(home: string): string[] {
566-
return [path.join(home, '.pi', 'agent', 'sessions')]
582+
sourcePaths(home: string, env?: AdapterEnv): string[] {
583+
return [piSessionDir(home, env)]
567584
},
568585

569586
parseSessionFile: parsePiSessionFile,

packages/cli/src/adapters/types.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ export interface InstallEntry {
77
content: string | object
88
}
99

10+
/**
11+
* Environment shape consumed by adapters when resolving paths. Adapters honor
12+
* the env vars exposed by each upstream agent so codetime tracks sessions
13+
* even when users relocate their config/data directories.
14+
*/
15+
export type AdapterEnv = Record<string, string | undefined>
16+
1017
export interface AgentAdapter {
1118
/** Unique identifier matching BackfillSourceId */
1219
readonly id: BackfillSourceId
@@ -20,18 +27,18 @@ export interface AgentAdapter {
2027
// ── Detection & Installation ──
2128

2229
/** Path whose existence indicates the agent is installed */
23-
detectPath: (home: string) => string
30+
detectPath: (home: string, env?: AdapterEnv) => string
2431
/** Path of the codetime integration file when installed */
25-
installedPath: (home: string) => string
32+
installedPath: (home: string, env?: AdapterEnv) => string
2633
/** Whether codetime integration is already installed */
27-
isInstalled: (home: string) => Promise<boolean>
34+
isInstalled: (home: string, env?: AdapterEnv) => Promise<boolean>
2835
/** Installation entries to write during `codetime install` */
29-
installEntries: (home: string) => InstallEntry[]
36+
installEntries: (home: string, env?: AdapterEnv) => InstallEntry[]
3037

3138
// ── Backfill ──
3239

3340
/** Directories/files containing historical session data */
34-
sourcePaths: (home: string) => string[]
41+
sourcePaths: (home: string, env?: AdapterEnv) => string[]
3542
/** Parse a single session file into canonical events, or null if unsupported */
3643
parseSessionFile?: (
3744
filePath: string,

0 commit comments

Comments
 (0)