docks-kit 0.15.5 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,7 @@ import {
22
22
  syncClaudeModel
23
23
  } from "./claudeSettingsModifiers"
24
24
  import { claudeRuntimePaths, materializeClaudeSettings, type ClaudeRuntimePaths } from "./claudeRuntime"
25
+ import { RETIRED_PERMISSION_RULES } from "./claudeRetired"
25
26
  import { p, spawnProcess, writeBytesIfChanged, writeTextIfChanged } from "./exec"
26
27
  import type { Ctx } from "./index"
27
28
  import { compareCodepoints, deepMerge, isObject, jqStringify, parseJson, type Json } from "./jq"
@@ -398,8 +399,14 @@ const REMOVED_MANIFEST = {
398
399
  "hooks.PreToolUse"
399
400
  ],
400
401
  permissionRules: {
401
- allow: ["Write(./)", "Bash(rtk *)"],
402
- deny: ["Write(**/.env)", "Write(**/.env.local)", "Write(**/secrets/**)"]
402
+ allow: ["Write(./)", "Bash(rtk *)", ...RETIRED_PERMISSION_RULES.allow],
403
+ deny: [
404
+ "Write(**/.env)",
405
+ "Write(**/.env.local)",
406
+ "Write(**/secrets/**)",
407
+ ...RETIRED_PERMISSION_RULES.deny
408
+ ],
409
+ ask: [...RETIRED_PERMISSION_RULES.ask]
403
410
  },
404
411
  claudeJsonKeys: [] as Array<string>,
405
412
  /** Home-relative artifacts the kit installed outside ~/.claude. */
