Files
oh-my-pi/packages/coding-agent/test/profile-cli.test.ts
T
Ogrodev 680917f02a feat: added isolated profiles with --profile and --alias
Added named OMP profiles that isolate agent state (auth credentials,
sessions, settings, model cache, history, memories, blobs, plus
config root subdirs) under `~/.omp/profiles/<name>/agent/`. Activated
via `--profile <name>` or `OMP_PROFILE=<name>`; `default` maps back to
the regular `~/.omp/agent/` tree.

Added `--alias <command>` to generate a shell shortcut (e.g.
`omp-work`) that forwards `omp --profile <name>`. Detects the active
shell (bash, zsh, fish, PowerShell, pwsh), writes a wrapper into the
correct rc file, and preserves subcommands like `update`, `--version`,
and `--model` because the wrapper passes through argv unchanged.

The `--profile`/`--alias` bootstrap pre-parser lives in
`packages/coding-agent/src/cli/profile-bootstrap.ts` and runs before
any module that touches `getAgentDir()` (notably `@oh-my-pi/pi-utils/env`,
which eagerly loads `.env` from the agent directory at its own import
time). The pre-parser mirrors `parseArgs` value-consumption rules and
honors `--`, so commands like `omp --system-prompt --profile foo` pass
the literal `--profile` through as the prompt body instead of silently
activating profile `foo`.

XDG resolution for named profiles is keyed on the profile-specific
XDG path (`$XDG_*_HOME/omp/profiles/<name>`), never the base app root,
so a profile's location is decided once at first activation and stays
stable even after `omp config init-xdg` materializes the base later.
The default profile keeps its existing base-app-root check.

`setProfile(undefined)` (and `setProfile("default")`) restores the
pre-profile `PI_CODING_AGENT_DIR` snapshot taken at first activation
instead of unconditionally deleting it. `setAgentDir` refreshes the
snapshot since that call is the user explicitly redefining the
baseline.

Validation rejects profile names that match `.`/`..`, fail
`/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/`, or hit a Windows reserved
device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0-9`, `LPT0-9`, including
dotted variants like `CON.txt`) — those would let `setProfile` accept
the input only for directory creation to fail later with confusing
errors on Windows.
2026-05-27 07:50:45 -03:00

98 lines
3.5 KiB
TypeScript

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();
});
});