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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user