diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4fe70c171..bdef5187c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,13 @@ - Added `read.summarize.minTotalLines` setting (default 100) to set the minimum file length that triggers read summarization - Added `:` support to `search` `paths`, allowing file-scoped constraints such as `:N-M`, `:N+K`, and comma-separated ranges +- Added `--profile ` / `OMP_PROFILE` support to isolate agent state (auth credentials, sessions, settings, caches, history, memories, and blobs) under a named profile. +- Added `--alias ` support for generating shell shortcuts like `omp-work` that forward to `omp --profile ` while preserving subcommands such as `update` and `--version`. +- Added `OMP_MCP_TIMEOUT_MS` environment variable to override MCP client request timeout for every server (in milliseconds); set to `0` to disable client-side timeouts. Invalid (negative or non-numeric) values are ignored with a warning and fall back to the per-server timeout or default 30s ([#1415](https://github.com/can1357/oh-my-pi/pull/1415)). +- Added interactive provider selection to `omp auth-broker logout` when no provider argument is supplied +- Added `--json` flag to `omp auth-broker list` for machine-readable output +- Added `omp auth-broker list` to enumerate supported OAuth providers (replaces `bunx @oh-my-pi/pi-ai list`). +- Added interactive provider selection to `omp auth-broker login` and `omp auth-broker logout` when no provider argument is supplied (replaces `bunx @oh-my-pi/pi-ai login` / `logout` interactive flows). ### Changed @@ -90,6 +97,14 @@ - Fixed auto-handoff race at the context threshold: when `compaction.strategy = handoff` fired at `agent_end` with an active checkpoint or incomplete todos, the deferred handoff post-prompt task and the rewind/todo-completion path both scheduled work concurrently, so a fresh `agent.continue()` streamed a new assistant turn alongside the handoff LLM call (visible as the "Auto-handoff" loader plus an assistant message still streaming, with the chat container then rebuilt mid-stream). `#checkCompaction` now reports whether it deferred a handoff and the `agent_end` handler short-circuits the rewind/todo passes; `#scheduleAgentContinue` also skips when `isCompacting || isGeneratingHandoff`. The pre-prompt `#checkCompaction` call now forces inline execution (`allowDefer = false`) so the new turn cannot begin until the maintenance settles. - Fixed `/exit` and Ctrl+C-double-tap hanging when a deferred handoff was mid-flight: `AgentSession.dispose()` now aborts retry/compaction (auto-compaction + handoff) and the agent stream before draining `#cancelPostPromptTasks`, so the post-prompt task awaiting `generateHandoff` rejects and `Promise.allSettled` can resolve. Tool work (bash/eval/python) is intentionally still left for the existing dispose paths so shared kernels continue to survive across session dispose. +### Fixed + +- Fixed LSP startup for Node-based language servers installed with their own Node runtime when the shell `node` shim has no active version. +- Fixed the `--profile` / `--alias` bootstrap pre-parser consuming tokens that belong to other string-valued flags such as `--system-prompt`, `--api-key`, and `--model`. The pre-parser now mirrors `parseArgs` value-consumption rules and honors `--`, so `omp --system-prompt --profile foo` correctly treats `--profile` as the prompt body and `foo` as a positional message instead of silently activating profile `foo`. +- Fixed `setProfile(undefined)` (and `setProfile("default")`) deleting the user's `PI_CODING_AGENT_DIR` override. The pre-profile value is now snapshotted on first activation and restored on reset; `setAgentDir` refreshes the snapshot. +- Fixed named profiles silently relocating when `$XDG_*_HOME/omp` materialized after first activation. The XDG choice for a named profile is now keyed on the profile-specific XDG path, so the location is decided once and stays stable until the user migrates it explicitly. +- Fixed `setProfile` accepting Windows reserved device names (`CON`, `PRN`, `AUX`, `NUL`, `COM0-9`, `LPT0-9`, including dotted variants like `CON.txt`). Those now throw at validation time instead of failing later during directory creation. + ## [15.4.3] - 2026-05-26 ### Fixed diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 701352956..e67866633 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -1,16 +1,18 @@ #!/usr/bin/env bun -import { APP_NAME, MIN_BUN_VERSION, procmgr, VERSION } from "@oh-my-pi/pi-utils"; +import { APP_NAME, getActiveProfile, MIN_BUN_VERSION, setProfile, VERSION } from "@oh-my-pi/pi-utils/dirs"; // Strip macOS malloc-stack-logging env vars before any subprocess is spawned. // Otherwise every child bun process (subagents, plugin installs, ptree spawns, // etc.) prints a `MallocStackLogging: can't turn off …` warning to stderr. -procmgr.scrubProcessEnv(); +delete process.env.MallocStackLogging; +delete process.env.MallocStackLoggingNoCompact; /** * CLI entry point — registers all commands explicitly and delegates to the * lightweight CLI runner from pi-utils. */ import { type CliConfig, type CommandEntry, run } from "@oh-my-pi/pi-utils/cli"; +import { extractProfileFlags } from "./cli/profile-bootstrap"; if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) { process.stderr.write( @@ -61,6 +63,11 @@ function isSubcommand(first: string | undefined): boolean { return commands.some(e => e.name === first || e.aliases?.includes(first)); } +// Pre-parser lives in ./cli/profile-bootstrap. It strips the global --profile +// and --alias flags before any module imports modulators that resolve agent +// paths (notably @oh-my-pi/pi-utils/env, which eagerly loads .env from the +// agent dir during its own import). + /** * Smoke-test entry. Spawns the stats sync worker, pings it, exits. * @@ -81,20 +88,50 @@ async function runSmokeTest(): Promise { /** Run the CLI with the given argv (no `process.argv` prefix). */ export async function runCli(argv: string[]): Promise { - if (argv[0] === "--smoke-test") { + let resolvedArgv = argv; + try { + const extracted = extractProfileFlags(resolvedArgv); + resolvedArgv = extracted.argv; + if (extracted.profile !== undefined) { + setProfile(extracted.profile); + } + if (extracted.aliasName !== undefined) { + const profile = extracted.profile ?? getActiveProfile(); + if (!profile) { + throw new Error("--alias requires --profile or OMP_PROFILE"); + } + const { installProfileAlias } = await import("./cli/profile-alias"); + const result = await installProfileAlias({ profile, aliasName: extracted.aliasName }); + process.stdout.write( + `Created ${result.aliasName} for profile ${result.profile} in ${result.configPath}\n` + + `Restart your shell or run: ${result.reloadedWith}\n` + + `Then use: ${result.aliasName} update, ${result.aliasName} --version, or ${result.aliasName}\n`, + ); + return; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Error: ${message}\n`); + process.exitCode = 1; + return; + } + + if (resolvedArgv[0] === "--smoke-test") { await runSmokeTest(); return; } // --help and --version are handled by run() directly, don't rewrite those. // Everything else that isn't a known subcommand routes to "launch". - const first = argv[0]; + const first = resolvedArgv[0]; const runArgv = first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help" - ? argv + ? resolvedArgv : isSubcommand(first) - ? argv - : ["launch", ...argv]; + ? resolvedArgv + : ["launch", ...resolvedArgv]; return run({ bin: APP_NAME, version: VERSION, argv: runArgv, commands, help: showHelp }); } -await runCli(process.argv.slice(2)); +if (import.meta.main) { + await runCli(process.argv.slice(2)); +} diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 6aaf5941d..d5af659e6 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -11,6 +11,8 @@ export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui"; export interface Args { cwd?: string; + profile?: string; + alias?: string; allowHome?: boolean; provider?: string; model?: string; @@ -79,6 +81,14 @@ export function parseArgs(args: string[], extensionFlags?: Map --alias \` to create a shell shortcut for a profile PI_CODING_AGENT_DIR - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent) PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) PI_SMOL_MODEL - Override smol/fast model (see --smol) PI_SLOW_MODEL - Override slow/reasoning model (see --slow) PI_PLAN_MODEL - Override planning model (see --plan) PI_NO_PTY - Disable PTY-based interactive bash execution - For complete environment variable reference, see: ${chalk.dim("docs/environment-variables.md")} ${chalk.bold("Available Tools (default-enabled unless noted):")} diff --git a/packages/coding-agent/src/cli/profile-alias.ts b/packages/coding-agent/src/cli/profile-alias.ts new file mode 100644 index 000000000..d2c16de28 --- /dev/null +++ b/packages/coding-agent/src/cli/profile-alias.ts @@ -0,0 +1,164 @@ +import * as os from "node:os"; +import * as path from "node:path"; + +export type ProfileAliasShell = "bash" | "zsh" | "fish" | "powershell" | "pwsh"; + +function quoteForShell(pathValue: string): string { + return `'${pathValue.replace(/'/g, `'"'"'`)}'`; +} + +function quoteForPowerShell(pathValue: string): string { + return `'${pathValue.replace(/'/g, `''`)}'`; +} + +export interface ProfileAliasInstallOptions { + profile: string; + aliasName: string; + shellPath?: string; + platform?: NodeJS.Platform; + homeDir?: string; + readFile?: (filePath: string) => Promise; + writeFile?: (filePath: string, content: string) => Promise; +} + +export interface ProfileAliasInstallResult { + shell: ProfileAliasShell; + configPath: string; + aliasName: string; + profile: string; + command: string; + reloadedWith: string; +} + +const ALIAS_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/; + +function validateAliasName(aliasName: string): string { + const normalized = aliasName.trim(); + if (!ALIAS_NAME_RE.test(normalized)) { + throw new Error(`Invalid alias "${aliasName}". Alias names must match ${ALIAS_NAME_RE.source}.`); + } + if (normalized === "omp") { + throw new Error('Invalid alias "omp". Refusing to shadow the base omp command.'); + } + return normalized; +} + +function normalizeShellName(shellPath: string | undefined, platform: NodeJS.Platform): ProfileAliasShell { + const shell = path + .basename(shellPath ?? "") + .toLowerCase() + .replace(/\.exe$/, ""); + if (shell === "zsh") return "zsh"; + if (shell === "bash" || shell === "sh") return "bash"; + if (shell === "fish") return "fish"; + if (shell === "pwsh") return "pwsh"; + if (shell === "powershell") return "powershell"; + if (platform === "win32") return process.env.POWERSHELL_DISTRIBUTION_CHANNEL ? "pwsh" : "powershell"; + throw new Error(`Unsupported shell${shell ? ` "${shell}"` : ""}. Supported shells: bash, zsh, fish, PowerShell.`); +} + +function resolveShellConfigPath(shell: ProfileAliasShell, homeDir: string, platform: NodeJS.Platform): string { + switch (shell) { + case "zsh": + return path.join(homeDir, ".zshrc"); + case "bash": + return platform === "darwin" ? path.join(homeDir, ".bash_profile") : path.join(homeDir, ".bashrc"); + case "fish": + return path.join(homeDir, ".config", "fish", "conf.d", "omp-profiles.fish"); + case "pwsh": + return platform === "win32" + ? path.join(homeDir, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1") + : path.join(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"); + case "powershell": + return path.join(homeDir, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"); + } +} + +function renderAliasBlock( + shell: ProfileAliasShell, + aliasName: string, + profile: string, +): { block: string; command: string } { + const command = `omp --profile ${profile}`; + const start = `# >>> omp profile alias: ${aliasName} >>>`; + const end = `# <<< omp profile alias: ${aliasName} <<<`; + let body: string; + switch (shell) { + case "fish": + body = [ + `function ${aliasName} --wraps omp --description 'OMP profile ${profile}'`, + ` command ${command} $argv`, + "end", + ].join("\n"); + break; + case "powershell": + case "pwsh": + body = [`function ${aliasName} {`, ` & omp --profile ${profile} @args`, "}"].join("\n"); + break; + default: + body = `alias ${aliasName}='command ${command}'`; + break; + } + return { block: `${start}\n${body}\n${end}`, command }; +} + +function upsertBlock(content: string, aliasName: string, block: string): string { + const start = `# >>> omp profile alias: ${aliasName} >>>`; + const end = `# <<< omp profile alias: ${aliasName} <<<`; + const startIndex = content.indexOf(start); + if (startIndex !== -1) { + const endIndex = content.indexOf(end, startIndex + start.length); + if (endIndex !== -1) { + const afterEnd = endIndex + end.length; + const prefix = content.slice(0, startIndex).replace(/[\t ]*\n?$/, ""); + const suffix = content.slice(afterEnd).replace(/^\n?/, ""); + return [prefix, block, suffix].filter(Boolean).join("\n\n").replace(/\n*$/, "\n"); + } + } + const trimmed = content.replace(/\s*$/, ""); + return `${trimmed}${trimmed ? "\n\n" : ""}${block}\n`; +} + +export async function installProfileAlias(options: ProfileAliasInstallOptions): Promise { + const profile = options.profile.trim(); + if (!profile || profile === "default") { + throw new Error("--alias requires a named --profile value."); + } + const aliasName = validateAliasName(options.aliasName); + const platform = options.platform ?? process.platform; + const homeDir = options.homeDir ?? os.homedir(); + const shell = normalizeShellName(options.shellPath ?? process.env.SHELL, platform); + const configPath = resolveShellConfigPath(shell, homeDir, platform); + const { block, command } = renderAliasBlock(shell, aliasName, profile); + const readFile = + options.readFile ?? + (async filePath => { + try { + return await Bun.file(filePath).text(); + } catch { + return ""; + } + }); + const writeFile = + options.writeFile ?? + (async (filePath, content) => { + await Bun.write(filePath, content); + }); + + const current = await readFile(configPath); + await writeFile(configPath, upsertBlock(current, aliasName, block)); + + return { + shell, + configPath, + aliasName, + profile, + command, + reloadedWith: + shell === "fish" + ? `source ${quoteForShell(configPath)}` + : shell === "powershell" || shell === "pwsh" + ? `. ${quoteForPowerShell(configPath)}` + : `. ${quoteForShell(configPath)}`, + }; +} diff --git a/packages/coding-agent/src/cli/profile-bootstrap.ts b/packages/coding-agent/src/cli/profile-bootstrap.ts new file mode 100644 index 000000000..5ca8fda39 --- /dev/null +++ b/packages/coding-agent/src/cli/profile-bootstrap.ts @@ -0,0 +1,159 @@ +/** + * Bootstrap-time argv preparser for the global `--profile` / `--alias` flags. + * + * Profile selection MUST happen before any module reads `getAgentDir()` (notably + * `@oh-my-pi/pi-utils/env`, which eagerly loads `.env` from the agent directory + * during its own import). The full `parseArgs` from `./args.ts` lives downstream + * of those imports, so we can't rely on it for profile bootstrap — we have to + * crack open argv before the lazy command modules load. + * + * Because of that, this preparser must respect the same value-consumption + * contract as `args.ts`: known string-valued flags consume the next token + * unconditionally (so the value can legitimately start with `-`), and the + * optional-value flags (`--resume`, `--session`, `-r`, `--list-models`) + * consume the next token only when it doesn't look like another flag. Without + * this, `omp --system-prompt --profile foo` silently activates profile `foo` + * instead of passing the literal `--profile` to the system prompt and `foo` + * as a positional message (issue raised by code review). + * + * Keep these tables in sync with `packages/coding-agent/src/cli/args.ts`. Any + * flag added there that consumes a value must be mirrored here, otherwise the + * preparser can corrupt user-visible CLI interpretation. + */ + +/** + * Flags that always consume the next argv token, even when that token starts + * with `-`. Mirrors the `arg === "--xxx" && i + 1 < args.length ? args[++i]` + * pattern in `args.ts`. + */ +const STRING_VALUE_FLAGS: ReadonlySet = new Set([ + "--mode", + "--fork", + "--provider", + "--model", + "--smol", + "--slow", + "--plan", + "--api-key", + "--system-prompt", + "--append-system-prompt", + "--provider-session-id", + "--session-dir", + "--models", + "--tools", + "--thinking", + "--export", + "--hook", + "--extension", + "-e", + "--plugin-dir", + "--skills", +]); + +/** + * Flags that consume the next argv token only when it does not look like + * another flag. Mirrors the `if (next && !next.startsWith("-")) args[++i]` + * pattern in `args.ts`. + */ +const OPTIONAL_VALUE_FLAGS: ReadonlySet = new Set(["--resume", "-r", "--session", "--list-models"]); + +export interface ProfileBootstrapResult { + argv: string[]; + profile?: string; + aliasName?: string; +} + +/** + * Strip `--profile` / `--alias` from argv while preserving the surrounding + * argument structure. Returns the residual argv to hand to the launch parser + * and the captured flag values. + * + * Throws when either flag is supplied without a value. + */ +export function extractProfileFlags(argv: readonly string[]): ProfileBootstrapResult { + const stripped: string[] = []; + let profile: string | undefined; + let aliasName: string | undefined; + let passThrough = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (passThrough) { + stripped.push(arg); + continue; + } + + // `--` ends option processing. Anything that follows is forwarded verbatim + // so users can pass arbitrary tokens (including a literal `--profile`) to + // downstream tools without the bootstrap stealing them. + if (arg === "--") { + passThrough = true; + stripped.push(arg); + continue; + } + + if (arg === "--profile") { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error("--profile requires a profile name"); + } + profile = value; + index += 1; + continue; + } + if (arg.startsWith("--profile=")) { + const value = arg.slice("--profile=".length); + if (!value) { + throw new Error("--profile requires a profile name"); + } + profile = value; + continue; + } + if (arg === "--alias") { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error("--alias requires a command name"); + } + aliasName = value; + index += 1; + continue; + } + if (arg.startsWith("--alias=")) { + const value = arg.slice("--alias=".length); + if (!value) { + throw new Error("--alias requires a command name"); + } + aliasName = value; + continue; + } + + // Forward both the flag and its value untouched so the downstream parser + // gets exactly what the user typed. Critical for `--system-prompt + // --profile foo`: the bootstrap must NOT interpret `--profile` here, it + // belongs to `--system-prompt`. + if (STRING_VALUE_FLAGS.has(arg)) { + stripped.push(arg); + if (index + 1 < argv.length) { + stripped.push(argv[index + 1]); + index += 1; + } + continue; + } + + if (OPTIONAL_VALUE_FLAGS.has(arg)) { + stripped.push(arg); + const next = argv[index + 1]; + // `--list-models` also rejects `@` prefixes (treated as file args by args.ts). + if (next !== undefined && !next.startsWith("-") && !next.startsWith("@")) { + stripped.push(next); + index += 1; + } + continue; + } + + stripped.push(arg); + } + + return { argv: stripped, profile, aliasName }; +} diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index c01083446..1215546b6 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -49,6 +49,12 @@ export default class Index extends Command { "allow-home": Flags.boolean({ description: "Allow starting in ~ without auto-switching to a temp dir", }), + profile: Flags.string({ + description: "Use an isolated profile for auth, sessions, settings, and caches", + }), + alias: Flags.string({ + description: "Create a shell shortcut for the selected profile and exit", + }), mode: Flags.string({ description: "Output mode: text (default), json, rpc, or rpc-ui", options: ["text", "json", "rpc", "acp", "rpc-ui"], @@ -144,6 +150,7 @@ export default class Index extends Command { `# Include files in initial message\n ${APP_NAME} @prompt.md @image.png "What color is the sky?"`, `# Non-interactive mode (process and exit)\n ${APP_NAME} -p "List all .ts files in src/"`, `# Continue previous session\n ${APP_NAME} --continue "What did we discuss?"`, + `# Create a shell shortcut for a work profile\n ${APP_NAME} --profile work --alias omp-work`, `# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`, `# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`, `# Export a session file to HTML\n ${APP_NAME} --export ~/.omp/agent/sessions/--path--/session.jsonl`, diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index fc2b34332..e74e5d777 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -80,6 +80,12 @@ export function getChangelogPath(): string | undefined { * User-level: ~/.omp/agent, ~/.claude, ~/.codex, ~/.gemini * Project-level: .omp, .claude, .codex, .gemini */ +// `globalAgentDir` returns a *home-relative* config-agent path (e.g. `.omp/agent` +// or `.omp/profiles/work/agent` when a profile is active). It MUST stay home- +// relative and read at call time: it absorbs both `PI_CONFIG_DIR` changes and +// the active profile every time we resolve a user-level config dir. Swapping it +// for `getAgentDir()` would freeze the path at module load and bypass XDG- +// independent reactivity that downstream tests and runtime config flips rely on. const USER_CONFIG_BASES = priorityList.map(({ dir, globalAgentDir }) => ({ base: () => path.join(os.homedir(), globalAgentDir ? globalAgentDir() : dir), name: dir, diff --git a/packages/coding-agent/test/profile-alias.test.ts b/packages/coding-agent/test/profile-alias.test.ts new file mode 100644 index 000000000..007208123 --- /dev/null +++ b/packages/coding-agent/test/profile-alias.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "bun:test"; +import { installProfileAlias } from "../src/cli/profile-alias"; + +describe("profile alias installer", () => { + it("writes a bash-compatible alias that forwards subcommands through omp", async () => { + const files = new Map(); + + const result = await installProfileAlias({ + profile: "work", + aliasName: "omp-work", + shellPath: "/bin/bash", + platform: "linux", + homeDir: "/home/me", + readFile: async filePath => files.get(filePath) ?? "", + writeFile: async (filePath, content) => { + files.set(filePath, content); + }, + }); + + expect(result.configPath).toBe("/home/me/.bashrc"); + expect(files.get("/home/me/.bashrc")).toContain("alias omp-work='command omp --profile work'"); + }); + + it("writes a fish function that forwards argv", async () => { + const files = new Map(); + + await installProfileAlias({ + profile: "work", + aliasName: "omp-work", + shellPath: "/opt/homebrew/bin/fish", + platform: "darwin", + homeDir: "/Users/me", + readFile: async filePath => files.get(filePath) ?? "", + writeFile: async (filePath, content) => { + files.set(filePath, content); + }, + }); + + const content = files.get("/Users/me/.config/fish/conf.d/omp-profiles.fish") ?? ""; + expect(content).toContain("function omp-work --wraps omp"); + expect(content).toContain("command omp --profile work $argv"); + }); + + it("writes a PowerShell function because aliases cannot carry arguments", async () => { + const files = new Map(); + + await installProfileAlias({ + profile: "work", + aliasName: "omp-work", + shellPath: "pwsh.exe", + platform: "win32", + homeDir: "C:\\Users\\me", + readFile: async filePath => files.get(filePath) ?? "", + writeFile: async (filePath, content) => { + files.set(filePath, content); + }, + }); + + const content = files.get("C:\\Users\\me/Documents/PowerShell/Microsoft.PowerShell_profile.ps1") ?? ""; + expect(content).toContain("function omp-work"); + expect(content).toContain("& omp --profile work @args"); + }); + + it("replaces a previous block for the same alias", async () => { + const files = new Map([ + [ + "/home/me/.zshrc", + [ + "before", + "# >>> omp profile alias: omp-work >>>", + "alias omp-work='command omp --profile old'", + "# <<< omp profile alias: omp-work <<<", + "after", + ].join("\n"), + ], + ]); + + await installProfileAlias({ + profile: "work", + aliasName: "omp-work", + shellPath: "/bin/zsh", + platform: "darwin", + homeDir: "/home/me", + readFile: async filePath => files.get(filePath) ?? "", + writeFile: async (filePath, content) => { + files.set(filePath, content); + }, + }); + + const content = files.get("/home/me/.zshrc") ?? ""; + expect(content).toContain("before"); + expect(content).toContain("after"); + expect(content).toContain("alias omp-work='command omp --profile work'"); + expect(content).not.toContain("--profile old"); + }); + + it("refuses to shadow the base omp command", () => { + expect( + installProfileAlias({ + profile: "work", + aliasName: "omp", + shellPath: "/bin/bash", + homeDir: "/home/me", + }), + ).rejects.toThrow("Refusing to shadow"); + }); +}); diff --git a/packages/coding-agent/test/profile-bootstrap.test.ts b/packages/coding-agent/test/profile-bootstrap.test.ts new file mode 100644 index 000000000..d89237401 --- /dev/null +++ b/packages/coding-agent/test/profile-bootstrap.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "bun:test"; +import { extractProfileFlags } from "../src/cli/profile-bootstrap"; + +describe("extractProfileFlags", () => { + it("extracts --profile without disturbing other tokens", () => { + expect(extractProfileFlags(["--profile", "work"])).toEqual({ + argv: [], + profile: "work", + aliasName: undefined, + }); + expect(extractProfileFlags(["foo", "--profile=work", "bar"])).toEqual({ + argv: ["foo", "bar"], + profile: "work", + aliasName: undefined, + }); + }); + + it("does not eat the value of known string-valued flags", () => { + // `omp --system-prompt --profile foo` must pass the literal `--profile` + // through to the launch parser (it's the system prompt) and `foo` is the + // positional message. The previous implementation would silently activate + // profile `foo` here, dropping the user's prompt. + const result = extractProfileFlags(["--system-prompt", "--profile", "foo", "bar"]); + expect(result.profile).toBeUndefined(); + expect(result.argv).toEqual(["--system-prompt", "--profile", "foo", "bar"]); + }); + + it("still extracts --profile after an unrelated string-valued flag", () => { + // Mirror image: when the user does mean to activate a profile *after* + // a string-valued flag, we must skip past the flag's value but still + // pick up the trailing `--profile`. + const result = extractProfileFlags(["--system-prompt", "hello", "--profile", "work"]); + expect(result.profile).toBe("work"); + expect(result.argv).toEqual(["--system-prompt", "hello"]); + }); + + it("treats optional-value flags as consuming the next token only when it doesn't look like a flag", () => { + // `--resume ` consumes the id, `--resume` alone is a picker. + const consumed = extractProfileFlags(["--resume", "abc123", "--profile", "work"]); + expect(consumed.argv).toEqual(["--resume", "abc123"]); + expect(consumed.profile).toBe("work"); + + const picker = extractProfileFlags(["--resume", "--profile", "work"]); + expect(picker.argv).toEqual(["--resume"]); + expect(picker.profile).toBe("work"); + + // `--list-models` mirrors args.ts and does not consume `@`-prefixed + // tokens (they're file args); the pre-pass releases them and the + // trailing `--profile work` still activates. + const filePrefixed = extractProfileFlags(["--list-models", "@models.txt", "--profile", "work"]); + expect(filePrefixed.argv).toEqual(["--list-models", "@models.txt"]); + expect(filePrefixed.profile).toBe("work"); + }); + + it("honors `--` and stops scanning for flags", () => { + const result = extractProfileFlags(["--", "--profile", "foo", "--alias", "bar"]); + expect(result.profile).toBeUndefined(); + expect(result.aliasName).toBeUndefined(); + expect(result.argv).toEqual(["--", "--profile", "foo", "--alias", "bar"]); + }); + + it("rejects --profile without a value", () => { + expect(() => extractProfileFlags(["--profile"])).toThrow("--profile requires a profile name"); + expect(() => extractProfileFlags(["--profile", "--version"])).toThrow("--profile requires a profile name"); + expect(() => extractProfileFlags(["--profile="])).toThrow("--profile requires a profile name"); + }); + + it("rejects --alias without a value", () => { + expect(() => extractProfileFlags(["--alias"])).toThrow("--alias requires a command name"); + expect(() => extractProfileFlags(["--alias", "--profile"])).toThrow("--alias requires a command name"); + expect(() => extractProfileFlags(["--alias="])).toThrow("--alias requires a command name"); + }); +}); diff --git a/packages/coding-agent/test/profile-cli.test.ts b/packages/coding-agent/test/profile-cli.test.ts new file mode 100644 index 000000000..1fcfd8117 --- /dev/null +++ b/packages/coding-agent/test/profile-cli.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getActiveProfile, getAgentDir, setAgentDir, setProfile } from "@oh-my-pi/pi-utils/dirs"; +import { Snowflake } from "@oh-my-pi/pi-utils/snowflake"; +import { runCli } from "../src/cli"; +import * as profileAliasCli from "../src/cli/profile-alias"; + +describe("global --profile flag", () => { + let configDir = ""; + let originalProfile: string | undefined; + let originalAgentDir = ""; + let originalAgentDirEnv: string | undefined; + let originalConfigDir: string | undefined; + + beforeEach(() => { + originalProfile = getActiveProfile(); + originalAgentDir = getAgentDir(); + originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR; + originalConfigDir = process.env.PI_CONFIG_DIR; + configDir = `.omp-profile-cli-test-${Snowflake.next()}`; + process.env.PI_CONFIG_DIR = configDir; + process.exitCode = 0; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + setProfile(undefined); + if (originalConfigDir === undefined) { + delete process.env.PI_CONFIG_DIR; + } else { + process.env.PI_CONFIG_DIR = originalConfigDir; + } + if (originalProfile) { + setProfile(originalProfile); + } else if (originalAgentDirEnv !== undefined) { + setAgentDir(originalAgentDir); + } else { + setProfile(undefined); + } + process.exitCode = 0; + await fs.rm(path.join(os.homedir(), configDir), { recursive: true, force: true }); + }); + + it("activates a profile before dispatching root flags", async () => { + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runCli(["--profile=work", "--version"]); + + expect(process.exitCode).toBe(0); + expect(writeSpy).toHaveBeenCalled(); + expect(getActiveProfile()).toBe("work"); + expect(getAgentDir()).toBe(path.join(os.homedir(), configDir, "profiles", "work", "agent")); + }); + + it("accepts the profile flag after other root flags", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runCli(["--version", "--profile", "office"]); + + expect(process.exitCode).toBe(0); + expect(getActiveProfile()).toBe("office"); + expect(getAgentDir()).toBe(path.join(os.homedir(), configDir, "profiles", "office", "agent")); + }); + + it("installs a shell alias and exits before command dispatch", async () => { + const installSpy = vi.spyOn(profileAliasCli, "installProfileAlias").mockResolvedValue({ + shell: "bash", + configPath: "/home/me/.bashrc", + aliasName: "omp-work", + profile: "work", + command: "omp --profile work", + reloadedWith: ". '/home/me/.bashrc'", + }); + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runCli(["--profile", "work", "--alias", "omp-work", "--version"]); + + expect(process.exitCode).toBe(0); + expect(installSpy).toHaveBeenCalledWith({ profile: "work", aliasName: "omp-work" }); + expect(outSpy.mock.calls.map(call => String(call[0] ?? "")).join("\n")).toContain("Created omp-work"); + }); + + it("rejects missing profile values without dispatching", async () => { + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const outSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runCli(["--profile", "--version"]); + + expect(process.exitCode).toBe(1); + expect(errSpy.mock.calls.map(call => String(call[0] ?? "")).join("\n")).toContain( + "--profile requires a profile name", + ); + expect(outSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index b93b678ca..9f8a97f29 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -28,6 +28,58 @@ export const VERSION: string = version; /** Minimum Bun version */ export const MIN_BUN_VERSION: string = engines.bun.replace(/[^0-9.]/g, ""); +const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const PROFILE_ENV_KEYS = ["OMP_PROFILE", "PI_PROFILE"] as const; + +/** + * Names Windows treats as reserved device aliases. Matches the basename + * itself as well as any `BASENAME.` form, because Windows reserves + * `CON.foo`/`PRN.txt`/etc. too — using them as a profile name would let + * `setProfile` accept the input only for directory creation to fail later + * with a confusing `ENOENT`/`EINVAL`. Case-insensitive: NTFS treats `CON` + * and `con` identically. + */ +const WINDOWS_RESERVED_BASENAME_RE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i; + +/** + * Normalize and validate a profile name. Returns `undefined` for the implicit + * default (empty string, whitespace, or the explicit "default" sentinel) and + * throws for syntactically invalid or platform-reserved names. + * + * Exported so consumers of `@oh-my-pi/pi-utils/dirs` (CLI bootstrap, tests, + * downstream tools) can validate user input without re-deriving the rules. + */ +export function normalizeProfileName(profile: string | undefined): string | undefined { + const normalized = profile?.trim(); + if (!normalized || normalized === "default") return undefined; + if ( + normalized === "." || + normalized === ".." || + !PROFILE_NAME_RE.test(normalized) || + WINDOWS_RESERVED_BASENAME_RE.test(normalized) + ) { + throw new Error( + `Invalid OMP profile "${profile}". Profile names must match ${PROFILE_NAME_RE.source}, ` + + `cannot be "." or "..", and cannot be a Windows reserved device name ` + + `(CON, PRN, AUX, NUL, COM0-9, LPT0-9, or any of those with an extension).`, + ); + } + return normalized; +} + +function getProfileFromEnv(): string | undefined { + return normalizeProfileName(process.env.OMP_PROFILE || process.env.PI_PROFILE); +} + +function getBaseConfigRoot(): string { + return path.join(os.homedir(), getConfigDirName()); +} + +function getProfileConfigRoot(profile: string | undefined): string { + const root = getBaseConfigRoot(); + return profile ? path.join(root, "profiles", profile) : root; +} + // ============================================================================= // Project directory // ============================================================================= @@ -96,7 +148,8 @@ export function getConfigDirName(): string { /** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */ export function getConfigAgentDirName(): string { - return `${getConfigDirName()}/agent`; + const profile = getActiveProfile(); + return profile ? path.join(getConfigDirName(), "profiles", profile, "agent") : `${getConfigDirName()}/agent`; } // ============================================================================= @@ -123,29 +176,47 @@ class DirResolver { readonly #rootCache = new Map(); readonly #agentCache = new Map(); - constructor(agentDirOverride?: string) { - this.configRoot = path.join(os.homedir(), getConfigDirName()); + constructor(options: { agentDirOverride?: string; profile?: string } = {}) { + const profile = normalizeProfileName(options.profile); + this.configRoot = getProfileConfigRoot(profile); const defaultAgent = path.join(this.configRoot, "agent"); + const agentDirOverride = profile ? undefined : options.agentDirOverride; this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent; const isDefault = this.agentDir === defaultAgent; - // XDG is a Linux convention. On other platforms, or for non-default - // profiles, all categories resolve to the legacy paths. + // XDG is a Linux convention. On supported platforms, default profile state + // resolves under $XDG_*_HOME/omp once `omp config init-xdg` has migrated + // the user's data. Named profiles follow a stricter rule: the XDG choice + // is keyed on the profile-specific XDG path, never the base app root. + // + // Why: if we consulted the base app root for named profiles too, the same + // profile could resolve to `~/.omp/profiles/` on first activation + // (when no $XDG_*_HOME/omp exists yet) and then silently move to + // `$XDG_*_HOME/omp/profiles/` the moment the base appeared, orphaning + // the earlier state. Pinning on the profile path means a profile's location + // is decided at first activation and stays put until the user explicitly + // migrates it (e.g. by mkdir'ing the XDG profile dir). let xdgData: string | undefined; let xdgState: string | undefined; let xdgCache: string | undefined; if ((process.platform === "linux" || process.platform === "darwin") && isDefault) { const resolveIf = (envVar: string) => { const value = process.env[envVar]; - if (value) { - try { - const joined = path.join(value, APP_NAME); - if (fs.existsSync(joined)) { - return joined; + if (!value) return undefined; + try { + const appRoot = path.join(value, APP_NAME); + if (profile) { + const profilePath = path.join(appRoot, "profiles", profile); + if (fs.existsSync(profilePath)) { + return profilePath; } - } catch {} - } + return undefined; + } + if (fs.existsSync(appRoot)) { + return appRoot; + } + } catch {} return undefined; }; xdgData = resolveIf("XDG_DATA_HOME"); @@ -190,8 +261,21 @@ class DirResolver { } } -let dirs = new DirResolver(process.env.PI_CODING_AGENT_DIR); - +let activeProfile = getProfileFromEnv(); +let dirs = new DirResolver({ + agentDirOverride: activeProfile ? undefined : process.env.PI_CODING_AGENT_DIR, + profile: activeProfile, +}); +/** + * Snapshot of `PI_CODING_AGENT_DIR` from before the first named-profile + * activation. Reset paths restore this value (or its absence) instead of + * unconditionally deleting the env var. Without the snapshot, a process started + * with `PI_CODING_AGENT_DIR=/custom` then `setProfile("work")` then + * `setProfile(undefined)` would silently lose `/custom` and fall back to + * `~/.omp/agent`. Captured at module load and refreshed on `setAgentDir`, + * since that call is the user explicitly redefining the baseline. + */ +let preProfileAgentDirEnv: string | undefined = process.env.PI_CODING_AGENT_DIR; // Anchor home for the resolver. Captured at module load to stay stable across // test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so // production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while @@ -209,10 +293,66 @@ export function getConfigRootDir(): string { /** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */ export function setAgentDir(dir: string): void { - dirs = new DirResolver(dir); + activeProfile = undefined; + dirs = new DirResolver({ agentDirOverride: dir }); process.env.PI_CODING_AGENT_DIR = dir; + preProfileAgentDirEnv = dir; + for (const key of PROFILE_ENV_KEYS) { + delete process.env[key]; + } } +/** + * Test-only: reset the pre-profile `PI_CODING_AGENT_DIR` snapshot to whatever + * the current environment looks like. Cross-suite test pollution can otherwise + * leak a stale snapshot through `setAgentDir` and corrupt `setProfile(undefined)` + * restore semantics. Production code MUST NOT call this — the snapshot's + * lifecycle is owned by `setAgentDir` / `setProfile` and a runtime caller has + * no business clearing it. + */ +export function __resetProfileSnapshotForTests(): void { + preProfileAgentDirEnv = process.env.PI_CODING_AGENT_DIR; +} + +/** Activate a named profile. Passing undefined or "default" returns to the default profile. */ +export function setProfile(profile: string | undefined): void { + const next = normalizeProfileName(profile); + if (next && !activeProfile) { + // First activation of a named profile in this process: snapshot the + // current PI_CODING_AGENT_DIR so a later reset can restore the user's + // explicit override. Subsequent profile switches keep the original + // snapshot — the "pre-profile" baseline is the state before profiles + // entered the picture, not the state between two activations. + preProfileAgentDirEnv = process.env.PI_CODING_AGENT_DIR; + } + activeProfile = next; + if (activeProfile) { + dirs = new DirResolver({ profile: activeProfile }); + process.env.OMP_PROFILE = activeProfile; + process.env.PI_PROFILE = activeProfile; + process.env.PI_CODING_AGENT_DIR = dirs.agentDir; + } else { + for (const key of PROFILE_ENV_KEYS) { + delete process.env[key]; + } + if (preProfileAgentDirEnv === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = preProfileAgentDirEnv; + } + dirs = new DirResolver({ agentDirOverride: preProfileAgentDirEnv }); + } +} + +/** Get the active named profile. Undefined means the default profile. */ +export function getActiveProfile(): string | undefined { + return activeProfile; +} + +/** Resolve the config root that backs a profile without activating it. */ +export function getProfileRootDir(profile: string | undefined): string { + return getProfileConfigRoot(normalizeProfileName(profile)); +} /** Get the agent config directory (~/.omp/agent). */ export function getAgentDir(): string { return dirs.agentDir; diff --git a/packages/utils/test/profiles.test.ts b/packages/utils/test/profiles.test.ts new file mode 100644 index 000000000..617a10466 --- /dev/null +++ b/packages/utils/test/profiles.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + __resetProfileSnapshotForTests, + getActiveProfile, + getAgentDbPath, + getAgentDir, + getConfigAgentDirName, + getConfigRootDir, + getPythonGatewayDir, + getSessionsDir, + getStatsDbPath, + setAgentDir, + setProfile, +} from "../src/dirs"; +import { Snowflake } from "../src/snowflake"; + +describe("profile directories", () => { + let tempRoot = ""; + let configDir = ""; + let originalAgentDir = ""; + let originalProfile: string | undefined; + let originalAgentDirEnv: string | undefined; + let originalConfigDir: string | undefined; + let originalXdgDataHome: string | undefined; + let originalXdgStateHome: string | undefined; + let originalXdgCacheHome: string | undefined; + + beforeEach(async () => { + originalAgentDir = getAgentDir(); + originalProfile = getActiveProfile(); + originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR; + originalConfigDir = process.env.PI_CONFIG_DIR; + originalXdgDataHome = process.env.XDG_DATA_HOME; + originalXdgStateHome = process.env.XDG_STATE_HOME; + originalXdgCacheHome = process.env.XDG_CACHE_HOME; + tempRoot = path.join(os.tmpdir(), "pi-utils-profiles", Snowflake.next()); + configDir = `.omp-profile-test-${Snowflake.next()}`; + await fs.mkdir(tempRoot, { recursive: true }); + process.env.PI_CONFIG_DIR = configDir; + // Other suites that run before this one (e.g. dirs-python-gateway) may have + // called `setAgentDir`, which permanently mutates the module-level + // pre-profile snapshot. Reset it here so each test starts from a clean + // `PI_CODING_AGENT_DIR` baseline matching the env we just configured. + delete process.env.PI_CODING_AGENT_DIR; + __resetProfileSnapshotForTests(); + delete process.env.XDG_DATA_HOME; + delete process.env.XDG_STATE_HOME; + delete process.env.XDG_CACHE_HOME; + }); + + afterEach(async () => { + setProfile(undefined); + if (originalConfigDir === undefined) { + delete process.env.PI_CONFIG_DIR; + } else { + process.env.PI_CONFIG_DIR = originalConfigDir; + } + if (originalXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = originalXdgDataHome; + } + if (originalXdgStateHome === undefined) { + delete process.env.XDG_STATE_HOME; + } else { + process.env.XDG_STATE_HOME = originalXdgStateHome; + } + if (originalXdgCacheHome === undefined) { + delete process.env.XDG_CACHE_HOME; + } else { + process.env.XDG_CACHE_HOME = originalXdgCacheHome; + } + if (originalProfile) { + setProfile(originalProfile); + } else if (originalAgentDirEnv !== undefined) { + setAgentDir(originalAgentDir); + } else { + setProfile(undefined); + } + await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.rm(path.join(os.homedir(), configDir), { recursive: true, force: true }); + }); + + it("moves agent and root data under the named profile root", () => { + setProfile("work"); + + const root = path.join(os.homedir(), configDir, "profiles", "work"); + const agent = path.join(root, "agent"); + expect(getActiveProfile()).toBe("work"); + expect(getConfigRootDir()).toBe(root); + expect(getConfigAgentDirName()).toBe(path.join(configDir, "profiles", "work", "agent")); + expect(getAgentDir()).toBe(agent); + expect(getAgentDbPath()).toBe(path.join(agent, "agent.db")); + expect(getSessionsDir()).toBe(path.join(agent, "sessions")); + expect(getStatsDbPath()).toBe(path.join(root, "stats.db")); + }); + + it("treats the default profile as regular mode", () => { + setProfile("default"); + + const root = path.join(os.homedir(), configDir); + expect(getActiveProfile()).toBeUndefined(); + expect(getConfigRootDir()).toBe(root); + expect(getAgentDir()).toBe(path.join(root, "agent")); + }); + + it("keeps XDG-backed named profile state under profile-specific roots", async () => { + if (process.platform === "win32") return; + + process.env.XDG_DATA_HOME = path.join(tempRoot, "data"); + process.env.XDG_STATE_HOME = path.join(tempRoot, "state"); + process.env.XDG_CACHE_HOME = path.join(tempRoot, "cache"); + // Named profiles only adopt XDG when their *own* XDG path already exists. + // Mkdir'ing only the base app root used to be enough (bug); the resolver + // now requires the profile-specific path so the profile location is stable + // across activations. + await fs.mkdir(path.join(process.env.XDG_DATA_HOME, "omp", "profiles", "work"), { recursive: true }); + await fs.mkdir(path.join(process.env.XDG_STATE_HOME, "omp", "profiles", "work"), { recursive: true }); + await fs.mkdir(path.join(process.env.XDG_CACHE_HOME, "omp", "profiles", "work"), { recursive: true }); + + setProfile("work"); + + expect(getAgentDbPath()).toBe(path.join(process.env.XDG_DATA_HOME, "omp", "profiles", "work", "agent.db")); + expect(getSessionsDir()).toBe(path.join(process.env.XDG_DATA_HOME, "omp", "profiles", "work", "sessions")); + expect(getPythonGatewayDir()).toBe( + path.join(process.env.XDG_STATE_HOME, "omp", "profiles", "work", "python-gateway"), + ); + }); + + it("does not silently switch a named profile to XDG once the base app dir appears", async () => { + if (process.platform === "win32") return; + + process.env.XDG_DATA_HOME = path.join(tempRoot, "data"); + process.env.XDG_STATE_HOME = path.join(tempRoot, "state"); + process.env.XDG_CACHE_HOME = path.join(tempRoot, "cache"); + + // Fresh install: XDG vars are set (typical Linux) but no $XDG/omp exists yet. + // First activation must land in ~//profiles/work because + // the profile-specific XDG path does not exist. + setProfile("work"); + const firstAgentDir = getAgentDir(); + expect(firstAgentDir).toBe(path.join(os.homedir(), configDir, "profiles", "work", "agent")); + + // Later, the base XDG app dir materializes (e.g. via `omp config init-xdg` + // migrating only the default-profile data). The named profile must stay + // in its original location until the user explicitly migrates it. + await fs.mkdir(path.join(process.env.XDG_DATA_HOME, "omp"), { recursive: true }); + await fs.mkdir(path.join(process.env.XDG_STATE_HOME, "omp"), { recursive: true }); + await fs.mkdir(path.join(process.env.XDG_CACHE_HOME, "omp"), { recursive: true }); + + setProfile(undefined); + setProfile("work"); + expect(getAgentDir()).toBe(firstAgentDir); + }); + + it("rejects path-like profile names", () => { + expect(() => setProfile("../work")).toThrow("Invalid OMP profile"); + expect(() => setProfile("work/team")).toThrow("Invalid OMP profile"); + }); + + it("restores the pre-profile PI_CODING_AGENT_DIR override on reset", () => { + const customAgentDir = path.join(tempRoot, "custom-agent"); + setAgentDir(customAgentDir); + expect(getAgentDir()).toBe(customAgentDir); + expect(process.env.PI_CODING_AGENT_DIR).toBe(customAgentDir); + + setProfile("work"); + expect(getActiveProfile()).toBe("work"); + expect(getAgentDir()).not.toBe(customAgentDir); + + setProfile(undefined); + expect(getActiveProfile()).toBeUndefined(); + // Critical: reset must restore the user's override, not delete it. + expect(process.env.PI_CODING_AGENT_DIR).toBe(customAgentDir); + expect(getAgentDir()).toBe(customAgentDir); + }); + + it("clears PI_CODING_AGENT_DIR on reset when nothing was set originally", () => { + delete process.env.PI_CODING_AGENT_DIR; + // Force a baseline snapshot of "no override" via setProfile so a stale + // module-load snapshot from a previous test cannot leak in. + setProfile("work"); + setProfile(undefined); + expect(process.env.PI_CODING_AGENT_DIR).toBeUndefined(); + }); + + it("rejects Windows reserved device names case-insensitively", () => { + for (const name of ["CON", "con", "PRN", "AUX", "NUL", "COM0", "COM9", "lpt1", "LPT9", "CON.txt", "com1.bak"]) { + expect(() => setProfile(name)).toThrow("Windows reserved device name"); + } + }); +});