@@ -447,14 +454,14 @@ function pruneJsonKeys(ctx: Ctx, file: string, keys: Array<string>): number {
447
454
  function prunePermissionRules(
448
455
  ctx: Ctx,
449
456
  file: string,
450
- rules: Readonly<Record<"allow" | "deny", ReadonlyArray<string>>>
457
+ rules: Readonly<Record<"allow" | "deny" | "ask", ReadonlyArray<string>>>
451
458
  ): number {
452
459
  if (!existsSync(file)) return 0
453
460
  const doc = parseJson(readFileSync(file, "utf8"))
454
461
  if (doc === undefined || !isObject(doc) || !isObject(doc["permissions"])) return 0
455
462
  const permissions = doc["permissions"]
456
463
  let present = 0
457
- for (const key of ["allow", "deny"] as const) {
464
+ for (const key of ["allow", "deny", "ask"] as const) {
458
465
  const values = permissions[key]
459
466
  if (!Array.isArray(values)) continue
460
467
  const removed = new Set(rules[key])
@@ -20,6 +20,7 @@ export async function codexSync(ctx: Ctx): Promise<void> {
20
20
  await ensureBubblewrap(ctx)
21
21
  if (!ctx.dryRun) mkdirSync(codexDir, { recursive: true })
22
22
  syncConfig(ctx, sotConfig, userConfig)
23
+ removeRetiredImportedHooks(ctx, codexDir)
23
24
  syncCodexModel(ctx, ctx.codexModel)
24
25
  syncCodexEffort(ctx, ctx.codexEffort)
25
26
  syncRules(ctx, payloadPaths("SoT/.codex/rules/"), p(codexDir, "rules"))
@@ -29,6 +30,74 @@ export async function codexSync(ctx: Ctx): Promise<void> {
29
30
  await syncPlugins(ctx, sotConfig)
30
31
  }
31
32
 
33
+ const RETIRED_CONTEXT_HOOK = `echo "[CONTEXT] Current date: $(date '+%A, %Y-%m-%d %H:%M:%S %Z')"`
34
+ const RETIRED_SKILLS_HOOK = `SKILL_COUNT=$(find .claude/skills -name 'SKILL.md' -mindepth 2 -maxdepth 2 2>/dev/null | wc -l); [ "$SKILL_COUNT" -gt 0 ] && echo "[SKILLS] $SKILL_COUNT project skills available in .claude/skills/. Claude Code loads them on demand via Skill tool. After code changes affecting documented patterns, update the relevant skill and its metadata.updated field." || true`
35
+ const RETIRED_CONFIG_HOOKS = new Set([
36
+ `echo "[CONFIG] Context: $([ \\\"\${CLAUDE_CODE_DISABLE_1M_CONTEXT:-0}\\\" = \\\"1\\\" ] && echo '200K' || echo '1M') | Compact-window: \${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-full} | Effort: \${CLAUDE_CODE_EFFORT_LEVEL:-high} | Thinking: adaptive | Subagent: \${CLAUDE_CODE_SUBAGENT_MODEL:-default}"`,
37
+ `echo "[CONFIG] Context: $([ \\\"\${CLAUDE_CODE_DISABLE_1M_CONTEXT:-0}\\\" = \\\"1\\\" ] && echo '200K' || echo '1M') | Compact-window: \${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-full} | Effort: \${CLAUDE_CODE_EFFORT_LEVEL:-high} | Thinking: adaptive | Model: \${ANTHROPIC_DEFAULT_OPUS_MODEL:-default} | Subagent: \${CLAUDE_CODE_SUBAGENT_MODEL:-default}"`,
38
+ `echo "[CONFIG] Context: $([ \\\"\${CLAUDE_CODE_DISABLE_1M_CONTEXT:-0}\\\" = \\\"1\\\" ] && echo '200K' || echo '1M') | Compact-window: \${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-full} | Effort: \${CLAUDE_CODE_EFFORT_LEVEL:-$(jq -r .effortLevel $HOME/.claude/settings.json 2>/dev/null || echo default)} | Thinking: adaptive | Subagent: \${CLAUDE_CODE_SUBAGENT_MODEL:-default}"`
39
+ ])
40
+
41
+ function isRetiredImportedSessionStartHook(handler: Json, codexDir: string): boolean {
42
+ if (!isObject(handler) || handler["type"] !== "command" || typeof handler["command"] !== "string") return false
43
+ const command = handler["command"]
44
+ const connectorScript = p(codexDir, "hooks", "disable-claudeai-connectors.sh")
45
+ const retiredConnectorHook = `[ -x '${connectorScript}' ] && '${connectorScript}' || true`
46
+ return command === RETIRED_CONTEXT_HOOK || command === RETIRED_SKILLS_HOOK || RETIRED_CONFIG_HOOKS.has(command) || command === retiredConnectorHook
47
+ }
48
+
49
+ function withoutRetiredImportedHooks(doc: { [key: string]: Json }, codexDir: string): { readonly value: Json; readonly removed: number } {
50
+ const hooks = doc["hooks"]
51
+ if (!isObject(hooks) || !Array.isArray(hooks["SessionStart"])) return { value: doc, removed: 0 }
52
+
53
+ let removed = 0
54
+ const groups: Array<Json> = []
55
+ for (const group of hooks["SessionStart"]) {
56
+ if (!isObject(group) || !Array.isArray(group["hooks"])) {
57
+ groups.push(group)
58
+ continue
59
+ }
60
+ const kept = group["hooks"].filter((handler) => {
61
+ if (!isRetiredImportedSessionStartHook(handler, codexDir)) return true
62
+ removed++
63
+ return false
64
+ })
65
+ if (kept.length > 0) groups.push({ ...group, hooks: kept })
66
+ }
67
+ if (removed === 0) return { value: doc, removed: 0 }
68
+
69
+ const nextHooks = { ...hooks }
70
+ if (groups.length > 0) nextHooks["SessionStart"] = groups
71
+ else delete nextHooks["SessionStart"]
72
+ return { value: { ...doc, hooks: nextHooks }, removed }
73
+ }
74
+
75
+ /** Remove only Claude hooks that docks-kit retired before Codex could import them safely. */
76
+ function removeRetiredImportedHooks(ctx: Ctx, codexDir: string): void {
77
+ const { change, echo, warn } = ctx.services.logger
78
+ const hooksFile = p(codexDir, "hooks.json")
79
+ if (!existsSync(hooksFile)) return
80
+ const before = readFileSync(hooksFile, "utf8")
81
+ const doc = parseJson(before)
82
+ if (doc === undefined || !isObject(doc)) {
83
+ warn(`Codex hooks file is not a valid JSON object; retired hook cleanup skipped: ${hooksFile}`)
84
+ return
85
+ }
86
+ const result = withoutRetiredImportedHooks(doc, codexDir)
87
+ if (result.removed === 0) return
88
+
89
+ if (ctx.dryRun) {
90
+ echo(`[dry-run] remove ${result.removed} retired imported docks-kit SessionStart hook(s) from ${hooksFile}`)
91
+ return
92
+ }
93
+
94
+ copyFileSync(hooksFile, `${hooksFile}.bak`)
95
+ writeFileSync(`${hooksFile}.tmp`, jqStringify(result.value))
96
+ renameSync(`${hooksFile}.tmp`, hooksFile)
97
+ change(`Codex: removed ${result.removed} retired imported docks-kit SessionStart hook(s) (backup at hooks.json.bak)`)
98
+ ctx.nextStepTriggers.codexRestart = true
99
+ }
100
+
32
101
  // ---------------------------------------------------------- bubblewrap ----
33
102
 
34
103
  async function ensureBubblewrap(ctx: Ctx): Promise<void> {
@@ -23,6 +23,7 @@ export type ToolId =
23
23
  | "npx"
24
24
  | "claude"
25
25
  | "codex"
26
+ | "omp"
26
27
  | "bun"
27
28
  | "bwrap"
28
29
  | "ffplay"
@@ -181,6 +182,12 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
181
182
  (pf = rawPlatform()) => hostOs(platformName(pf)).installHint("codex"),
182
183
  { version: versionProbe("codex") }
183
184
  ),
185
+ omp: spec(
186
+ "omp",
187
+ "optional",
188
+ () => "install omp from https://github.com/can1357/oh-my-pi (an existing install self-updates with `omp update`)",
189
+ { version: versionProbe("omp", ["--version"], (out) => out.trim().replace(/^omp[/v]/, "")) }
190
+ ),
184
191
  bun: spec(
185
192
  "bun",
186
193
  "optional",
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Per-machine harness selection at ~/.docks-kit/state.json. The selection keeps
3
+ * the omp harness opt-in. A missing or unreadable state file is represented by
4
+ * undefined so callers resolve it to LEGACY_SELECTION and existing machines
5
+ * keep today's behavior.
6
+ */
7
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
8
+ import { homedir } from "node:os"
9
+
10
+ import { p } from "./exec"
11
+
12
+ export type Harness = "claude" | "codex" | "agents" | "omp"
13
+
14
+ export const HARNESSES: ReadonlyArray<Harness> = ["claude", "codex", "agents", "omp"]
15
+ export const LEGACY_SELECTION: ReadonlyArray<Harness> = ["claude", "codex", "agents"]
16
+
17
+ function isHarness(value: unknown): value is Harness {
18
+ return value === "claude" || value === "codex" || value === "agents" || value === "omp"
19
+ }
20
+
21
+ function normalizeHarnesses(selection: ReadonlyArray<unknown>): Array<Harness> {
22
+ const selected = new Set<Harness>()
23
+ for (const value of selection) {
24
+ if (isHarness(value)) selected.add(value)
25
+ }
26
+ return HARNESSES.filter((harness) => selected.has(harness))
27
+ }
28
+
29
+ /** Resolve the engine home root from HOME with the platform home as fallback. */
30
+ export function engineHome(env: NodeJS.ProcessEnv = process.env): string {
31
+ const home = env["HOME"]
32
+ return home !== undefined && home !== "" ? home : homedir()
33
+ }
34
+
35
+ export function harnessStateFile(home: string): string {
36
+ return p(home, ".docks-kit", "state.json")
37
+ }
38
+
39
+ /** Read valid local state without allowing corruption to make sync unusable. */
40
+ export function readHarnessSelection(home: string): ReadonlyArray<Harness> | undefined {
41
+ let parsed: unknown
42
+ try {
43
+ parsed = JSON.parse(readFileSync(harnessStateFile(home), "utf8")) as unknown
44
+ } catch {
45
+ return undefined
46
+ }
47
+
48
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
49
+ const state = parsed as Record<string, unknown>
50
+ if (state["version"] !== 1 || !Array.isArray(state["harnesses"])) return undefined
51
+
52
+ const selection = normalizeHarnesses(state["harnesses"])
53
+ return selection.length > 0 ? selection : undefined
54
+ }
55
+
56
+ export function writeHarnessSelection(home: string, selection: ReadonlyArray<Harness>): void {
57
+ if (selection.length === 0) {
58
+ throw new Error("Cannot write an empty harness selection because sync would become a no-op")
59
+ }
60
+
61
+ const harnesses = normalizeHarnesses(selection)
62
+ if (harnesses.length === 0) {
63
+ throw new Error("Harness selection must contain at least one known harness name")
64
+ }
65
+
66
+ const directory = p(home, ".docks-kit")
67
+ const file = harnessStateFile(home)
68
+ const text = `${JSON.stringify({ version: 1, harnesses }, null, 2)}\n`
69
+ // `mode` applies only when mkdir creates the path, so an existing permissive
70
+ // ~/.docks-kit would keep its mode.
71
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
72
+ chmodSync(directory, 0o700)
73
+ writeFileSync(file, text, { mode: 0o600 })
74
+ chmodSync(file, 0o600)
75
+ }
@@ -6,7 +6,7 @@
6
6
  * vocabulary directly.
7
7
  */
8
8
  import { p } from "./exec"
9
- import { homedir } from "node:os"
9
+ import { engineHome } from "./harnesses"
10
10
 
11
11
  import { kitHome } from "../kitHome"
12
12
  import { payloadText } from "../payload"
@@ -15,6 +15,7 @@ import type { TerminalLease } from "./logger"
15
15
  import type { BunRuntimeState } from "./bun"
16
16
  import { claudeNextSteps, claudeSummary, claudeSync, type ClaudeRuntimeState } from "./claudeSync"
17
17
  import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
18
+ import { ompNextSteps, ompSummary, ompSync, type OmpState } from "./ompSync"
18
19
  import { normalizeManifest, skillsNextSteps, skillsSummary, skillsSync, type SkillsState } from "./skillsSync"
19
20
  import { modeModel, modeToolchain } from "./modes"
20
21
  import { ExitError, parseArgs, parseClaudePlugin, parseCompactWindow, validateModifierFlags } from "./parseArgs"
@@ -81,6 +82,7 @@ export interface Ctx {
81
82
  readonly repoDir: string
82
83
  readonly home: string
83
84
  readonly agentsDir: string
85
+ readonly interactive: boolean
84
86
  dryRun: boolean
85
87
  verbose: boolean
86
88
  skipBubblewrap: boolean
@@ -106,19 +108,21 @@ export interface Ctx {
106
108
  syncClaude: boolean
107
109
  syncCodex: boolean
108
110
  syncAgents: boolean
111
+ syncOmp: boolean
109
112
  /** Per-run next-step triggers (Output Policy): advice prints only when its trigger changed or --verbose. */
110
113
  readonly nextStepTriggers: {
111
114
  claudePlugins: boolean
112
115
  claudeRestart: boolean
113
116
  codexRestart: boolean
114
117
  skillsRestart: boolean
118
+ ompRestart: boolean
115
119
  }
116
120
  }
117
121
 
118
122
  /** Globals default from env using the historical ${VAR:-default} contract. */
119
123
  function makeCtx(services: EngineServices): Ctx {
120
124
  const env = process.env
121
- const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir()
125
+ const home = engineHome(env)
122
126
  const compactWindowSource = env["CLAUDE_COMPACT_WINDOW"] ?? ""
123
127
  const claudeCompactWindow = compactWindowSource === "" ? "" : parseCompactWindow(compactWindowSource)
124
128
  if (claudeCompactWindow === undefined) {
@@ -133,6 +137,11 @@ function makeCtx(services: EngineServices): Ctx {
133
137
  repoDir: kitHome(),
134
138
  home,
135
139
  agentsDir: env["AGENTS_DIR"] !== undefined && env["AGENTS_DIR"] !== "" ? env["AGENTS_DIR"] : p(home, ".agents"),
140
+ interactive:
141
+ env["DOCKS_KIT_INTERACTIVE"] === "1" ||
142
+ (env["DOCKS_KIT_INTERACTIVE"] !== "0" &&
143
+ process.stdout.isTTY === true &&
144
+ process.stdin.isTTY === true),
136
145
  dryRun: env["DRY_RUN"] === "1",
137
146
  verbose: env["DOCKS_KIT_VERBOSE"] === "1",
138
147
  skipBubblewrap: env["SKIP_BUBBLEWRAP"] === "1",
@@ -154,7 +163,14 @@ function makeCtx(services: EngineServices): Ctx {
154
163
  syncClaude: false,
155
164
  syncCodex: false,
156
165
  syncAgents: false,
157
- nextStepTriggers: { claudePlugins: false, claudeRestart: false, codexRestart: false, skillsRestart: false }
166
+ syncOmp: false,
167
+ nextStepTriggers: {
168
+ claudePlugins: false,
169
+ claudeRestart: false,
170
+ codexRestart: false,
171
+ skillsRestart: false,
172
+ ompRestart: false
173
+ }
158
174
  }
159
175
  }
160
176
 
@@ -181,6 +197,7 @@ async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number
181
197
  | { readonly kind: "claude"; readonly runtime: ClaudeRuntimeState }
182
198
  | { readonly kind: "codex" }
183
199
  | { readonly kind: "skills"; readonly state: SkillsState }
200
+ | { readonly kind: "omp"; readonly state: OmpState }
184
201
  interface SelectedPipeline {
185
202
  readonly name: string
186
203
  readonly run: SyncTask<PipelineResult>
@@ -208,6 +225,12 @@ async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number
208
225
  run: async () => ({ kind: "skills", state: await skillsSync(ctx) })
209
226
  })
210
227
  }
228
+ if (ctx.syncOmp) {
229
+ selected.push({
230
+ name: "omp",
231
+ run: async () => ({ kind: "omp", state: await ompSync(ctx) })
232
+ })
233
+ }
211
234
 
212
235
  // A populated skills manifest deploys with `-a claude-code codex`, and symlink healing also writes into Claude's tree.
213
236
  ctx.syncConcurrency = syncConcurrencyForManifest(
@@ -243,9 +266,11 @@ async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number
243
266
  const codexRan = ctx.syncCodex
244
267
  let claudeRuntime: ClaudeRuntimeState | undefined
245
268
  let skillsState: SkillsState | undefined
269
+ let ompState: OmpState | undefined
246
270
  for (const result of results) {
247
271
  if (result.kind === "claude") claudeRuntime = result.runtime
248
272
  else if (result.kind === "skills") skillsState = result.state
273
+ else if (result.kind === "omp") ompState = result.state
249
274
  }
250
275
 
251
276
  echo("")
@@ -254,11 +279,13 @@ async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number
254
279
  if (claudeRuntime !== undefined) claudeSummary(ctx, claudeRuntime)
255
280
  if (codexRan) codexSummary(ctx)
256
281
  if (skillsState !== undefined) skillsSummary(ctx, skillsState)
282
+ if (ompState !== undefined) ompSummary(ctx, ompState)
257
283
 
258
284
  const advice = [
259
285
  ...(claudeRan ? claudeNextSteps(ctx) : []),
260
286
  ...(codexRan ? codexNextSteps(ctx) : []),
261
- ...(skillsState !== undefined ? skillsNextSteps(ctx) : [])
287
+ ...(skillsState !== undefined ? skillsNextSteps(ctx) : []),
288
+ ...(ompState !== undefined ? ompNextSteps(ctx) : [])
262
289
  ]
263
290
  if (advice.length > 0) {
264
291
  echo("")
@@ -0,0 +1,101 @@
1
+ /**
2
+ * omp path resolution, mirroring upstream `packages/utils/src/dirs.ts`
3
+ * `DirResolver`. The kit deploys into two different roots and must not guess
4
+ * either one: the agent directory holds `AGENTS.md`, `config.yml`, and
5
+ * `mcp.json`; the data root holds `marketplaces.json` and `plugins/`.
6
+ *
7
+ * Resolution reads the environment and probes directory existence only, so a
8
+ * dry run stays free of omp subcommands.
9
+ */
10
+ import { existsSync } from "node:fs"
11
+ import { isAbsolute, resolve } from "node:path"
12
+
13
+ import { p } from "./exec"
14
+
15
+ /** Upstream CONFIG_DIR_NAME; PI_CONFIG_DIR renames this directory under home. */
16
+ const DEFAULT_CONFIG_DIR_NAME = ".omp"
17
+ /** Upstream APP_NAME, the fixed segment under an XDG category root. */
18
+ const APP_NAME = "omp"
19
+ /** Upstream PROFILE_NAME_RE. An invalid name degrades to the default profile. */
20
+ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/
21
+ /** Windows device aliases upstream rejects, bare or with any extension. */
22
+ const WINDOWS_RESERVED_BASENAME_RE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i
23
+
24
+ export interface OmpPaths {
25
+ /** undefined for the default profile, else the normalized profile name */
26
+ readonly profile: string | undefined
27
+ /** config root for the active profile */
28
+ readonly configRoot: string
29
+ /** directory holding AGENTS.md, config.yml, mcp.json - never XDG-redirected */
30
+ readonly agentDir: string
31
+ /** root holding marketplaces.json and plugins/ - XDG-redirected when adopted */
32
+ readonly dataRoot: string
33
+ }
34
+
35
+ export interface OmpPathInputs {
36
+ readonly home: string
37
+ readonly env: Record<string, string | undefined>
38
+ readonly platform: NodeJS.Platform
39
+ }
40
+
41
+ /**
42
+ * Upstream `normalizeProfileName`, minus the throw: an invalid value reaches
43
+ * `readProfileFromEnvSafe`, which degrades to the default profile so a bad env
44
+ * var cannot crash a bare import.
45
+ */
46
+ function normalizeProfile(value: string | undefined): string | undefined {
47
+ const name = value?.trim()
48
+ if (name === undefined || name === "" || name === "default") return undefined
49
+ if (name === "." || name === ".." || name.endsWith(".")) return undefined
50
+ if (!PROFILE_NAME_RE.test(name) || WINDOWS_RESERVED_BASENAME_RE.test(name)) return undefined
51
+ return name
52
+ }
53
+
54
+ /** Upstream applies `path.resolve` to PI_CODING_AGENT_DIR, so cwd anchors a relative value. */
55
+ function resolveAgentOverride(value: string): string {
56
+ return isAbsolute(value) ? value : resolve(process.cwd(), value)
57
+ }
58
+
59
+ export function ompPaths({ home, env, platform }: OmpPathInputs): OmpPaths {
60
+ // OMP_PROFILE wins whenever it is defined, including when explicitly empty;
61
+ // PI_PROFILE is only the legacy fallback.
62
+ const profile = normalizeProfile(env["OMP_PROFILE"] !== undefined ? env["OMP_PROFILE"] : env["PI_PROFILE"])
63
+
64
+ // PI_CONFIG_DIR is a config root dirname joined under home, not a path.
65
+ const configDirName = env["PI_CONFIG_DIR"]
66
+ const baseRoot = p(home, configDirName !== undefined && configDirName !== "" ? configDirName : DEFAULT_CONFIG_DIR_NAME)
67
+ const configRoot = profile === undefined ? baseRoot : p(baseRoot, "profiles", profile)
68
+
69
+ // A named profile derives its own agent directory and ignores the override.
70
+ const agentOverride = env["PI_CODING_AGENT_DIR"]
71
+ const defaultAgentDir = p(configRoot, "agent")
72
+ const agentDir = profile === undefined && agentOverride !== undefined && agentOverride !== ""
73
+ ? resolveAgentOverride(agentOverride)
74
+ : defaultAgentDir
75
+
76
+ return { profile, configRoot, agentDir, dataRoot: xdgDataRoot(env, platform, profile, agentDir === defaultAgentDir) ?? configRoot }
77
+ }
78
+
79
+ /**
80
+ * XDG is a Linux and macOS convention upstream disables whenever an agent-dir
81
+ * override is active, and adopts a category only once its omp directory
82
+ * exists - `omp config init-xdg` creates it without moving existing data. A
83
+ * named profile keys on its own `profiles/<name>` path and adopts that path,
84
+ * so a profile stays where it was first activated.
85
+ */
86
+ function xdgDataRoot(
87
+ env: Record<string, string | undefined>,
88
+ platform: NodeJS.Platform,
89
+ profile: string | undefined,
90
+ agentDirIsDefault: boolean
91
+ ): string | undefined {
92
+ if (!agentDirIsDefault) return undefined
93
+ if (platform !== "linux" && platform !== "darwin") return undefined
94
+
95
+ const dataHome = env["XDG_DATA_HOME"]
96
+ if (dataHome === undefined || dataHome === "") return undefined
97
+
98
+ const appRoot = p(dataHome, APP_NAME)
99
+ const candidate = profile === undefined ? appRoot : p(appRoot, "profiles", profile)
100
+ return existsSync(candidate) ? candidate : undefined
101
+ }