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.
This commit is contained in:
Ogrodev
2026-05-27 07:50:45 -03:00
parent 0b52b8c0d0
commit 680917f02a
12 changed files with 1035 additions and 24 deletions
+12 -1
View File
@@ -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<string, { type: "
result.version = true;
} else if (arg === "--allow-home") {
result.allowHome = true;
} else if (arg === "--profile" && i + 1 < args.length) {
result.profile = args[++i];
} else if (arg.startsWith("--profile=")) {
result.profile = arg.slice("--profile=".length);
} else if (arg === "--alias" && i + 1 < args.length) {
result.alias = args[++i];
} else if (arg.startsWith("--alias=")) {
result.alias = arg.slice("--alias=".length);
} else if (arg === "--mode" && i + 1 < args.length) {
const mode = args[++i];
if (mode === "text" || mode === "json" || mode === "rpc" || mode === "acp" || mode === "rpc-ui") {
@@ -262,13 +272,14 @@ export function getExtraHelpText(): string {
ANTHROPIC_SEARCH_API_KEY - Anthropic search provider
${chalk.dim("# Configuration")}
OMP_PROFILE - Named profile for isolated agent state (same as --profile)
Use \`omp --profile <name> --alias <command>\` 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):")}
@@ -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<string>;
writeFile?: (filePath: string, content: string) => Promise<void>;
}
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<ProfileAliasInstallResult> {
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)}`,
};
}
@@ -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<string> = 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<string> = 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 };
}