Merge PR #1435: feat: added isolated profiles with --profile and --alias
Closes #1435 # Conflicts: # .github/actions/bun-install/action.yml
This commit is contained in:
@@ -58,4 +58,3 @@ runs:
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -337,6 +337,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
@@ -367,6 +368,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
@@ -429,6 +431,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
@@ -459,6 +462,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/setup-system-deps
|
||||
- uses: ./.github/actions/bun-install
|
||||
- name: Resolve Linux x64 native artifact run
|
||||
@@ -577,6 +581,9 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3"
|
||||
env:
|
||||
SCCACHE_BUCKET: ""
|
||||
AWS_ACCESS_KEY_ID: ""
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
@@ -72,6 +72,14 @@ Project-level bases:
|
||||
|
||||
`CONFIG_DIR_NAME` is `.omp` (`packages/utils/src/dirs.ts`).
|
||||
|
||||
## Profiles
|
||||
|
||||
A named profile (`omp --profile <name>`, the `--alias` shortcut, or `OMP_PROFILE` / `PI_PROFILE`) relocates the OMP user base. When a profile is active, every OMP-native user-level path written here as `~/.omp/agent/...` resolves to `~/.omp/profiles/<name>/agent/...` instead.
|
||||
|
||||
The relocation is uniform across the native provider (`builtin.ts`) and the generic `config.ts` helpers, so it covers slash commands, rules, prompts, instructions, hooks, tools, extensions, settings, skills, and MCP, plus the top-level `SYSTEM.md` / `RULES.md` / `AGENTS.md` files and runtime state (sessions, blobs, `agent.db`). A profile sees only its own OMP config, never the default profile's `~/.omp/agent`.
|
||||
|
||||
The other source bases are not profile-scoped and load identically under every profile: the external-tool bases (`~/.claude`, `~/.codex`, `~/.gemini`) belong to those tools, and the project-level bases (`<cwd>/.omp`, `<cwd>/.claude`, ...) are keyed to the working directory. Throughout this document, read `~/.omp/agent` as shorthand for the active profile's agent directory.
|
||||
|
||||
## Important constraint
|
||||
|
||||
The generic helpers in `src/config.ts` do **not** include `.pi` in source discovery order.
|
||||
|
||||
@@ -34,7 +34,7 @@ Native `extension-module` discovery comes from:
|
||||
- User directory: `~/.omp/agent/extensions`
|
||||
- Native legacy/settings JSON entries: `<cwd>/.omp/settings.json#extensions` and `~/.omp/agent/settings.json#extensions`
|
||||
|
||||
Path roots come from the native provider (`SOURCE_PATHS.native`). Project lookup is cwd-only for these native roots; it does not walk ancestors.
|
||||
The project root is the native provider's `.omp` directory (`SOURCE_PATHS.native.projectDir`), cwd-only; it does not walk ancestors. The user root is the active profile's agent directory via `getAgentDir()`, so under `omp --profile <name>` it becomes `~/.omp/profiles/<name>/agent/extensions` (and it honors `PI_CODING_AGENT_DIR`). See [Profiles](./config-usage.md#profiles).
|
||||
|
||||
Notes:
|
||||
|
||||
|
||||
+46
-3
@@ -15,7 +15,7 @@ Source of truth in code:
|
||||
OMP can discover MCP servers from multiple tools (`.claude/`, `.cursor/`, `.vscode/`, `opencode.json`, and more), but for OMP-native configuration you should usually use one of these primary files:
|
||||
|
||||
- Project: `.omp/mcp.json`
|
||||
- User: `~/.omp/agent/mcp.json`
|
||||
- User: `~/.omp/agent/mcp.json` (or `~/.omp/profiles/<name>/agent/mcp.json` when a named profile is active — see [Profiles](#profiles))
|
||||
|
||||
The native provider also reads `.omp/.mcp.json` and `~/.omp/agent/.mcp.json` for compatibility, but OMP writes to the primary `mcp.json` paths above.
|
||||
|
||||
@@ -26,6 +26,19 @@ OMP also accepts fallback standalone files in the project root:
|
||||
|
||||
Use `.omp/mcp.json` or `~/.omp/agent/mcp.json` when you want OMP to own the configuration. Use root `mcp.json` / `.mcp.json` only when you want a portable fallback file that other MCP clients may also read.
|
||||
|
||||
### Profiles
|
||||
|
||||
Named profiles (`omp --profile <name>`, the `--alias` shortcut, or `OMP_PROFILE`/`PI_PROFILE`) isolate user-level MCP config. When a profile is active, the **user** scope resolves to the profile's agent directory instead of the default one:
|
||||
|
||||
- Default profile: `~/.omp/agent/mcp.json`
|
||||
- Profile `<name>`: `~/.omp/profiles/<name>/agent/mcp.json`
|
||||
|
||||
Discovery, the `/mcp` commands, and the config writer all follow the active profile, so a profile sees **only** its own user-level servers — never the default profile's `~/.omp/agent/mcp.json`. Add a server to a profile by launching under it (`omp --profile <name>`) and running `/mcp add` → User level, or by editing `~/.omp/profiles/<name>/agent/mcp.json` directly.
|
||||
|
||||
Project-scoped MCP config (`.omp/mcp.json`) is keyed to the working directory, not the profile, so it applies under every profile. External-tool configs (`.claude/`, `.cursor/`, etc.) are also profile-independent because they belong to those tools rather than to an OMP profile.
|
||||
|
||||
MCP follows the same profile rules as the rest of OMP-native config; see [Configuration Discovery → Profiles](./config-usage.md#profiles).
|
||||
|
||||
## Add a schema reference
|
||||
|
||||
Add this line at the top of the file for editor autocomplete and validation:
|
||||
@@ -61,7 +74,7 @@ Top-level keys:
|
||||
|
||||
- `$schema` — optional JSON Schema URL for tooling
|
||||
- `mcpServers` — map of server name to server config
|
||||
- `disabledServers` — user-level denylist used to turn off discovered servers by name; runtime loading reads this list from `~/.omp/agent/mcp.json`
|
||||
- `disabledServers` — user-level denylist used to turn off discovered servers by name; runtime loading reads this list from the active profile's user MCP file (`~/.omp/agent/mcp.json`, or `~/.omp/profiles/<name>/agent/mcp.json` under a named profile)
|
||||
|
||||
Server names must match `^[a-zA-Z0-9_.-]{1,100}$`.
|
||||
|
||||
@@ -185,6 +198,33 @@ OMP understands two auth-related objects.
|
||||
|
||||
Use this when OMP should remember how to rehydrate credentials for a server.
|
||||
|
||||
You normally do not need to write this block: when OMP completes an OAuth flow
|
||||
for an `http`/`sse` server it stores the credential under a deterministic id
|
||||
derived from the active profile and server URL
|
||||
(`mcp_oauth:profile:<profile>:<url>`), with the refresh material embedded. Any
|
||||
config that points at the same URL — including a *definition-only* entry in a
|
||||
shared project `mcp.json` with no `auth` block at all — resolves the active
|
||||
profile's own credential automatically, including when auth storage is backed by
|
||||
a shared auth broker. This is what makes project-scoped servers safe across
|
||||
profiles: commit the definition, and each profile authorizes (and stays signed
|
||||
in as) its own account via `/mcp reauth <name>`. An explicit `credentialId` is
|
||||
still honored when it resolves; if it points at another profile's row, OMP falls
|
||||
back to the profile-scoped url-keyed binding.
|
||||
|
||||
`/mcp reauth` on a definition-only entry leaves the file untouched — the
|
||||
credential (refresh material included) lives entirely in the active profile's
|
||||
auth storage (local `agent.db` or broker), so a committed project config never
|
||||
picks up local auth state. An explicitly
|
||||
configured `Authorization` header always wins over the url-keyed binding.
|
||||
|
||||
The binding is per profile but not per project: once a profile has authorized
|
||||
a URL, *any* checkout whose `mcp.json` defines a server at that URL connects
|
||||
with that profile's credential automatically. Committed MCP definitions are
|
||||
trusted input — the same already applies to `stdio` entries, which run
|
||||
arbitrary commands — so review a repository's `mcp.json` before opening it
|
||||
with a profile that holds credentials you care about, or use a dedicated
|
||||
profile for untrusted checkouts.
|
||||
|
||||
### `oauth`
|
||||
|
||||
```json
|
||||
@@ -193,12 +233,15 @@ Use this when OMP should remember how to rehydrate credentials for a server.
|
||||
"clientSecret": "...",
|
||||
"redirectUri": "...",
|
||||
"callbackPort": 3334,
|
||||
"callbackPath": "/oauth/callback"
|
||||
"callbackPath": "/oauth/callback",
|
||||
"prompt": "consent"
|
||||
}
|
||||
```
|
||||
|
||||
Use this when the MCP server requires explicit OAuth client settings.
|
||||
|
||||
`prompt` controls the OAuth `prompt` parameter sent with the authorization request. It defaults to `"consent"` so the provider always shows its consent/account screen — without it, a provider with an active browser session silently re-approves the same account, making it impossible to switch accounts or workspaces when reauthorizing (e.g. to use a different Linear workspace per OMP profile). Set it to `""` to omit the parameter for providers that reject it, or to another value the provider understands (e.g. `"select_account"`).
|
||||
|
||||
Slack is the clearest current example. Slack's MCP server is hosted at `https://mcp.slack.com/mcp`, uses Streamable HTTP, and requires confidential OAuth with your Slack app's client credentials.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -167,7 +167,7 @@ The Extensions dashboard also loads `slash-commands` capability and displays act
|
||||
|
||||
1. **Extension commands** (`#tryExecuteExtensionCommand`)
|
||||
If `/name` matches extension-registered command, handler executes immediately and prompt returns.
|
||||
2. **TypeScript custom commands and MCP prompt commands** (`#tryExecuteCustomCommand`)
|
||||
2. **TypeScript custom commands and MCP prompt commands** (`#tryExecuteCustomCommand`)
|
||||
Boundary only: if matched, it executes and may return:
|
||||
- `string` -> replace prompt text with that string
|
||||
- `void/undefined` -> treated as handled; no LLM prompt
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added isolated profile support via `--profile <name>` / `OMP_PROFILE` and shell alias bootstrap via `--alias <command>`, including launch/ACP bootstrap handling, extension-flag-safe parsing, profile-scoped user config discovery, and symlinked extension-directory discovery.
|
||||
- Fixed paste and image placeholders crashing when the editor renders before theme initialization.
|
||||
- Added `ModelRegistry.create(authStorage, modelsPath?)` async factory that runs the JSON → YAML migration step on `models.{yml,yaml}` asynchronously ahead of the sync constructor's bundled-model load. The sync `new ModelRegistry(...)` constructor still works (tests rely on it); production boot paths now use the factory so the migration's I/O lands off the event-loop hot path.
|
||||
- Added `ConfigFile.tryLoadAsync()`, `ConfigFile.loadAsync()`, `ConfigFile.loadOrDefaultAsync()`, `ConfigFile.getMtimeMsAsync()`, and `ConfigFile.warmup(file)` so the rest of the codebase can migrate config reads off the sync path.
|
||||
@@ -176,6 +177,9 @@
|
||||
- Fixed a collapsed, still-streaming tool preview (an `eval`/`bash`/`ssh` box with output streaming in) reading as "weirdly truncated" — top border and head rows missing — once its box outgrew the viewport, snapping back to whole only while expanded with `ctrl+o` and breaking again when collapsed. A streaming preview was classified commit-unstable whenever collapsed, so the transcript offered none of its rows to native scrollback; once the box outgrew the window its head fell into the gap between the commit boundary and the window top, committed nowhere and repainted nowhere. The `provisionalPendingPreview` flag now applies only to the pending call preview (before any result) — once a streaming result exists the result renderer is the live, top-anchored shape and the block is commit-stable in both collapsed and expanded states, so its durable head always reaches scrollback.
|
||||
- Fixed a crash in subagent task execution and extensions when a string (instead of a string array) was returned or set for the system prompt. Gracefully wrap string values in arrays.
|
||||
- Fixed the todo completion reminder escalating 1/3 → 2/3 → 3/3 within a single user pause: `#checkTodoCompletion` appended a `<system-reminder>` and then scheduled `agent.continue()`, so a text-only acknowledgement ("paused at your instruction") triggered another `agent_end` that re-ran the same check and fired the next reminder — no user input required. `AgentSession` now tracks `#todoReminderAwaitingProgress`: a reminder sets it, any `toolResult` (real progress) or a new user prompt clears it, and `#checkTodoCompletion` stays silent while it is set. Escalation through `todo.reminders.max` still works when the agent makes tool-level progress between stops ([#2590](https://github.com/can1357/oh-my-pi/issues/2590)).
|
||||
- Fixed profile bootstrap so an extension-shadowed `--plan` flag no longer swallows a following global `--profile`.
|
||||
- Fixed MCP OAuth URL-keyed credentials to stay profile-scoped under shared auth-broker storage and to clear discovered definition-only server auth during `/mcp unauth`.
|
||||
- Fixed auto-learn managed skills to use the active profile's agent directory, so authored profile skills keep priority over managed fallbacks.
|
||||
|
||||
## [15.13.0] - 2026-06-14
|
||||
|
||||
|
||||
@@ -9,11 +9,9 @@
|
||||
*/
|
||||
import { constants as fsConstants, type Stats } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import { SOURCE_PATHS } from "../discovery/helpers";
|
||||
|
||||
/** Provider id stamped on discovered managed skills (distinguishes them from authored). */
|
||||
export const MANAGED_SKILLS_PROVIDER_ID = "omp-managed";
|
||||
@@ -24,8 +22,8 @@ export const MAX_MANAGED_SKILL_BYTES = 64_000;
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
/** Resolve the isolated managed-skills directory (`~/.omp/agent/managed-skills`). */
|
||||
export function getManagedSkillsDir(home: string = os.homedir()): string {
|
||||
return path.join(home, SOURCE_PATHS.native.userAgent, "managed-skills");
|
||||
export function getManagedSkillsDir(agentDir: string = getAgentDir()): string {
|
||||
return path.join(agentDir, "managed-skills");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,13 +38,14 @@ export interface MCPServer {
|
||||
clientSecret?: string;
|
||||
resource?: string;
|
||||
};
|
||||
/** OAuth configuration (clientId, clientSecret, redirectUri, callbackPort, callbackPath) for servers requiring explicit client credentials */
|
||||
/** OAuth configuration (clientId, clientSecret, redirectUri, callbackPort, callbackPath, prompt) for servers requiring explicit client credentials */
|
||||
oauth?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
redirectUri?: string;
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
/** Transport type */
|
||||
transport?: "stdio" | "sse" | "http";
|
||||
|
||||
@@ -15,8 +15,17 @@ try {
|
||||
* lightweight CLI runner from pi-utils.
|
||||
*/
|
||||
import type { CliConfig } from "@oh-my-pi/pi-utils/cli";
|
||||
import { APP_NAME, MIN_BUN_VERSION, VERSION } from "@oh-my-pi/pi-utils/dirs";
|
||||
import { declareWorkerHostEntry } from "@oh-my-pi/pi-utils/env";
|
||||
import {
|
||||
APP_NAME,
|
||||
getActiveProfile,
|
||||
MIN_BUN_VERSION,
|
||||
resolveProfileEnv,
|
||||
setProfile,
|
||||
VERSION,
|
||||
} from "@oh-my-pi/pi-utils/dirs";
|
||||
import { declareWorkerHostEntry } from "@oh-my-pi/pi-utils/worker-host";
|
||||
import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
|
||||
import { extractProfileFlags } from "./cli/profile-bootstrap";
|
||||
|
||||
if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
|
||||
process.stderr.write(
|
||||
@@ -27,11 +36,11 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
|
||||
|
||||
process.title = APP_NAME;
|
||||
|
||||
// Declare this module as the worker-host entry: Worker threads and worker
|
||||
// subprocesses re-enter `Bun.main` with a hidden argv selector instead of
|
||||
// loading separate worker entrypoints (single-entry contract across source,
|
||||
// npm bundle, and compiled binary).
|
||||
declareWorkerHostEntry();
|
||||
// Worker-host entry declaration (Worker threads and worker subprocesses
|
||||
// re-enter `Bun.main` with a hidden argv selector instead of loading separate
|
||||
// worker entrypoints) happens inside `runCli` after profile bootstrap:
|
||||
// `@oh-my-pi/pi-utils/env` eagerly loads `.env` from the agent directory at
|
||||
// import time, so it must not be imported before `setProfile` runs.
|
||||
|
||||
async function showHelp(config: CliConfig): Promise<void> {
|
||||
const { renderRootHelp } = await import("@oh-my-pi/pi-utils/cli");
|
||||
@@ -196,15 +205,67 @@ async function runTinyWorker(): Promise<void> {
|
||||
|
||||
/** Run the CLI with the given argv (no `process.argv` prefix). */
|
||||
export async function runCli(argv: string[]): Promise<void> {
|
||||
if (argv[0] === "--smoke-test") {
|
||||
await runSmokeTest();
|
||||
let resolvedArgv = argv;
|
||||
try {
|
||||
const extracted = extractProfileFlags(resolvedArgv);
|
||||
resolvedArgv = extracted.argv;
|
||||
if (extracted.profile !== undefined) {
|
||||
setProfile(extracted.profile);
|
||||
} else {
|
||||
// No explicit --profile: activate any OMP_PROFILE/PI_PROFILE inherited
|
||||
// from the environment. Module-load resolution deliberately swallows an
|
||||
// invalid value to avoid an uncaught throw before this try/catch is in
|
||||
// scope (see `readProfileFromEnvSafe` in dirs.ts), and callers may set
|
||||
// OMP_PROFILE after importing this module (profile aliases/tests). Surfacing
|
||||
// validation here turns `OMP_PROFILE=.. omp --version` into a clean error;
|
||||
// calling setProfile keeps every later path helper on the env-selected
|
||||
// profile instead of the default agent directory.
|
||||
setProfile(resolveProfileEnv(process.env.OMP_PROFILE, process.env.PI_PROFILE));
|
||||
}
|
||||
if (extracted.aliasName !== undefined) {
|
||||
const profile = extracted.profile ?? getActiveProfile();
|
||||
if (!profile) {
|
||||
throw new Error("--alias requires --profile <name> or OMP_PROFILE");
|
||||
}
|
||||
const result = await installProfileAlias({
|
||||
profile,
|
||||
aliasName: extracted.aliasName,
|
||||
command: resolveProfileAliasCommandFromProcess(),
|
||||
});
|
||||
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 (TINY_WORKER_ARGS.has(argv[0] ?? "")) {
|
||||
|
||||
// Worker-thread entry dispatch must run before the first `await`: the
|
||||
// stats sync worker's buffering onmessage handler is installed in the
|
||||
// synchronous prefix of `runWorkerEntrypoint`, and Bun flushes the
|
||||
// worker's parked initial messages as soon as the entry module's
|
||||
// top-level evaluation finishes.
|
||||
if (TINY_WORKER_ARGS.has(resolvedArgv[0] ?? "")) {
|
||||
await runTinyWorker();
|
||||
return;
|
||||
}
|
||||
if (await runWorkerEntrypoint(argv[0])) {
|
||||
if (await runWorkerEntrypoint(resolvedArgv[0])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Declare this module as the worker-host entry now that the active profile
|
||||
// is resolved. The worker-host module is side-effect-free; importing
|
||||
// `@oh-my-pi/pi-utils/env` here would snapshot the wrong agent `.env`.
|
||||
declareWorkerHostEntry();
|
||||
|
||||
if (resolvedArgv[0] === "--smoke-test") {
|
||||
await runSmokeTest();
|
||||
return;
|
||||
}
|
||||
const [{ run }, { commands, resolveCliArgv }] = await Promise.all([
|
||||
@@ -213,7 +274,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
]);
|
||||
// --help and --version are handled by run() directly, don't rewrite those.
|
||||
// Everything else that isn't a known subcommand routes to "launch".
|
||||
const resolved = resolveCliArgv(argv);
|
||||
const resolved = resolveCliArgv(resolvedArgv);
|
||||
if ("error" in resolved) {
|
||||
process.stderr.write(`error: ${resolved.error}\n`);
|
||||
process.exitCode = 1;
|
||||
@@ -226,7 +287,13 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
// lowering) builds to fail, and the entrypoint needs nothing after this.
|
||||
// The catch mirrors what an unhandled TLA rejection produced: error dump to
|
||||
// stderr, exit code 1. Success paths resolve without touching the exit code.
|
||||
runCli(process.argv.slice(2)).catch((err: unknown) => {
|
||||
process.stderr.write(`${Bun.inspect(err, { colors: process.stderr.isTTY === true })}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
// Guarded so importing `runCli` (profile CLI tests, SDK embedding) does not
|
||||
// launch the agent as a side effect. Worker threads re-enter this module as
|
||||
// their entry with `import.meta.main === false`, so the worker-host dispatch
|
||||
// is admitted via `!Bun.isMainThread`.
|
||||
if (import.meta.main || !Bun.isMainThread) {
|
||||
runCli(process.argv.slice(2)).catch((err: unknown) => {
|
||||
process.stderr.write(`${Bun.inspect(err, { colors: process.stderr.isTTY === true })}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,12 +5,22 @@ import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-catalog/effort";
|
||||
import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "chalk";
|
||||
import { parseEffort } from "../thinking";
|
||||
import { BUILTIN_TOOLS } from "../tools";
|
||||
import { BUILTIN_TOOL_NAMES } from "../tools/builtin-names";
|
||||
import {
|
||||
OPTIONAL_FLAGS,
|
||||
OPTIONAL_VALUE_FLAGS,
|
||||
type ParseDeps,
|
||||
PROFILE_BOOTSTRAP_BOUNDARY_ARG,
|
||||
STRING_SETTERS,
|
||||
STRING_VALUE_FLAGS,
|
||||
} from "./flag-tables";
|
||||
|
||||
export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui";
|
||||
|
||||
export interface Args {
|
||||
cwd?: string;
|
||||
profile?: string;
|
||||
alias?: string;
|
||||
allowHome?: boolean;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
@@ -68,6 +78,19 @@ export interface Args {
|
||||
unrecognizedFlags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime dependencies the data-driven setters need. Constructed once at
|
||||
* module load and passed to every {@link STRING_SETTERS} call so the
|
||||
* setter table itself can stay free of `@oh-my-pi/pi-utils` runtime imports
|
||||
* (which would otherwise trip the profile bootstrap's env-init ordering).
|
||||
*/
|
||||
const PARSE_DEPS: ParseDeps = {
|
||||
logger,
|
||||
parseEffort,
|
||||
builtinToolNames: BUILTIN_TOOL_NAMES,
|
||||
thinkingEfforts: THINKING_EFFORTS,
|
||||
};
|
||||
|
||||
export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
|
||||
// Work on a copy: the `--option=value` handling below splices the value
|
||||
// into the array, and callers reuse the same argv (the post-extension
|
||||
@@ -81,19 +104,19 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
||||
unrecognizedFlags: [],
|
||||
};
|
||||
|
||||
// `--` ends option parsing (POSIX end-of-options). Everything after it is
|
||||
// literal positional text, so flag-shaped messages are not parsed or rejected.
|
||||
let sawSeparator = false;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
let arg = args[i];
|
||||
const flagIndex = i;
|
||||
|
||||
// POSIX positional separator: once `--` lands, every remaining token is
|
||||
// a positional regardless of shape. Without this, a flag-looking message
|
||||
// (`omp -p -- --explain-this`) would be re-validated by the loop below
|
||||
// and rejected by the unknown-flag guard (#2461 review).
|
||||
if (sawSeparator) {
|
||||
result.messages.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === PROFILE_BOOTSTRAP_BOUNDARY_ARG) {
|
||||
continue;
|
||||
}
|
||||
const flagIndex = i;
|
||||
|
||||
// Support --flag=value syntax (e.g. --tools=ask,read). The value is
|
||||
// spliced in as the next token so value-consuming flags pick it up via
|
||||
@@ -120,112 +143,53 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
||||
if (extFlag.type === "boolean") {
|
||||
result.unknownFlags.set(flagName, true);
|
||||
} else if (extFlag.type === "string" && i + 1 < args.length) {
|
||||
// Consume the value in `--flag=value` form, or when the next token is
|
||||
// not flag-looking. A `-`-prefixed token in space form is left to be
|
||||
// its own flag; pass a flag-looking value as `--flag=value`.
|
||||
// Consume the value in `--flag=value` form or when the next token is not
|
||||
// flag-looking. A standalone `--` remains the end-of-options marker; use
|
||||
// `--flag=--` when an extension needs a literal "--" string value.
|
||||
if (equalsValueIndex !== -1 || !args[i + 1].startsWith("-")) {
|
||||
result.unknownFlags.set(flagName, args[++i]);
|
||||
}
|
||||
}
|
||||
} else if (STRING_VALUE_FLAGS.has(arg)) {
|
||||
if (i + 1 < args.length) {
|
||||
STRING_SETTERS[arg](result, args[++i], PARSE_DEPS);
|
||||
}
|
||||
} else if (OPTIONAL_VALUE_FLAGS.has(arg)) {
|
||||
const config = OPTIONAL_FLAGS[arg];
|
||||
const next = args[i + 1];
|
||||
const consume =
|
||||
next !== undefined && !next.startsWith("-") && !(config.rejectEmpty === true && next.length === 0);
|
||||
config.set(result, consume ? args[++i] : undefined);
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
} else if (arg === "--version" || arg === "-v") {
|
||||
result.version = true;
|
||||
} else if (arg === "--allow-home") {
|
||||
result.allowHome = true;
|
||||
} else if (arg === "--cwd" && i + 1 < args.length) {
|
||||
result.cwd = args[++i];
|
||||
} else if (arg === "--config" && i + 1 < args.length) {
|
||||
result.config = [...(result.config ?? []), args[++i]];
|
||||
} else if (arg === "--mode" && i + 1 < args.length) {
|
||||
const mode = args[++i];
|
||||
if (mode === "text" || mode === "json" || mode === "rpc" || mode === "acp" || mode === "rpc-ui") {
|
||||
result.mode = mode;
|
||||
}
|
||||
} else if (arg === "--profile" && i + 1 < args.length) {
|
||||
// Normally stripped by `extractProfileFlags` before parseArgs sees it;
|
||||
// kept here as a fallback for direct parseArgs callers.
|
||||
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 === "--continue" || arg === "-c") {
|
||||
result.continue = true;
|
||||
} else if (arg === "--resume" || arg === "-r" || arg === "--session") {
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("-")) {
|
||||
result.resume = args[++i];
|
||||
} else {
|
||||
result.resume = true;
|
||||
}
|
||||
} else if (arg === "--fork" && i + 1 < args.length) {
|
||||
result.fork = args[++i];
|
||||
} else if (arg === "--provider" && i + 1 < args.length) {
|
||||
result.provider = args[++i];
|
||||
} else if (arg === "--model" && i + 1 < args.length) {
|
||||
result.model = args[++i];
|
||||
} else if (arg === "--smol" && i + 1 < args.length) {
|
||||
result.smol = args[++i];
|
||||
} else if (arg === "--slow" && i + 1 < args.length) {
|
||||
result.slow = args[++i];
|
||||
} else if (arg === "--plan" && i + 1 < args.length) {
|
||||
result.plan = args[++i];
|
||||
} else if (arg === "--api-key" && i + 1 < args.length) {
|
||||
result.apiKey = args[++i];
|
||||
} else if (arg === "--system-prompt" && i + 1 < args.length) {
|
||||
result.systemPrompt = args[++i];
|
||||
} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
|
||||
result.appendSystemPrompt = args[++i];
|
||||
} else if (arg === "--provider-session-id" && i + 1 < args.length) {
|
||||
result.providerSessionId = args[++i];
|
||||
} else if (arg === "--no-session") {
|
||||
result.noSession = true;
|
||||
} else if (arg === "--session-dir" && i + 1 < args.length) {
|
||||
result.sessionDir = args[++i];
|
||||
} else if (arg === "--models" && i + 1 < args.length) {
|
||||
result.models = args[++i].split(",").map(s => s.trim());
|
||||
} else if (arg === "--no-tools") {
|
||||
result.noTools = true;
|
||||
} else if (arg === "--no-lsp") {
|
||||
result.noLsp = true;
|
||||
} else if (arg === "--no-pty") {
|
||||
result.noPty = true;
|
||||
} else if (arg === "--tools" && i + 1 < args.length) {
|
||||
const toolNames = args[++i]
|
||||
.split(",")
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const validTools: string[] = [];
|
||||
for (const name of toolNames) {
|
||||
if (name in BUILTIN_TOOLS) {
|
||||
validTools.push(name);
|
||||
} else {
|
||||
logger.warn("Unknown tool passed to --tools", {
|
||||
tool: name,
|
||||
validTools: Object.keys(BUILTIN_TOOLS),
|
||||
});
|
||||
}
|
||||
}
|
||||
result.tools = validTools;
|
||||
} else if (arg === "--thinking" && i + 1 < args.length) {
|
||||
const rawThinking = args[++i];
|
||||
const thinking = parseEffort(rawThinking);
|
||||
if (thinking !== undefined) {
|
||||
result.thinking = thinking;
|
||||
} else {
|
||||
logger.warn("Invalid thinking level passed to --thinking", {
|
||||
level: rawThinking,
|
||||
validThinkingLevels: THINKING_EFFORTS,
|
||||
});
|
||||
}
|
||||
} else if (arg === "--hide-thinking") {
|
||||
result.hideThinking = true;
|
||||
} else if (arg === "--print" || arg === "-p") {
|
||||
result.print = true;
|
||||
} else if (arg === "--export" && i + 1 < args.length) {
|
||||
result.export = args[++i];
|
||||
} else if (arg === "--hook" && i + 1 < args.length) {
|
||||
result.hooks = result.hooks ?? [];
|
||||
result.hooks.push(args[++i]);
|
||||
} else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {
|
||||
result.extensions = result.extensions ?? [];
|
||||
result.extensions.push(args[++i]);
|
||||
} else if (arg === "--plugin-dir" && i + 1 < args.length) {
|
||||
result.pluginDirs = result.pluginDirs ?? [];
|
||||
result.pluginDirs.push(args[++i]);
|
||||
} else if (arg === "--no-extensions") {
|
||||
result.noExtensions = true;
|
||||
} else if (arg === "--no-skills") {
|
||||
@@ -236,19 +200,6 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
||||
result.noTitle = true;
|
||||
} else if (arg === "--auto-approve" || arg === "--yolo") {
|
||||
result.autoApprove = true;
|
||||
} else if (arg === "--approval-mode" && i + 1 < args.length) {
|
||||
const mode = args[++i];
|
||||
if (mode === "always-ask" || mode === "write" || mode === "yolo") {
|
||||
result.approvalMode = mode;
|
||||
} else {
|
||||
logger.warn("Invalid value passed to --approval-mode", {
|
||||
value: mode,
|
||||
validValues: ["always-ask", "write", "yolo"],
|
||||
});
|
||||
}
|
||||
} else if (arg === "--skills" && i + 1 < args.length) {
|
||||
// Comma-separated glob patterns for skill filtering
|
||||
result.skills = args[++i].split(",").map(s => s.trim());
|
||||
} else if (arg.startsWith("@")) {
|
||||
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
||||
} else if (!arg.startsWith("-") || arg === "-") {
|
||||
@@ -344,13 +295,14 @@ export function getExtraHelpText(): string {
|
||||
ANTHROPIC_SEARCH_BASE_URL - Anthropic web search base URL (override; pairs with ANTHROPIC_SEARCH_API_KEY)
|
||||
|
||||
${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):")}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* knob and is keyed by flag name so it stays stable as flags are added.
|
||||
*/
|
||||
import type { ArgDescriptor, CliConfig, CommandCtor, FlagDescriptor } from "@oh-my-pi/pi-utils/cli";
|
||||
import { BUILTIN_TOOLS } from "../tools";
|
||||
import { BUILTIN_TOOL_NAMES } from "../tools/builtin-names";
|
||||
|
||||
export type Shell = "bash" | "zsh" | "fish";
|
||||
|
||||
@@ -77,7 +77,7 @@ function flagValue(name: string, desc: FlagDescriptor): ValueSource {
|
||||
if (MODEL_FLAGS[name]) return { kind: "models", multiple: false };
|
||||
if (name === "models") return { kind: "models", multiple: true };
|
||||
if (SESSION_FLAGS[name]) return { kind: "sessions" };
|
||||
if (name === "tools") return { kind: "list", values: Object.keys(BUILTIN_TOOLS) };
|
||||
if (name === "tools") return { kind: "list", values: BUILTIN_TOOL_NAMES };
|
||||
if (DIR_FLAGS[name]) return { kind: "dir" };
|
||||
if (desc.kind === "integer") return { kind: "value" };
|
||||
return { kind: "file" };
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Single source of truth for argv flag classification, shared by:
|
||||
* - `parseArgs` in `./args.ts` (the launch-time CLI parser)
|
||||
* - `extractProfileFlags` in `./profile-bootstrap.ts` (the early
|
||||
* `--profile` / `--alias` pre-parser)
|
||||
*
|
||||
* `parseArgs` dispatches string-valued flags by looking up their setter in
|
||||
* {@link STRING_SETTERS}. Optional-value flags use {@link OPTIONAL_FLAGS} so
|
||||
* per-flag quirks (currently empty-string rejection for `--resume`) live here
|
||||
* instead of being hard-coded in the dispatch loop.
|
||||
*
|
||||
* The bootstrap doesn't dispatch — it only needs to know which flags consume
|
||||
* a value — so it consults {@link STRING_VALUE_FLAGS} and
|
||||
* {@link OPTIONAL_VALUE_FLAGS}, both derived from `Object.keys(...)` on the
|
||||
* setter/config records below.
|
||||
*
|
||||
* The deliberate consequence: a string-valued flag exists in this CLI surface
|
||||
* iff it has an entry here. Adding a new string-valued flag means adding a
|
||||
* setter/config entry in this file; both `args.ts` and the bootstrap pick it
|
||||
* up automatically, so the two cannot drift out of sync.
|
||||
*
|
||||
* IMPORT RULE: this module MUST NOT import any runtime value from
|
||||
* `@oh-my-pi/pi-utils` (or anything that transitively does). That package's
|
||||
* `env.ts` eagerly loads `.env` files from `getAgentDir()` during module
|
||||
* initialization, which would race the profile bootstrap. Type-only imports
|
||||
* are erased at runtime and are therefore safe.
|
||||
*
|
||||
* If a setter needs runtime dependencies (logging, validators, lookup
|
||||
* tables), they're passed in through {@link ParseDeps} and `args.ts` wires the
|
||||
* real implementations at the dispatch site.
|
||||
*/
|
||||
|
||||
import type { Effort } from "@oh-my-pi/pi-ai";
|
||||
import type { Args } from "./args";
|
||||
|
||||
/**
|
||||
* Runtime dependencies injected into setters that need to validate input or
|
||||
* warn about bad values. `args.ts` constructs one object at module load and
|
||||
* passes it to each {@link STRING_SETTERS} call.
|
||||
*
|
||||
* Keeping these out of the setter closures means this module stays free of
|
||||
* runtime imports from `@oh-my-pi/pi-utils`, which is the whole reason it can
|
||||
* be safely imported by `profile-bootstrap.ts` before `setProfile` runs.
|
||||
*/
|
||||
export interface ParseDeps {
|
||||
logger: { warn: (message: string, meta?: Record<string, unknown>) => void };
|
||||
parseEffort: (value: string | null | undefined) => Effort | undefined;
|
||||
builtinToolNames: readonly string[];
|
||||
thinkingEfforts: readonly string[];
|
||||
}
|
||||
|
||||
export type StringSetter = (result: Args, value: string, deps: ParseDeps) => void;
|
||||
|
||||
/**
|
||||
* Setter for a flag that may or may not consume the next argv token.
|
||||
* Receives `undefined` for the bare form (`--resume` with no value, etc.).
|
||||
*/
|
||||
export type OptionalSetter = (result: Args, value: string | undefined) => void;
|
||||
|
||||
/**
|
||||
* Per-flag optional-value consumption policy.
|
||||
*
|
||||
* Every optional flag always rejects tokens that start with `-` — that shared
|
||||
* rule lives in the dispatch site. These booleans capture the *additional*
|
||||
* per-flag quirks:
|
||||
*
|
||||
* - `rejectEmpty`: treat `""` like “no value provided”. Needed for
|
||||
* `--resume` / `-r` / `--session`. Without it, an empty string
|
||||
* gets consumed as the session prefix and downstream resolution can match
|
||||
* every session.
|
||||
*/
|
||||
export interface OptionalFlagConfig {
|
||||
set: OptionalSetter;
|
||||
rejectEmpty?: boolean;
|
||||
}
|
||||
|
||||
// Shared setters for flags that alias the same field.
|
||||
const setExtension: StringSetter = (result, value) => {
|
||||
result.extensions = result.extensions ?? [];
|
||||
result.extensions.push(value);
|
||||
};
|
||||
|
||||
const setResume: OptionalSetter = (result, value) => {
|
||||
result.resume = value !== undefined ? value : true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Setters for flags with string values. Most built-ins consume the next argv
|
||||
* token even when it starts with `-`; flags listed in
|
||||
* {@link EXTENSION_SHADOWABLE_STRING_FLAGS} use extension-style consumption so
|
||||
* a registered boolean extension can shadow them before profile bootstrap.
|
||||
*/
|
||||
export const STRING_SETTERS: Record<string, StringSetter> = {
|
||||
"--cwd": (result, value) => {
|
||||
result.cwd = value;
|
||||
},
|
||||
"--config": (result, value) => {
|
||||
result.config = [...(result.config ?? []), value];
|
||||
},
|
||||
"--mode": (result, value) => {
|
||||
if (value === "text" || value === "json" || value === "rpc" || value === "acp" || value === "rpc-ui") {
|
||||
result.mode = value;
|
||||
}
|
||||
},
|
||||
"--fork": (result, value) => {
|
||||
result.fork = value;
|
||||
},
|
||||
"--provider": (result, value) => {
|
||||
result.provider = value;
|
||||
},
|
||||
"--model": (result, value) => {
|
||||
result.model = value;
|
||||
},
|
||||
"--smol": (result, value) => {
|
||||
result.smol = value;
|
||||
},
|
||||
"--slow": (result, value) => {
|
||||
result.slow = value;
|
||||
},
|
||||
"--plan": (result, value) => {
|
||||
result.plan = value;
|
||||
},
|
||||
"--api-key": (result, value) => {
|
||||
result.apiKey = value;
|
||||
},
|
||||
"--system-prompt": (result, value) => {
|
||||
result.systemPrompt = value;
|
||||
},
|
||||
"--append-system-prompt": (result, value) => {
|
||||
result.appendSystemPrompt = value;
|
||||
},
|
||||
"--provider-session-id": (result, value) => {
|
||||
result.providerSessionId = value;
|
||||
},
|
||||
"--session-dir": (result, value) => {
|
||||
result.sessionDir = value;
|
||||
},
|
||||
"--models": (result, value) => {
|
||||
result.models = value.split(",").map(s => s.trim());
|
||||
},
|
||||
"--tools": (result, value, deps) => {
|
||||
const names = value
|
||||
.split(",")
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const valid: string[] = [];
|
||||
for (const name of names) {
|
||||
if (deps.builtinToolNames.includes(name)) {
|
||||
valid.push(name);
|
||||
} else {
|
||||
deps.logger.warn("Unknown tool passed to --tools", {
|
||||
tool: name,
|
||||
validTools: deps.builtinToolNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
result.tools = valid;
|
||||
},
|
||||
"--thinking": (result, value, deps) => {
|
||||
const thinking = deps.parseEffort(value);
|
||||
if (thinking !== undefined) {
|
||||
result.thinking = thinking;
|
||||
} else {
|
||||
deps.logger.warn("Invalid thinking level passed to --thinking", {
|
||||
level: value,
|
||||
validThinkingLevels: deps.thinkingEfforts,
|
||||
});
|
||||
}
|
||||
},
|
||||
"--export": (result, value) => {
|
||||
result.export = value;
|
||||
},
|
||||
"--hook": (result, value) => {
|
||||
result.hooks = result.hooks ?? [];
|
||||
result.hooks.push(value);
|
||||
},
|
||||
"--extension": setExtension,
|
||||
"-e": setExtension,
|
||||
"--plugin-dir": (result, value) => {
|
||||
result.pluginDirs = result.pluginDirs ?? [];
|
||||
result.pluginDirs.push(value);
|
||||
},
|
||||
"--skills": (result, value) => {
|
||||
result.skills = value.split(",").map(s => s.trim());
|
||||
},
|
||||
"--approval-mode": (result, value, deps) => {
|
||||
if (value === "always-ask" || value === "write" || value === "yolo") {
|
||||
result.approvalMode = value;
|
||||
} else {
|
||||
deps.logger.warn("Invalid value passed to --approval-mode", {
|
||||
value,
|
||||
validValues: ["always-ask", "write", "yolo"],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional-value flags. Setters receive `undefined` for the bare form.
|
||||
*
|
||||
* The dispatch in `args.ts` applies the shared "doesn't start with `-`"
|
||||
* check for every flag, then consults the per-flag booleans below for the
|
||||
* remaining quirks.
|
||||
*/
|
||||
export const OPTIONAL_FLAGS: Record<string, OptionalFlagConfig> = {
|
||||
"--resume": { set: setResume, rejectEmpty: true },
|
||||
"-r": { set: setResume, rejectEmpty: true },
|
||||
"--session": { set: setResume, rejectEmpty: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Derived from {@link STRING_SETTERS}. A flag is in this set if and only if
|
||||
* it has a setter — by construction, drift between "the bootstrap thinks
|
||||
* this flag accepts a value" and "the launch parser can set one" is
|
||||
* structurally impossible.
|
||||
*/
|
||||
export const STRING_VALUE_FLAGS: ReadonlySet<string> = new Set(Object.keys(STRING_SETTERS));
|
||||
|
||||
/**
|
||||
* Built-in string flags known to be shadowed by bundled/common boolean
|
||||
* extensions before extension metadata is available. They still accept a
|
||||
* value-like successor for the built-in form (`--plan opus`), but a
|
||||
* flag-looking successor remains a fresh flag (`--plan --profile work`).
|
||||
*/
|
||||
export const EXTENSION_SHADOWABLE_STRING_FLAGS: ReadonlySet<string> = new Set(["--plan"]);
|
||||
|
||||
/**
|
||||
* Derived from {@link OPTIONAL_FLAGS}. Same single-source contract as
|
||||
* {@link STRING_VALUE_FLAGS}.
|
||||
*/
|
||||
export const OPTIONAL_VALUE_FLAGS: ReadonlySet<string> = new Set(Object.keys(OPTIONAL_FLAGS));
|
||||
|
||||
/**
|
||||
* Internal marker inserted by the profile bootstrap when removing `--profile`
|
||||
* or `--alias` would otherwise make the following value-like token become the
|
||||
* value of a preceding optional/extension flag. `parseArgs` ignores it, but its
|
||||
* flag-looking shape preserves argv boundaries during the second parse.
|
||||
*/
|
||||
export const PROFILE_BOOTSTRAP_BOUNDARY_ARG = "--omp-profile-boundary";
|
||||
|
||||
/**
|
||||
* Long-form launch flags that take NO value (booleans). The bootstrap pre-parser
|
||||
* needs this to tell a known value-less flag (whose successor is a fresh
|
||||
* argument — `omp --print --profile work` still selects a profile) apart from an
|
||||
* UNKNOWN long option that might be an extension string flag consuming the next
|
||||
* token as its value (so the bootstrap must not steal that token as a global
|
||||
* `--profile`/`--alias`). MUST mirror the value-less flag arms of `parseArgs`
|
||||
* in `./args.ts`: adding a new boolean launch flag there means adding it here,
|
||||
* or `--<newflag> --profile X` stops selecting a profile. Short aliases
|
||||
* (`-h`/`-v`/`-c`/`-p`) are intentionally omitted — the protection rule only
|
||||
* fires for `--`-prefixed tokens.
|
||||
*/
|
||||
export const VALUELESS_FLAGS: ReadonlySet<string> = new Set([
|
||||
"--help",
|
||||
"--version",
|
||||
"--allow-home",
|
||||
"--continue",
|
||||
"--no-session",
|
||||
"--no-tools",
|
||||
"--no-lsp",
|
||||
"--no-pty",
|
||||
"--hide-thinking",
|
||||
"--print",
|
||||
"--no-extensions",
|
||||
"--no-skills",
|
||||
"--no-rules",
|
||||
"--no-title",
|
||||
"--auto-approve",
|
||||
"--yolo",
|
||||
]);
|
||||
@@ -0,0 +1,338 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { normalizeProfileName } from "@oh-my-pi/pi-utils/dirs";
|
||||
|
||||
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 ProfileAliasCommand {
|
||||
display: string;
|
||||
posix: string;
|
||||
fish: string;
|
||||
powerShell: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ALIAS_COMMAND: ProfileAliasCommand = {
|
||||
display: "omp",
|
||||
posix: "omp",
|
||||
fish: "omp",
|
||||
powerShell: "omp",
|
||||
};
|
||||
|
||||
export interface ProfileAliasInstallOptions {
|
||||
profile: string;
|
||||
aliasName: string;
|
||||
shellPath?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
readFile?: (filePath: string) => Promise<string>;
|
||||
command?: ProfileAliasCommand;
|
||||
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}$/;
|
||||
const POSIX_RESERVED_ALIAS_NAMES: ReadonlySet<string> = new Set([
|
||||
"case",
|
||||
"coproc",
|
||||
"do",
|
||||
"done",
|
||||
"elif",
|
||||
"else",
|
||||
"esac",
|
||||
"fi",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"in",
|
||||
"select",
|
||||
"then",
|
||||
"time",
|
||||
"until",
|
||||
"while",
|
||||
]);
|
||||
const FISH_RESERVED_ALIAS_NAMES: ReadonlySet<string> = new Set([
|
||||
"and",
|
||||
"begin",
|
||||
"break",
|
||||
"builtin",
|
||||
"case",
|
||||
"command",
|
||||
"continue",
|
||||
"else",
|
||||
"end",
|
||||
"exec",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"not",
|
||||
"or",
|
||||
"return",
|
||||
"switch",
|
||||
"while",
|
||||
]);
|
||||
const POWERSHELL_RESERVED_ALIAS_NAMES: ReadonlySet<string> = new Set([
|
||||
"begin",
|
||||
"break",
|
||||
"catch",
|
||||
"class",
|
||||
"continue",
|
||||
"data",
|
||||
"do",
|
||||
"dynamicparam",
|
||||
"else",
|
||||
"elseif",
|
||||
"end",
|
||||
"enum",
|
||||
"exit",
|
||||
"filter",
|
||||
"finally",
|
||||
"for",
|
||||
"foreach",
|
||||
"from",
|
||||
"function",
|
||||
"if",
|
||||
"in",
|
||||
"param",
|
||||
"process",
|
||||
"return",
|
||||
"switch",
|
||||
"throw",
|
||||
"trap",
|
||||
"try",
|
||||
"until",
|
||||
"using",
|
||||
"var",
|
||||
"while",
|
||||
"workflow",
|
||||
]);
|
||||
|
||||
// Keep local: importing the pi-utils root here would eagerly load env before
|
||||
// cli.ts has applied --profile, regressing profile-specific .env loading.
|
||||
function isEnoentError(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "ENOENT";
|
||||
}
|
||||
|
||||
function getReservedAliasNames(shell: ProfileAliasShell): ReadonlySet<string> {
|
||||
switch (shell) {
|
||||
case "bash":
|
||||
case "zsh":
|
||||
return POSIX_RESERVED_ALIAS_NAMES;
|
||||
case "fish":
|
||||
return FISH_RESERVED_ALIAS_NAMES;
|
||||
case "powershell":
|
||||
case "pwsh":
|
||||
return POWERSHELL_RESERVED_ALIAS_NAMES;
|
||||
}
|
||||
}
|
||||
|
||||
function validateAliasName(aliasName: string, shell: ProfileAliasShell): 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.toLowerCase() === "omp") {
|
||||
throw new Error('Invalid alias "omp". Refusing to shadow the base omp command.');
|
||||
}
|
||||
if (getReservedAliasNames(shell).has(normalized.toLowerCase())) {
|
||||
throw new Error(`Invalid alias "${aliasName}". Refusing to create a ${shell} reserved word.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// On Windows the launching shell is rarely exported through $SHELL, so when it
|
||||
// is missing we infer the PowerShell edition from the inherited environment.
|
||||
// PowerShell 7 (pwsh) always seeds PSModulePath with separator-delimited
|
||||
// ".../PowerShell/..." module directories (plus the Windows PowerShell ones for
|
||||
// back-compat), whereas Windows PowerShell 5.1 only ever lists
|
||||
// ".../WindowsPowerShell/...". The separator anchors keep "WindowsPowerShell"
|
||||
// from matching. POWERSHELL_DISTRIBUTION_CHANNEL is set only by some pwsh
|
||||
// distributions, so it stays a secondary hint rather than the primary signal.
|
||||
function detectWindowsPowerShell(env: NodeJS.ProcessEnv): ProfileAliasShell {
|
||||
const modulePath = env.PSModulePath ?? env.PSMODULEPATH ?? env.psmodulepath ?? "";
|
||||
if (/[\\/]PowerShell[\\/]/i.test(modulePath)) return "pwsh";
|
||||
if (env.POWERSHELL_DISTRIBUTION_CHANNEL) return "pwsh";
|
||||
return "powershell";
|
||||
}
|
||||
|
||||
function normalizeShellName(
|
||||
shellPath: string | undefined,
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): ProfileAliasShell {
|
||||
const shell = path
|
||||
.basename(shellPath ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/\.exe$/, "");
|
||||
if (shell === "zsh") return "zsh";
|
||||
if (shell === "bash") return "bash";
|
||||
if (shell === "fish") return "fish";
|
||||
if (shell === "pwsh") return "pwsh";
|
||||
if (shell === "powershell") return "powershell";
|
||||
if (platform === "win32") return detectWindowsPowerShell(env);
|
||||
throw new Error(`Unsupported shell${shell ? ` "${shell}"` : ""}. Supported shells: bash, zsh, fish, PowerShell.`);
|
||||
}
|
||||
|
||||
export function resolveProfileAliasCommandFromProcess(
|
||||
argv: readonly string[] = process.argv,
|
||||
cwd: string = process.cwd(),
|
||||
): ProfileAliasCommand {
|
||||
const runtime = argv[0];
|
||||
const script = argv[1];
|
||||
if (!runtime || !script || !/\.[cm]?[jt]s$/.test(script)) return DEFAULT_ALIAS_COMMAND;
|
||||
|
||||
const scriptPath = path.resolve(cwd, script);
|
||||
const posix = `${quoteForShell(runtime)} ${quoteForShell(scriptPath)}`;
|
||||
return {
|
||||
display: `${runtime} ${scriptPath}`,
|
||||
posix,
|
||||
fish: posix,
|
||||
powerShell: `${quoteForPowerShell(runtime)} ${quoteForPowerShell(scriptPath)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveShellConfigPath(
|
||||
shell: ProfileAliasShell,
|
||||
homeDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string {
|
||||
switch (shell) {
|
||||
case "zsh":
|
||||
return path.join(env.ZDOTDIR || homeDir, ".zshrc");
|
||||
case "bash":
|
||||
return platform === "darwin" ? path.join(homeDir, ".bash_profile") : path.join(homeDir, ".bashrc");
|
||||
case "fish": {
|
||||
// fish sources conf.d from $XDG_CONFIG_HOME/fish (default ~/.config/fish);
|
||||
// a hard-coded ~/.config would be silently ignored when the user relocates
|
||||
// their XDG config root, leaving the alias unsourced after a restart.
|
||||
const configHome = env.XDG_CONFIG_HOME || path.join(homeDir, ".config");
|
||||
return path.join(configHome, "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,
|
||||
command: ProfileAliasCommand,
|
||||
): { block: string; command: string } {
|
||||
const profiledCommand = `${command.display} --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.fish} --profile=${profile} $argv`,
|
||||
"end",
|
||||
].join("\n");
|
||||
break;
|
||||
case "powershell":
|
||||
case "pwsh":
|
||||
body = [`function ${aliasName} {`, ` & ${command.powerShell} --profile=${profile} @args`, "}"].join("\n");
|
||||
break;
|
||||
default:
|
||||
body = [`${aliasName}() {`, ` command ${command.posix} --profile=${profile} "$@"`, "}"].join("\n");
|
||||
break;
|
||||
}
|
||||
return { block: `${start}\n${body}\n${end}`, command: profiledCommand };
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error(
|
||||
`Found "${start}" without a matching "${end}" in the shell config. ` +
|
||||
`The managed alias block is malformed; remove the stale marker line and rerun --alias.`,
|
||||
);
|
||||
}
|
||||
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`;
|
||||
}
|
||||
|
||||
function readAliasConfigText(filePath: string): Promise<string> {
|
||||
return Bun.file(filePath).text();
|
||||
}
|
||||
|
||||
export async function readProfileAliasConfigFile(
|
||||
filePath: string,
|
||||
readText: (filePath: string) => Promise<string> = readAliasConfigText,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await readText(filePath);
|
||||
} catch (error) {
|
||||
if (isEnoentError(error)) return "";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installProfileAlias(options: ProfileAliasInstallOptions): Promise<ProfileAliasInstallResult> {
|
||||
const profile = normalizeProfileName(options.profile);
|
||||
if (!profile) {
|
||||
throw new Error("--alias requires a named --profile value.");
|
||||
}
|
||||
const platform = options.platform ?? process.platform;
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const env = options.env ?? process.env;
|
||||
const shell = normalizeShellName(options.shellPath ?? env.SHELL, platform, env);
|
||||
const aliasName = validateAliasName(options.aliasName, shell);
|
||||
const configPath = resolveShellConfigPath(shell, homeDir, platform, env);
|
||||
const { block, command } = renderAliasBlock(shell, aliasName, profile, options.command ?? DEFAULT_ALIAS_COMMAND);
|
||||
const readFile = options.readFile ?? readProfileAliasConfigFile;
|
||||
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,243 @@
|
||||
/**
|
||||
* 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 usually consume the next
|
||||
* token even when it starts with `-`, except for string flags that can be
|
||||
* shadowed by preloaded boolean extensions (currently `--plan`). Optional-value
|
||||
* flags (`--resume`, `--session`, `-r`) 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.
|
||||
*
|
||||
* The shared classification lives in {@link ./flag-tables}, imported below,
|
||||
* so the bootstrap and `args.ts` reference one source of truth instead of
|
||||
* maintaining parallel constants.
|
||||
*
|
||||
* An unclassified bare long option (one not in any flag table) is treated as a
|
||||
* possible extension string flag, but the bootstrap mirrors `parseArgs`'
|
||||
* extension-flag rules ({@link ./args}): a string extension flag consumes its
|
||||
* successor ONLY when that successor is value-like (does not start with `-`), and
|
||||
* a boolean extension flag consumes nothing. So the successor is forwarded
|
||||
* untouched (and never read as a global `--profile`/`--alias`) only when it is
|
||||
* value-like; a flag-looking successor is left for normal processing, so
|
||||
* `omp --some-ext-flag --profile work` still selects a profile. Known value-less
|
||||
* launch flags ({@link VALUELESS_FLAGS}) are exempt so a trailing profile after
|
||||
* them also activates (`omp --print --profile work`).
|
||||
*/
|
||||
|
||||
import { isSubcommand } from "../cli-commands";
|
||||
import {
|
||||
EXTENSION_SHADOWABLE_STRING_FLAGS,
|
||||
OPTIONAL_FLAGS,
|
||||
OPTIONAL_VALUE_FLAGS,
|
||||
PROFILE_BOOTSTRAP_BOUNDARY_ARG,
|
||||
STRING_VALUE_FLAGS,
|
||||
VALUELESS_FLAGS,
|
||||
} from "./flag-tables";
|
||||
|
||||
function isProfileBootstrapSubcommand(arg: string): boolean {
|
||||
return arg === "launch" || arg === "acp";
|
||||
}
|
||||
|
||||
function isUnknownLongValueCandidate(arg: string): boolean {
|
||||
return (
|
||||
arg.startsWith("--") &&
|
||||
!arg.includes("=") &&
|
||||
!STRING_VALUE_FLAGS.has(arg) &&
|
||||
!OPTIONAL_VALUE_FLAGS.has(arg) &&
|
||||
!VALUELESS_FLAGS.has(arg)
|
||||
);
|
||||
}
|
||||
|
||||
function needsBoundaryAfterGlobalStrip(stripped: readonly string[]): boolean {
|
||||
const previous = stripped[stripped.length - 1];
|
||||
return (
|
||||
previous !== undefined &&
|
||||
(OPTIONAL_VALUE_FLAGS.has(previous) ||
|
||||
EXTENSION_SHADOWABLE_STRING_FLAGS.has(previous) ||
|
||||
isUnknownLongValueCandidate(previous))
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProfileBootstrapResult {
|
||||
argv: string[];
|
||||
profile?: string;
|
||||
aliasName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `--profile` / `--alias` from argv while preserving the surrounding
|
||||
* argument structure, returning the residual argv to hand to the launch parser
|
||||
* and the captured flag values.
|
||||
*
|
||||
* Global flag extraction stops only when the first residual argv token names a
|
||||
* registered command that owns its own flags (e.g. `grep`): everything from
|
||||
* that token onward is forwarded verbatim so a subcommand's own flags and
|
||||
* positionals are never stolen (`omp grep --profile <path>` greps for
|
||||
* `--profile`; it does not select a profile). `launch` and `acp` are explicit
|
||||
* spellings of launch-shaped commands, so `omp launch --profile work` and
|
||||
* `omp acp --profile work` still select profile `work`.
|
||||
*
|
||||
* 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;
|
||||
let sawSubcommand = false;
|
||||
let canDispatchSubcommand = true;
|
||||
let insertBoundaryBeforeNextValue = false;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (passThrough || sawSubcommand) {
|
||||
stripped.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (insertBoundaryBeforeNextValue) {
|
||||
if (!arg.startsWith("-")) {
|
||||
stripped.push(PROFILE_BOOTSTRAP_BOUNDARY_ARG);
|
||||
}
|
||||
insertBoundaryBeforeNextValue = false;
|
||||
}
|
||||
|
||||
// `--` 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;
|
||||
insertBoundaryBeforeNextValue = needsBoundaryAfterGlobalStrip(stripped);
|
||||
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;
|
||||
insertBoundaryBeforeNextValue = needsBoundaryAfterGlobalStrip(stripped);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--alias") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error("--alias requires a command name");
|
||||
}
|
||||
aliasName = value;
|
||||
insertBoundaryBeforeNextValue = needsBoundaryAfterGlobalStrip(stripped);
|
||||
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;
|
||||
insertBoundaryBeforeNextValue = needsBoundaryAfterGlobalStrip(stripped);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Known string flags normally consume flag-looking values (for example
|
||||
// `--system-prompt --profile foo` means the system prompt is literally
|
||||
// `--profile`). A small allow-list of built-ins can be shadowed by boolean
|
||||
// extensions before extension metadata is loaded; those mirror extension
|
||||
// consumption here so `--plan --profile work` still activates `work`.
|
||||
if (EXTENSION_SHADOWABLE_STRING_FLAGS.has(arg)) {
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
const next = argv[index + 1];
|
||||
if (next !== undefined && !next.startsWith("-")) {
|
||||
stripped.push(next);
|
||||
index += 1;
|
||||
}
|
||||
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)) {
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
if (index + 1 < argv.length) {
|
||||
stripped.push(argv[index + 1]);
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (OPTIONAL_VALUE_FLAGS.has(arg)) {
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
const config = OPTIONAL_FLAGS[arg];
|
||||
const next = argv[index + 1];
|
||||
if (next !== undefined && !next.startsWith("-") && !(config.rejectEmpty === true && next.length === 0)) {
|
||||
stripped.push(next);
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// An unclassified bare long option (`--xxx` with no `=`) may be an extension
|
||||
// string flag that consumes the next token as its value. The bootstrap runs
|
||||
// before extensions load, so it cannot consult the extension flag table; it
|
||||
// therefore mirrors the value-consumption rule `parseArgs` applies to
|
||||
// extension flags (./args.ts): a string extension flag consumes its successor
|
||||
// ONLY when that successor is value-like (does not start with `-`), and a
|
||||
// boolean extension flag consumes nothing. So protect (forward + skip) the
|
||||
// successor only when it is value-like — `omp --bar val --profile work` keeps
|
||||
// `val` with `--bar` and still extracts the trailing profile — and otherwise
|
||||
// forward just the flag, letting the loop process a flag-looking successor so
|
||||
// a trailing global flag still applies (`omp --some-ext-bool --profile work`
|
||||
// selects profile `work`). A `--` successor is deliberately NOT protected
|
||||
// here: it falls through to the end-of-options arm above, keeping `--` a
|
||||
// single, consistent meaning instead of being swallowed as a flag value.
|
||||
// Known value-less launch flags are exempt so a trailing profile still
|
||||
// activates (`omp --print --profile work`).
|
||||
if (isUnknownLongValueCandidate(arg)) {
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
const next = argv[index + 1];
|
||||
if (next !== undefined && !next.startsWith("-")) {
|
||||
stripped.push(next);
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only the first residual argv token can be the dispatched subcommand. Once
|
||||
// any other token has been forwarded, later subcommand names are launch text.
|
||||
// `launch` and `acp` are explicit spellings of launch-shaped commands, so
|
||||
// global launch flags that follow them must still be extracted.
|
||||
if (canDispatchSubcommand && isSubcommand(arg) && !isProfileBootstrapSubcommand(arg)) {
|
||||
sawSubcommand = true;
|
||||
}
|
||||
canDispatchSubcommand = false;
|
||||
stripped.push(arg);
|
||||
}
|
||||
|
||||
return { argv: stripped, profile, aliasName };
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
cwd: Flags.string({
|
||||
description: "Directory to start in (overrides the launch cwd)",
|
||||
}),
|
||||
@@ -151,6 +157,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`,
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
},
|
||||
"callbackPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "OAuth `prompt` parameter sent during authorization (default: \"consent\" so the provider always shows its account/consent screen; set to \"\" to omit)."
|
||||
}
|
||||
},
|
||||
"description": "Explicit OAuth client settings for servers that need them during /mcp reauth or initial connect."
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Primary provider for OMP native configs. Supports all capabilities.
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import { getManagedSkillsDir, MANAGED_SKILLS_PROVIDER_ID } from "../autolearn/managed-skills";
|
||||
import { registerProvider } from "../capability";
|
||||
@@ -61,7 +61,9 @@ async function getConfigDirs(ctx: LoadContext): Promise<Array<{ dir: string; lev
|
||||
if (projectDir) {
|
||||
result.push({ dir: projectDir, level: "project" });
|
||||
}
|
||||
const userDir = await ifNonEmptyDir(ctx.home, PATHS.userAgent);
|
||||
// Native user config is profile-scoped: getAgentDir() points at the active
|
||||
// profile's agent dir (~/.omp/profiles/<name>/agent), like sessions and MCP.
|
||||
const userDir = await ifNonEmptyDir(getAgentDir());
|
||||
if (userDir) {
|
||||
result.push({ dir: userDir, level: "user" });
|
||||
}
|
||||
@@ -178,6 +180,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
||||
redirectUri?: string;
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
prompt?: string;
|
||||
}
|
||||
| undefined,
|
||||
transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
|
||||
@@ -187,11 +190,14 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
||||
return result;
|
||||
};
|
||||
|
||||
// User scope tracks the active profile via getAgentDir() (not ctx.home), so it
|
||||
// stays in sync with getMCPConfigPath("user") and the /mcp config writer.
|
||||
const userAgentDir = getAgentDir();
|
||||
const paths = [
|
||||
{ path: path.join(ctx.cwd, PATHS.projectDir, "mcp.json"), level: "project" as const },
|
||||
{ path: path.join(ctx.cwd, PATHS.projectDir, ".mcp.json"), level: "project" as const },
|
||||
{ path: path.join(ctx.home, PATHS.userAgent, "mcp.json"), level: "user" as const },
|
||||
{ path: path.join(ctx.home, PATHS.userAgent, ".mcp.json"), level: "user" as const },
|
||||
{ path: path.join(userAgentDir, "mcp.json"), level: "user" as const },
|
||||
{ path: path.join(userAgentDir, ".mcp.json"), level: "user" as const },
|
||||
];
|
||||
|
||||
const contents = await Promise.allSettled(
|
||||
@@ -226,7 +232,7 @@ registerProvider<MCPServer>(mcpCapability.id, {
|
||||
async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
|
||||
const items: SystemPrompt[] = [];
|
||||
|
||||
const userPath = path.join(ctx.home, PATHS.userAgent, "SYSTEM.md");
|
||||
const userPath = path.join(getAgentDir(), "SYSTEM.md");
|
||||
const userContent = await readFile(userPath);
|
||||
if (userContent) {
|
||||
items.push({
|
||||
@@ -277,7 +283,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
|
||||
// User-level scan from ~/.omp/agent/skills/
|
||||
const userScan = scanSkillsFromDir(ctx, {
|
||||
dir: path.join(ctx.home, PATHS.userAgent, "skills"),
|
||||
dir: path.join(getAgentDir(), "skills"),
|
||||
providerId: PROVIDER_ID,
|
||||
level: "user",
|
||||
requireDescription: true,
|
||||
@@ -297,7 +303,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
const MANAGED_SKILLS_PRIORITY = 5;
|
||||
async function loadManagedSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
||||
return scanSkillsFromDir(ctx, {
|
||||
dir: getManagedSkillsDir(ctx.home),
|
||||
dir: getManagedSkillsDir(),
|
||||
providerId: MANAGED_SKILLS_PROVIDER_ID,
|
||||
level: "user",
|
||||
requireDescription: true,
|
||||
@@ -373,7 +379,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
||||
// the current turn so they keep hold across long conversations".
|
||||
// User scope: ~/.omp/agent/RULES.md
|
||||
// Project scope: nearest .omp/RULES.md walking up from cwd to repoRoot
|
||||
const userRulesFile = path.join(ctx.home, PATHS.userAgent, "RULES.md");
|
||||
const userRulesFile = path.join(getAgentDir(), "RULES.md");
|
||||
const userRule = await loadStickyRulesFile(userRulesFile, "user");
|
||||
if (userRule) items.push(userRule);
|
||||
|
||||
@@ -890,7 +896,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
|
||||
const items: ContextFile[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const userPath = path.join(ctx.home, PATHS.userAgent, "AGENTS.md");
|
||||
const userPath = path.join(getAgentDir(), "AGENTS.md");
|
||||
const userContent = await readFile(userPath);
|
||||
if (userContent) {
|
||||
items.push({
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { FileType, glob } from "@oh-my-pi/pi-natives";
|
||||
import {
|
||||
CONFIG_DIR_NAME,
|
||||
getAgentDir,
|
||||
getConfigDirName,
|
||||
getPluginsDir,
|
||||
getProjectDir,
|
||||
@@ -86,6 +87,11 @@ export type SourceId = keyof typeof SOURCE_PATHS;
|
||||
* Get user-level path for a source.
|
||||
*/
|
||||
export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null {
|
||||
// Native user config is profile-scoped via getAgentDir() (the active profile's
|
||||
// agent dir), matching builtin.ts and getMCPConfigPath("user"). External tools
|
||||
// (~/.claude, ~/.gemini, …) are intentionally not profile-scoped, so they keep
|
||||
// resolving against ctx.home below.
|
||||
if (source === "native") return path.join(getAgentDir(), subpath);
|
||||
const paths = SOURCE_PATHS[source];
|
||||
if (!paths.userAgent) return null;
|
||||
return path.join(ctx.home, paths.userAgent, subpath);
|
||||
@@ -569,6 +575,25 @@ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: strin
|
||||
const indexFiles = [...globIndexFiles, ...linkedFiles.indexFiles];
|
||||
const packageJsonFiles = [...globPackageJsonFiles, ...linkedFiles.packageJsonFiles];
|
||||
|
||||
// The native glob walker runs with follow_links=false, so a symlinked extension
|
||||
// directory is yielded as a Symlink entry but never descended into: its inner
|
||||
// index.{ts,js}/package.json are invisible to the `*/...` patterns above.
|
||||
// Detect top-level symlinked directories and synthesize the equivalent subdir
|
||||
// matches so the resolution below treats them like real directories. Symlinked
|
||||
// *files* already match, because the native file-type filter resolves a
|
||||
// symlink's target type for File filters.
|
||||
const topLevelEntries = await readDirEntries(dir);
|
||||
for (const entry of topLevelEntries) {
|
||||
if (!entry.isSymbolicLink()) continue;
|
||||
// readDirEntries follows the symlink: a link to a file/dangling link yields [].
|
||||
const subEntries = await readDirEntries(path.join(dir, entry.name));
|
||||
const hasEntry = (name: string): boolean =>
|
||||
subEntries.some(e => e.name === name && (e.isFile() || e.isSymbolicLink()));
|
||||
if (hasEntry("package.json")) packageJsonFiles.push({ path: `${entry.name}/package.json` });
|
||||
if (hasEntry("index.ts")) indexFiles.push({ path: `${entry.name}/index.ts` });
|
||||
else if (hasEntry("index.js")) indexFiles.push({ path: `${entry.name}/index.js` });
|
||||
}
|
||||
|
||||
// Process direct files
|
||||
for (const match of directFiles) {
|
||||
if (match.path.includes("/")) continue;
|
||||
|
||||
@@ -46,6 +46,7 @@ interface MCPConfigFile {
|
||||
redirectUri?: string;
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
|
||||
import { readDirEntries, readFile } from "../capability/fs";
|
||||
import type { LoadContext } from "../capability/types";
|
||||
import { getEnabledPlugins } from "../extensibility/plugins/loader";
|
||||
@@ -82,7 +82,7 @@ interface ScopeDirs {
|
||||
function scopeDirs(ctx: LoadContext): ScopeDirs {
|
||||
return {
|
||||
project: path.join(ctx.cwd, ".omp"),
|
||||
user: path.join(ctx.home, ".omp", "agent"),
|
||||
user: getAgentDir(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
unsubscribeFromResources,
|
||||
} from "./client";
|
||||
import { loadAllMCPConfigs, validateServerConfig } from "./config";
|
||||
import { refreshMCPOAuthToken } from "./oauth-flow";
|
||||
import {
|
||||
lookupMcpOAuthCredential,
|
||||
type MCPOAuthCredentialLookup,
|
||||
selectMcpOAuthRefreshMaterial,
|
||||
} from "./oauth-credentials";
|
||||
import { type MCPStoredOAuthCredential, refreshMCPOAuthToken } from "./oauth-flow";
|
||||
import type { MCPToolDetails } from "./tool-bridge";
|
||||
import { DeferredMCPTool, MCPTool } from "./tool-bridge";
|
||||
import type { MCPToolCache } from "./tool-cache";
|
||||
@@ -400,9 +405,15 @@ export class MCPManager {
|
||||
}
|
||||
|
||||
// Wire auth refresh for HTTP transports so 401s trigger token refresh.
|
||||
if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
|
||||
// Gate on a resolvable managed credential, not on the auth block:
|
||||
// definition-only configs (url-keyed fallback) get Bearer injection
|
||||
// too and need the same mid-session refresh hook.
|
||||
if (
|
||||
connection.transport instanceof HttpTransport &&
|
||||
lookupMcpOAuthCredential(this.#authStorage, config)
|
||||
) {
|
||||
connection.transport.onAuthError = async () => {
|
||||
const refreshed = await this.#resolveAuthConfig(config, true);
|
||||
const refreshed = await this.#resolveAuthConfig(config, { forceRefresh: true });
|
||||
if (refreshed.type === "http" || refreshed.type === "sse") {
|
||||
return refreshed.headers ?? null;
|
||||
}
|
||||
@@ -673,9 +684,11 @@ export class MCPManager {
|
||||
|
||||
/**
|
||||
* Resolve auth and shell-command substitutions in config before connecting.
|
||||
* Pass `oauth: false` to skip OAuth credential injection (used by reauth's
|
||||
* unauthenticated probe, which must observe the server's bare 401).
|
||||
*/
|
||||
async prepareConfig(config: MCPServerConfig): Promise<MCPServerConfig> {
|
||||
return this.#resolveAuthConfig(config);
|
||||
async prepareConfig(config: MCPServerConfig, options?: { oauth?: boolean }): Promise<MCPServerConfig> {
|
||||
return this.#resolveAuthConfig(config, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -925,9 +938,10 @@ export class MCPManager {
|
||||
this.#connections.set(name, connection);
|
||||
|
||||
// Wire auth refresh for HTTP transports, and reconnect for any transport.
|
||||
if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
|
||||
// Same gate as connectServers: any resolvable managed credential.
|
||||
if (connection.transport instanceof HttpTransport && lookupMcpOAuthCredential(this.#authStorage, config)) {
|
||||
connection.transport.onAuthError = async () => {
|
||||
const refreshed = await this.#resolveAuthConfig(config, true);
|
||||
const refreshed = await this.#resolveAuthConfig(config, { forceRefresh: true });
|
||||
if (refreshed.type === "http" || refreshed.type === "sse") {
|
||||
return refreshed.headers ?? null;
|
||||
}
|
||||
@@ -1169,78 +1183,101 @@ export class MCPManager {
|
||||
|
||||
/**
|
||||
* Resolve OAuth credentials and shell commands in config.
|
||||
* `oauth: false` skips credential injection (reauth's unauthenticated probe);
|
||||
* `forceRefresh` bypasses the expiry buffer (401/403 auth-error hook).
|
||||
*/
|
||||
async #resolveAuthConfig(config: MCPServerConfig, forceRefresh = false): Promise<MCPServerConfig> {
|
||||
async #resolveAuthConfig(
|
||||
config: MCPServerConfig,
|
||||
opts?: { forceRefresh?: boolean; oauth?: boolean },
|
||||
): Promise<MCPServerConfig> {
|
||||
let resolved: MCPServerConfig = { ...config };
|
||||
|
||||
const auth = config.auth;
|
||||
if (auth?.type === "oauth" && auth.credentialId && this.#authStorage) {
|
||||
const credentialId = auth.credentialId;
|
||||
const lookup: MCPOAuthCredentialLookup | undefined =
|
||||
opts?.oauth !== false ? lookupMcpOAuthCredential(this.#authStorage, config) : undefined;
|
||||
if (lookup && this.#authStorage) {
|
||||
const { credentialId } = lookup;
|
||||
try {
|
||||
let credential = this.#authStorage.get(credentialId);
|
||||
if (credential?.type === "oauth") {
|
||||
// Proactive refresh: 5-minute buffer before expiry
|
||||
// Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
|
||||
const REFRESH_BUFFER_MS = 5 * 60_000;
|
||||
const shouldRefresh =
|
||||
forceRefresh || (credential.expires && Date.now() >= credential.expires - REFRESH_BUFFER_MS);
|
||||
if (shouldRefresh && credential.refresh && auth.tokenUrl) {
|
||||
const resource =
|
||||
auth.resource ?? (config.type === "http" || config.type === "sse" ? config.url : undefined);
|
||||
try {
|
||||
const refreshed = await refreshMCPOAuthToken(
|
||||
auth.tokenUrl,
|
||||
credential.refresh,
|
||||
auth.clientId,
|
||||
auth.clientSecret,
|
||||
resource,
|
||||
);
|
||||
const refreshedCredential = { type: "oauth" as const, ...refreshed };
|
||||
await this.#authStorage.set(credentialId, refreshedCredential);
|
||||
credential = refreshedCredential;
|
||||
} catch (refreshError) {
|
||||
const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
|
||||
if (isDefinitiveOAuthFailure(errorMsg)) {
|
||||
// `invalid_grant` / `invalid_token` / 401 from the token endpoint means
|
||||
// the server has retired this credential — keeping the stale access
|
||||
// token would just re-fail with 401 on every MCP request and leave a
|
||||
// poisoned row in agent.db that survives restarts. Drop it now so the
|
||||
// next connect attempt surfaces a clean "needs reauth" failure and
|
||||
// the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
|
||||
// to forget the server entirely).
|
||||
logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
|
||||
credentialId,
|
||||
error: errorMsg,
|
||||
});
|
||||
await this.#authStorage.remove(credentialId);
|
||||
credential = undefined;
|
||||
} else {
|
||||
logger.warn("MCP OAuth refresh failed, using existing token", {
|
||||
credentialId,
|
||||
error: refreshError,
|
||||
});
|
||||
}
|
||||
let credential: MCPStoredOAuthCredential | undefined = lookup.credential;
|
||||
// Refresh material comes from ONE source: the credential's embedded
|
||||
// fields (written atomically with the tokens they minted — tokenUrl
|
||||
// always present) or, for legacy rows that predate embedding, the
|
||||
// config auth block. Never mix the two: a shared file's auth block
|
||||
// can belong to another profile, whose client the grant is NOT
|
||||
// bound to.
|
||||
const material = selectMcpOAuthRefreshMaterial(credential, auth);
|
||||
const tokenUrl = material?.tokenUrl;
|
||||
const clientId = material?.clientId;
|
||||
const clientSecret = material?.clientSecret;
|
||||
const resource =
|
||||
material?.resource ?? (config.type === "http" || config.type === "sse" ? config.url : undefined);
|
||||
// Proactive refresh: 5-minute buffer before expiry
|
||||
// Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
|
||||
const REFRESH_BUFFER_MS = 5 * 60_000;
|
||||
const shouldRefresh =
|
||||
opts?.forceRefresh || (credential.expires && Date.now() >= credential.expires - REFRESH_BUFFER_MS);
|
||||
if (shouldRefresh && credential.refresh && tokenUrl) {
|
||||
try {
|
||||
const refreshed = await refreshMCPOAuthToken(
|
||||
tokenUrl,
|
||||
credential.refresh,
|
||||
clientId,
|
||||
clientSecret,
|
||||
resource,
|
||||
);
|
||||
// Spread the old credential first so embedded refresh material survives rotation.
|
||||
const refreshedCredential: MCPStoredOAuthCredential = {
|
||||
...credential,
|
||||
...refreshed,
|
||||
tokenUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
resource,
|
||||
};
|
||||
await this.#authStorage.set(credentialId, refreshedCredential);
|
||||
credential = refreshedCredential;
|
||||
} catch (refreshError) {
|
||||
const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
|
||||
if (isDefinitiveOAuthFailure(errorMsg)) {
|
||||
// `invalid_grant` / `invalid_token` / 401 from the token endpoint means
|
||||
// the server has retired this credential — keeping the stale access
|
||||
// token would just re-fail with 401 on every MCP request and leave a
|
||||
// poisoned row in agent.db that survives restarts. Drop it now so the
|
||||
// next connect attempt surfaces a clean "needs reauth" failure and
|
||||
// the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
|
||||
// to forget the server entirely).
|
||||
logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
|
||||
credentialId,
|
||||
error: errorMsg,
|
||||
});
|
||||
await this.#authStorage.remove(credentialId);
|
||||
credential = undefined;
|
||||
} else {
|
||||
logger.warn("MCP OAuth refresh failed, using existing token", {
|
||||
credentialId,
|
||||
error: refreshError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (credential?.type === "oauth") {
|
||||
if (resolved.type === "http" || resolved.type === "sse") {
|
||||
resolved = {
|
||||
...resolved,
|
||||
headers: {
|
||||
...resolved.headers,
|
||||
Authorization: `Bearer ${credential.access}`,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
resolved = {
|
||||
...resolved,
|
||||
env: {
|
||||
...resolved.env,
|
||||
OAUTH_ACCESS_TOKEN: credential.access,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (credential) {
|
||||
if (resolved.type === "http" || resolved.type === "sse") {
|
||||
resolved = {
|
||||
...resolved,
|
||||
headers: {
|
||||
...resolved.headers,
|
||||
Authorization: `Bearer ${credential.access}`,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
resolved = {
|
||||
...resolved,
|
||||
env: {
|
||||
...resolved.env,
|
||||
OAUTH_ACCESS_TOKEN: credential.access,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expandEnvVarsDeep } from "../discovery/helpers";
|
||||
import type { AuthStorage } from "../session/auth-storage";
|
||||
import { isManagedMCPOAuthCredentialId, type MCPStoredOAuthCredential, mcpOAuthCredentialId } from "./oauth-flow";
|
||||
import type { MCPAuthConfig, MCPServerConfig } from "./types";
|
||||
|
||||
export interface MCPOAuthCredentialLookup {
|
||||
credentialId: string;
|
||||
credential: MCPStoredOAuthCredential;
|
||||
}
|
||||
|
||||
export type MCPOAuthRefreshMaterial = MCPStoredOAuthCredential | MCPAuthConfig | undefined;
|
||||
|
||||
export function mcpOAuthCredentialIdsForServerUrl(serverUrl: string | undefined): string[] {
|
||||
if (!serverUrl) return [];
|
||||
const ids: string[] = [];
|
||||
for (const url of [expandEnvVarsDeep(serverUrl), serverUrl]) {
|
||||
const id = mcpOAuthCredentialId(url);
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function hasMcpAuthorizationHeader(config: MCPServerConfig): boolean {
|
||||
if (config.type !== "http" && config.type !== "sse") return false;
|
||||
return Object.keys(config.headers ?? {}).some(header => header.toLowerCase() === "authorization");
|
||||
}
|
||||
|
||||
export function lookupMcpOAuthCredentialForServer(
|
||||
authStorage: AuthStorage | null | undefined,
|
||||
auth: MCPAuthConfig | undefined,
|
||||
serverUrl: string | undefined,
|
||||
options: { allowUrlKeyedFallback?: boolean } = {},
|
||||
): MCPOAuthCredentialLookup | undefined {
|
||||
if (!authStorage) return undefined;
|
||||
if (auth && auth.type !== "oauth") return undefined;
|
||||
const urlKeyedCredentialIds = mcpOAuthCredentialIdsForServerUrl(serverUrl);
|
||||
if (
|
||||
auth?.credentialId &&
|
||||
(!auth.credentialId.startsWith("mcp_oauth:profile:") || urlKeyedCredentialIds.includes(auth.credentialId))
|
||||
) {
|
||||
const credential = authStorage.get(auth.credentialId);
|
||||
if (credential?.type === "oauth") {
|
||||
return { credentialId: auth.credentialId, credential };
|
||||
}
|
||||
}
|
||||
if (options.allowUrlKeyedFallback === false) return undefined;
|
||||
for (const credentialId of urlKeyedCredentialIds) {
|
||||
const credential = authStorage.get(credentialId);
|
||||
if (credential?.type === "oauth") {
|
||||
return { credentialId, credential };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function lookupMcpOAuthCredential(
|
||||
authStorage: AuthStorage | null | undefined,
|
||||
config: MCPServerConfig,
|
||||
): MCPOAuthCredentialLookup | undefined {
|
||||
const auth = config.auth;
|
||||
if (config.type !== "http" && config.type !== "sse") {
|
||||
return lookupMcpOAuthCredentialForServer(authStorage, auth, undefined);
|
||||
}
|
||||
if (hasMcpAuthorizationHeader(config)) {
|
||||
return lookupMcpOAuthCredentialForServer(authStorage, auth, config.url, { allowUrlKeyedFallback: false });
|
||||
}
|
||||
return lookupMcpOAuthCredentialForServer(authStorage, auth, config.url);
|
||||
}
|
||||
|
||||
export function selectMcpOAuthRefreshMaterial(
|
||||
credential: MCPStoredOAuthCredential,
|
||||
auth: MCPAuthConfig | undefined,
|
||||
): MCPOAuthRefreshMaterial {
|
||||
return credential.tokenUrl ? credential : auth;
|
||||
}
|
||||
|
||||
export async function removeManagedMcpOAuthCredential(
|
||||
authStorage: AuthStorage,
|
||||
credentialId: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!isManagedMCPOAuthCredentialId(credentialId)) return false;
|
||||
if (authStorage.get(credentialId)?.type !== "oauth") return false;
|
||||
await authStorage.remove(credentialId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function removeManagedMcpOAuthCredentials(
|
||||
authStorage: AuthStorage,
|
||||
credentialIds: readonly (string | undefined)[],
|
||||
): Promise<boolean> {
|
||||
let removed = false;
|
||||
for (const credentialId of credentialIds) {
|
||||
removed = (await removeManagedMcpOAuthCredential(authStorage, credentialId)) || removed;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -9,6 +9,44 @@ import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/oauth/callback-se
|
||||
import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/oauth/callback-server";
|
||||
import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
|
||||
import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
|
||||
import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
|
||||
import type { OAuthCredential } from "../session/auth-storage";
|
||||
|
||||
/** Credential-id prefix for OMP-managed MCP OAuth credentials keyed by profile and server URL. */
|
||||
const MCP_OAUTH_URL_CREDENTIAL_PREFIX = "mcp_oauth:";
|
||||
|
||||
/**
|
||||
* Deterministic credential id for an MCP server URL scoped to an OMP profile.
|
||||
*
|
||||
* Local profile stores are already separate, but auth-broker storage shares one
|
||||
* provider namespace across profiles. Including the profile in the provider key
|
||||
* keeps a shared project `mcp.json` definition from making profile B overwrite
|
||||
* or read profile A's OAuth row for the same server URL. The URL is used
|
||||
* verbatim (query string included) because it can carry tenant selectors such
|
||||
* as `?project_ref=`.
|
||||
*/
|
||||
export function mcpOAuthCredentialId(serverUrl: string, profile: string | undefined = getActiveProfile()): string {
|
||||
return `${MCP_OAUTH_URL_CREDENTIAL_PREFIX}profile:${profile ?? "default"}:${serverUrl}`;
|
||||
}
|
||||
|
||||
/** Whether a credential id was minted by OMP's MCP OAuth flows (either era). */
|
||||
export function isManagedMCPOAuthCredentialId(credentialId: string | undefined): credentialId is string {
|
||||
return (
|
||||
!!credentialId &&
|
||||
(credentialId.startsWith("mcp_oauth_") || credentialId.startsWith(MCP_OAUTH_URL_CREDENTIAL_PREFIX))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored MCP OAuth credential. Refresh material is embedded so token refresh
|
||||
* works without any `auth` block persisted in (possibly shared) config files.
|
||||
*/
|
||||
export interface MCPStoredOAuthCredential extends OAuthCredential {
|
||||
tokenUrl?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 3000;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
@@ -126,6 +164,15 @@ export interface MCPOAuthConfig {
|
||||
clientSecret?: string;
|
||||
/** OAuth scopes (space-separated) */
|
||||
scopes?: string;
|
||||
/**
|
||||
* `prompt` parameter for the authorization request. Defaults to `"consent"`
|
||||
* so the provider always shows its authorize screen instead of silently
|
||||
* re-approving the browser's current session — without it, reauthorizing to
|
||||
* switch accounts/workspaces is impossible once a session cookie exists
|
||||
* (RFC 6749 §3.1 requires servers to ignore the param when unsupported).
|
||||
* Set to `""` to omit the parameter entirely.
|
||||
*/
|
||||
prompt?: string;
|
||||
/** Exact redirect URI to advertise to the provider */
|
||||
redirectUri?: string;
|
||||
/** Custom callback port (default: 3000) */
|
||||
@@ -202,6 +249,10 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
||||
if (this.config.scopes && !params.get("scope")) {
|
||||
params.set("scope", this.config.scopes);
|
||||
}
|
||||
const prompt = this.config.prompt ?? "consent";
|
||||
if (prompt && !params.get("prompt")) {
|
||||
params.set("prompt", prompt);
|
||||
}
|
||||
const existingResource = params.get("resource")?.trim();
|
||||
if (existingResource) {
|
||||
this.#resource = resolveResourceUri(existingResource);
|
||||
|
||||
@@ -74,6 +74,8 @@ interface MCPServerConfigBase {
|
||||
redirectUri?: string;
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
/** `prompt` param for the authorization request (default "consent"; "" to omit) */
|
||||
prompt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,14 +49,19 @@ type WizardStep =
|
||||
|
||||
/**
|
||||
* Result of the wizard's OAuth callback. `credentialId` is mandatory;
|
||||
* `clientId`/`clientSecret` are populated when the OAuth provider performed
|
||||
* dynamic client registration (or when the caller pre-supplied them) so the
|
||||
* wizard can fold them into the final `mcp.json` entry for refresh.
|
||||
* `clientId` is populated when the OAuth provider performed dynamic client
|
||||
* registration (or when the caller pre-supplied it) so the wizard can fold it
|
||||
* into the final `mcp.json` entry. Refresh material (including any DCR client
|
||||
* secret) is embedded in the stored credential, never written to config files.
|
||||
*/
|
||||
export interface MCPAddWizardOAuthResult {
|
||||
credentialId: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
interface MCPAddWizardOAuthOptions {
|
||||
serverUrl?: string;
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
@@ -125,7 +130,7 @@ export class MCPAddWizard extends Container {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scopes: string,
|
||||
resource?: string,
|
||||
options?: MCPAddWizardOAuthOptions,
|
||||
) => Promise<MCPAddWizardOAuthResult>)
|
||||
| null = null;
|
||||
#onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
|
||||
@@ -140,7 +145,7 @@ export class MCPAddWizard extends Container {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scopes: string,
|
||||
resource?: string,
|
||||
options?: MCPAddWizardOAuthOptions,
|
||||
) => Promise<MCPAddWizardOAuthResult>,
|
||||
onTestConnection?: (config: MCPServerConfig) => Promise<void>,
|
||||
onRender?: () => void,
|
||||
@@ -1157,14 +1162,16 @@ export class MCPAddWizard extends Container {
|
||||
this.#state.oauthClientId,
|
||||
this.#state.oauthClientSecret,
|
||||
this.#state.oauthScopes,
|
||||
oauthResource || undefined,
|
||||
{
|
||||
serverUrl: this.#state.url || undefined,
|
||||
resource: oauthResource || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// Store credential ID + any dynamically-registered client credentials,
|
||||
// so the final mcp.json entry persists everything needed for refresh.
|
||||
// Store credential ID + any dynamically-registered client id. DCR client
|
||||
// secrets stay embedded in the stored credential, never in mcp.json.
|
||||
this.#state.oauthCredentialId = oauthResult.credentialId;
|
||||
if (oauthResult.clientId) this.#state.oauthClientId = oauthResult.clientId;
|
||||
if (oauthResult.clientSecret) this.#state.oauthClientSecret = oauthResult.clientSecret;
|
||||
this.#state.oauthResource = oauthResult.resource ?? oauthResource;
|
||||
|
||||
// Show success message
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as path from "node:path";
|
||||
import { type Component, replaceTabs, Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||
import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import type { SourceMeta } from "../../capability/types";
|
||||
import { expandEnvVarsDeep } from "../../discovery/helpers";
|
||||
import { analyzeAuthError, discoverOAuthEndpoints, MCPManager } from "../../mcp";
|
||||
import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
|
||||
import {
|
||||
@@ -17,7 +18,13 @@ import {
|
||||
setServerDisabled,
|
||||
updateMCPServer,
|
||||
} from "../../mcp/config-writer";
|
||||
import { MCPOAuthFlow } from "../../mcp/oauth-flow";
|
||||
import {
|
||||
lookupMcpOAuthCredentialForServer,
|
||||
mcpOAuthCredentialIdsForServerUrl,
|
||||
removeManagedMcpOAuthCredential,
|
||||
removeManagedMcpOAuthCredentials,
|
||||
} from "../../mcp/oauth-credentials";
|
||||
import { MCPOAuthFlow, type MCPStoredOAuthCredential, mcpOAuthCredentialId } from "../../mcp/oauth-flow";
|
||||
import {
|
||||
clearSmitheryApiKey,
|
||||
createSmitheryCliAuthSession,
|
||||
@@ -34,7 +41,6 @@ import {
|
||||
toConfigName,
|
||||
} from "../../mcp/smithery-registry";
|
||||
import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
|
||||
import type { OAuthCredential } from "../../session/auth-storage";
|
||||
import { shortenPath } from "../../tools/render-utils";
|
||||
import { urlHyperlinkAlways } from "../../tui";
|
||||
import { openPath } from "../../utils/open";
|
||||
@@ -116,17 +122,16 @@ class McpConnectingBlock extends ChatBlock {
|
||||
/**
|
||||
* Outcome of {@link MCPCommandController}'s OAuth handler.
|
||||
*
|
||||
* `clientId`/`clientSecret` are populated when the OAuth provider required (or
|
||||
* accepted) dynamic client registration; callers MUST persist them alongside
|
||||
* `credentialId` so subsequent token refreshes and reauthorizations can reuse
|
||||
* the same registered client. Both are also set when the caller pre-supplied a
|
||||
* client id via the wizard or `oauth.clientId` in `mcp.json`, in which case the
|
||||
* write-back is a no-op.
|
||||
* `credentialId` is deterministic per server URL when the URL was supplied, so
|
||||
* every profile resolves its own credential row under the same id. Refresh
|
||||
* material (token URL, client id/secret) is embedded in the stored credential;
|
||||
* the returned `clientId` may be folded into `mcp.json` for pre-auth reuse.
|
||||
* DCR-issued client secrets stay embedded in the stored credential and are
|
||||
* deliberately not surfaced here, so they cannot leak into config files.
|
||||
*/
|
||||
interface OAuthFlowResult {
|
||||
credentialId: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
@@ -490,38 +495,28 @@ export class MCPCommandController {
|
||||
}
|
||||
|
||||
try {
|
||||
const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
|
||||
const oauthResource = oauth.resource ?? finalConfig.url;
|
||||
const oauthResult = await this.#handleOAuthFlow(
|
||||
oauth.authorizationUrl,
|
||||
oauth.tokenUrl,
|
||||
oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
|
||||
oauthClientSecret,
|
||||
finalConfig.oauth?.clientSecret ?? "",
|
||||
oauth.scopes ?? "",
|
||||
finalConfig.oauth?.callbackPort,
|
||||
finalConfig.oauth?.callbackPath,
|
||||
finalConfig.oauth?.redirectUri,
|
||||
oauthResource,
|
||||
{
|
||||
callbackPort: finalConfig.oauth?.callbackPort,
|
||||
callbackPath: finalConfig.oauth?.callbackPath,
|
||||
redirectUri: finalConfig.oauth?.redirectUri,
|
||||
prompt: finalConfig.oauth?.prompt,
|
||||
serverUrl: finalConfig.url,
|
||||
resource: oauthResource,
|
||||
},
|
||||
);
|
||||
const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? finalConfig.oauth?.clientId;
|
||||
const persistedClientSecret = oauthResult.clientSecret ?? finalConfig.oauth?.clientSecret;
|
||||
const persistedResource = oauthResult.resource ?? oauthResource;
|
||||
finalConfig = {
|
||||
...finalConfig,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
credentialId: oauthResult.credentialId,
|
||||
tokenUrl: oauth.tokenUrl,
|
||||
resource: persistedResource,
|
||||
clientId: persistedClientId,
|
||||
clientSecret: persistedClientSecret,
|
||||
},
|
||||
oauth: {
|
||||
...finalConfig.oauth,
|
||||
clientId: persistedClientId ?? finalConfig.oauth?.clientId,
|
||||
clientSecret: persistedClientSecret ?? finalConfig.oauth?.clientSecret,
|
||||
},
|
||||
};
|
||||
finalConfig = this.#persistOAuthResult(finalConfig, oauthResult, {
|
||||
tokenUrl: oauth.tokenUrl,
|
||||
resource: oauthResource,
|
||||
clientId: oauth.clientId,
|
||||
userClientSecret: finalConfig.oauth?.clientSecret,
|
||||
});
|
||||
} catch (oauthError) {
|
||||
this.ctx.showError(
|
||||
`OAuth flow failed for "${parsed.initialName}": ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`,
|
||||
@@ -553,25 +548,8 @@ export class MCPCommandController {
|
||||
done();
|
||||
this.#handleWizardCancel();
|
||||
},
|
||||
async (
|
||||
authUrl: string,
|
||||
tokenUrl: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scopes: string,
|
||||
resource?: string,
|
||||
) => {
|
||||
return await this.#handleOAuthFlow(
|
||||
authUrl,
|
||||
tokenUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
scopes,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
resource,
|
||||
);
|
||||
async (authUrl: string, tokenUrl: string, clientId: string, clientSecret: string, scopes: string, options) => {
|
||||
return await this.#handleOAuthFlow(authUrl, tokenUrl, clientId, clientSecret, scopes, options);
|
||||
},
|
||||
async (config: MCPServerConfig) => {
|
||||
return await this.#handleTestConnection(config);
|
||||
@@ -598,10 +576,14 @@ export class MCPCommandController {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
scopes: string,
|
||||
callbackPort?: number,
|
||||
callbackPath?: string,
|
||||
redirectUri?: string,
|
||||
resource?: string,
|
||||
opts?: {
|
||||
callbackPort?: number;
|
||||
callbackPath?: string;
|
||||
redirectUri?: string;
|
||||
prompt?: string;
|
||||
serverUrl?: string;
|
||||
resource?: string;
|
||||
},
|
||||
): Promise<OAuthFlowResult> {
|
||||
const authStorage = this.ctx.session.modelRegistry.authStorage;
|
||||
let parsedAuthUrl: URL;
|
||||
@@ -637,10 +619,11 @@ export class MCPCommandController {
|
||||
clientId: resolvedClientId,
|
||||
clientSecret: resolvedClientSecret,
|
||||
scopes: scopes || undefined,
|
||||
redirectUri,
|
||||
callbackPort,
|
||||
callbackPath,
|
||||
resource,
|
||||
prompt: opts?.prompt,
|
||||
redirectUri: opts?.redirectUri,
|
||||
callbackPort: opts?.callbackPort,
|
||||
callbackPath: opts?.callbackPath,
|
||||
resource: opts?.resource,
|
||||
},
|
||||
{
|
||||
onAuth: (info: { url: string; instructions?: string }) => {
|
||||
@@ -712,22 +695,29 @@ export class MCPCommandController {
|
||||
new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0),
|
||||
]);
|
||||
|
||||
// Generate a unique credential ID
|
||||
const credentialId = `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
// Deterministic per-URL id: every profile resolves its own credential row
|
||||
// under the same key, so shared project configs stay profile-isolated.
|
||||
// Random fallback only for flows that never knew the server URL.
|
||||
const credentialId = opts?.serverUrl
|
||||
? mcpOAuthCredentialId(opts.serverUrl)
|
||||
: `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
// Store credentials in auth storage
|
||||
const oauthCredential: OAuthCredential = {
|
||||
// Embed refresh material so the credential is self-contained: token
|
||||
// refresh must work for configs that carry no auth block at all.
|
||||
const oauthCredential: MCPStoredOAuthCredential = {
|
||||
type: "oauth",
|
||||
...credentials,
|
||||
tokenUrl,
|
||||
clientId: flow.resolvedClientId ?? resolvedClientId,
|
||||
clientSecret: flow.registeredClientSecret ?? resolvedClientSecret,
|
||||
resource: flow.resource,
|
||||
};
|
||||
|
||||
// Store under a synthetic provider name
|
||||
await authStorage.set(credentialId, oauthCredential);
|
||||
|
||||
return {
|
||||
credentialId,
|
||||
clientId: flow.resolvedClientId,
|
||||
clientSecret: flow.registeredClientSecret,
|
||||
resource: flow.resource,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -750,20 +740,52 @@ export class MCPCommandController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a completed OAuth flow back into a server config. Owns the
|
||||
* persistence policy in one place: the auth block records the credential
|
||||
* pointer plus refresh material, the oauth block echoes the client id for
|
||||
* pre-auth reuse, and only a user-supplied client secret is ever written —
|
||||
* DCR-issued secrets stay embedded in the stored credential so they cannot
|
||||
* leak into (possibly shared/committed) config files.
|
||||
*/
|
||||
#persistOAuthResult(
|
||||
config: MCPServerConfig,
|
||||
result: OAuthFlowResult,
|
||||
opts: { tokenUrl: string; resource?: string; clientId?: string; userClientSecret?: string },
|
||||
): MCPServerConfig {
|
||||
const clientId = result.clientId ?? opts.clientId ?? config.oauth?.clientId;
|
||||
const resource = result.resource ?? opts.resource ?? config.auth?.resource;
|
||||
return {
|
||||
...config,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
credentialId: result.credentialId,
|
||||
tokenUrl: opts.tokenUrl,
|
||||
clientId,
|
||||
clientSecret: opts.userClientSecret,
|
||||
resource,
|
||||
},
|
||||
oauth: {
|
||||
...config.oauth,
|
||||
clientId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to an MCP server.
|
||||
* Throws an error if connection fails (used for auto-detection).
|
||||
*/
|
||||
async #handleTestConnection(config: MCPServerConfig): Promise<void> {
|
||||
async #handleTestConnection(config: MCPServerConfig, options?: { oauth?: boolean }): Promise<void> {
|
||||
// Create temporary connection using a test name
|
||||
const testName = `test_${Date.now()}`;
|
||||
let resolvedConfig: MCPServerConfig;
|
||||
if (this.ctx.mcpManager) {
|
||||
resolvedConfig = await this.ctx.mcpManager.prepareConfig(config);
|
||||
resolvedConfig = await this.ctx.mcpManager.prepareConfig(config, options);
|
||||
} else {
|
||||
const tempManager = new MCPManager(getProjectDir());
|
||||
tempManager.setAuthStorage(this.ctx.session.modelRegistry.authStorage);
|
||||
resolvedConfig = await tempManager.prepareConfig(config);
|
||||
resolvedConfig = await tempManager.prepareConfig(config, options);
|
||||
}
|
||||
|
||||
const connection = await connectToServer(testName, resolvedConfig);
|
||||
@@ -850,11 +872,6 @@ export class MCPCommandController {
|
||||
};
|
||||
}
|
||||
|
||||
async #removeManagedOAuthCredential(credentialId: string | undefined): Promise<void> {
|
||||
if (!credentialId?.startsWith("mcp_oauth_")) return;
|
||||
await this.ctx.session.modelRegistry.authStorage.remove(credentialId);
|
||||
}
|
||||
|
||||
#stripOAuthAuth(config: MCPServerConfig): MCPServerConfig {
|
||||
const next = { ...config } as MCPServerConfig & { auth?: MCPAuthConfig };
|
||||
delete next.auth;
|
||||
@@ -868,11 +885,26 @@ export class MCPCommandController {
|
||||
scopes?: string;
|
||||
resource?: string;
|
||||
}> {
|
||||
// Stdio servers manage credentials inside the child process; OMP's OAuth
|
||||
// flow only applies to http/sse transports. Without this guard the
|
||||
// unauthenticated preflight below spawns the child, which happily reuses
|
||||
// its own cached tokens (e.g. mcp-remote's machine-wide ~/.mcp-auth) and
|
||||
// produces the misleading "reauthorization is not required".
|
||||
if (config.type !== "http" && config.type !== "sse") {
|
||||
const remoteUrl = config.args?.find(arg => /^https?:\/\//.test(arg));
|
||||
const httpHint = `{ "type": "http", "url": ${JSON.stringify(remoteUrl ?? "<remote url>")} }`;
|
||||
const usesMcpRemote = [config.command, ...(config.args ?? [])].some(part => part?.includes("mcp-remote"));
|
||||
throw new Error(
|
||||
usesMcpRemote
|
||||
? `this server proxies OAuth through mcp-remote, which caches tokens machine-wide in ~/.mcp-auth (shared across every OMP profile). Clear ~/.mcp-auth to force a fresh login, or replace the proxy with ${httpHint} so OMP manages OAuth per profile.`
|
||||
: `stdio servers manage their own credentials, so OMP has no OAuth to reauthorize. If the service supports OAuth over HTTP, configure it as ${httpHint} instead.`,
|
||||
);
|
||||
}
|
||||
// First test if server actually needs auth by connecting without OAuth
|
||||
let connectionSucceeded = false;
|
||||
let connectionError: Error | undefined;
|
||||
try {
|
||||
await this.#handleTestConnection(this.#stripOAuthAuth(config));
|
||||
await this.#handleTestConnection(this.#stripOAuthAuth(config), { oauth: false });
|
||||
connectionSucceeded = true;
|
||||
} catch (error) {
|
||||
connectionError = error as Error;
|
||||
@@ -1433,12 +1465,34 @@ export class MCPCommandController {
|
||||
}
|
||||
|
||||
const currentAuth = (found.config as MCPServerConfig & { auth?: MCPAuthConfig }).auth;
|
||||
if (found.discovered && currentAuth?.type !== "oauth") {
|
||||
this.#showMessage(["", theme.fg("muted", `No stored OAuth auth to remove for "${name}".`), ""].join("\n"));
|
||||
return;
|
||||
}
|
||||
const authStorage = this.ctx.session.modelRegistry.authStorage;
|
||||
if (currentAuth?.type === "oauth") {
|
||||
await this.#removeManagedOAuthCredential(currentAuth.credentialId);
|
||||
await removeManagedMcpOAuthCredential(authStorage, currentAuth.credentialId);
|
||||
}
|
||||
// Also drop this profile's url-keyed binding so the server is truly
|
||||
// signed out even when the config carries no auth block. Runtime
|
||||
// discovery expands `${...}` URL values before MCPManager looks up the
|
||||
// deterministic credential row, so unauth must clear that same key.
|
||||
let removedUrlKeyedCredential = false;
|
||||
if ((found.config.type === "http" || found.config.type === "sse") && found.config.url) {
|
||||
removedUrlKeyedCredential = await removeManagedMcpOAuthCredentials(
|
||||
authStorage,
|
||||
mcpOAuthCredentialIdsForServerUrl(found.config.url),
|
||||
);
|
||||
}
|
||||
|
||||
if (found.discovered && currentAuth?.type !== "oauth") {
|
||||
if (!removedUrlKeyedCredential) {
|
||||
this.#showMessage(
|
||||
["", theme.fg("muted", `No stored OAuth auth to remove for "${name}".`), ""].join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.#reloadMCP();
|
||||
this.#showMessage(
|
||||
["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = this.#stripOAuthAuth(found.config);
|
||||
@@ -1472,52 +1526,70 @@ export class MCPCommandController {
|
||||
}
|
||||
|
||||
const currentAuth = (found.config as MCPServerConfig & { auth?: MCPAuthConfig }).auth;
|
||||
if (currentAuth?.type === "oauth") {
|
||||
await this.#removeManagedOAuthCredential(currentAuth.credentialId);
|
||||
}
|
||||
|
||||
const authStorage = this.ctx.session.modelRegistry.authStorage;
|
||||
const baseConfig = this.#stripOAuthAuth(found.config);
|
||||
const oauth = await this.#resolveOAuthEndpointsFromServer(baseConfig);
|
||||
const oauthClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret ?? "";
|
||||
const runtimeBaseConfig = expandEnvVarsDeep(baseConfig);
|
||||
// Resolve endpoints first: this fails fast for stdio transports and
|
||||
// probes http/sse with { oauth: false }, so nothing destructive has
|
||||
// happened yet if the server turns out not to need (or support) OAuth.
|
||||
// Use the same env-expanded config shape runtime discovery passes to
|
||||
// MCPManager; the raw file value may contain `${...}` placeholders.
|
||||
const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig);
|
||||
const serverUrl =
|
||||
runtimeBaseConfig.type === "http" || runtimeBaseConfig.type === "sse" ? runtimeBaseConfig.url : undefined;
|
||||
// A user-supplied client secret may live in either block (the wizard
|
||||
// writes it to auth.clientSecret); DCR secrets are embedded in the
|
||||
// stored credential and never echoed back into config files.
|
||||
const configuredClientId = found.config.oauth?.clientId ?? currentAuth?.clientId;
|
||||
const existingCredential = lookupMcpOAuthCredentialForServer(authStorage, currentAuth, serverUrl)?.credential;
|
||||
const flowClientId = oauth.clientId ?? configuredClientId ?? existingCredential?.clientId ?? "";
|
||||
const storedClientSecret =
|
||||
existingCredential?.clientId === flowClientId ? existingCredential.clientSecret : undefined;
|
||||
const userClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret;
|
||||
const flowClientSecret = userClientSecret ?? storedClientSecret ?? "";
|
||||
|
||||
this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
|
||||
|
||||
const currentAuthResource = currentAuth?.resource ? expandEnvVarsDeep(currentAuth.resource) : undefined;
|
||||
const oauthResource =
|
||||
oauth.resource ?? currentAuth?.resource ?? ("url" in baseConfig ? baseConfig.url : undefined);
|
||||
oauth.resource ?? currentAuthResource ?? ("url" in runtimeBaseConfig ? runtimeBaseConfig.url : undefined);
|
||||
|
||||
const oauthResult = await this.#handleOAuthFlow(
|
||||
oauth.authorizationUrl,
|
||||
oauth.tokenUrl,
|
||||
oauth.clientId ?? found.config.oauth?.clientId ?? "",
|
||||
oauthClientSecret,
|
||||
flowClientId,
|
||||
flowClientSecret,
|
||||
oauth.scopes ?? "",
|
||||
found.config.oauth?.callbackPort,
|
||||
found.config.oauth?.callbackPath,
|
||||
found.config.oauth?.redirectUri,
|
||||
oauthResource,
|
||||
{
|
||||
callbackPort: found.config.oauth?.callbackPort,
|
||||
callbackPath: found.config.oauth?.callbackPath,
|
||||
redirectUri: found.config.oauth?.redirectUri,
|
||||
prompt: found.config.oauth?.prompt,
|
||||
serverUrl,
|
||||
resource: oauthResource,
|
||||
},
|
||||
);
|
||||
|
||||
const persistedClientId = oauthResult.clientId ?? oauth.clientId ?? found.config.oauth?.clientId;
|
||||
const persistedClientSecret = oauthResult.clientSecret ?? (oauthClientSecret || undefined);
|
||||
const persistedResource = oauthResult.resource ?? oauthResource;
|
||||
// The flow overwrote (or minted) this profile's row; a superseded
|
||||
// pointer row from the legacy random-id era is now orphaned. GC only
|
||||
// after success so cancelling the browser step leaves the previous
|
||||
// session signed in.
|
||||
if (currentAuth?.type === "oauth" && currentAuth.credentialId !== oauthResult.credentialId) {
|
||||
await removeManagedMcpOAuthCredential(authStorage, currentAuth.credentialId);
|
||||
}
|
||||
|
||||
const updated: MCPServerConfig = {
|
||||
...baseConfig,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
credentialId: oauthResult.credentialId,
|
||||
// Definition-only entries resolve through the url-keyed binding alone;
|
||||
// skip the write-back so a committed project mcp.json stays clean.
|
||||
const urlKeyedId = serverUrl ? mcpOAuthCredentialId(serverUrl) : undefined;
|
||||
if (currentAuth || oauthResult.credentialId !== urlKeyedId) {
|
||||
const updated = this.#persistOAuthResult(baseConfig, oauthResult, {
|
||||
tokenUrl: oauth.tokenUrl,
|
||||
resource: persistedResource,
|
||||
clientId: persistedClientId,
|
||||
clientSecret: persistedClientSecret,
|
||||
},
|
||||
oauth: {
|
||||
...found.config.oauth,
|
||||
clientId: persistedClientId ?? found.config.oauth?.clientId,
|
||||
clientSecret: persistedClientSecret ?? found.config.oauth?.clientSecret,
|
||||
},
|
||||
};
|
||||
await updateMCPServer(found.filePath, name, updated);
|
||||
clientId: oauth.clientId,
|
||||
userClientSecret,
|
||||
resource: oauthResource,
|
||||
});
|
||||
await updateMCPServer(found.filePath, name, updated);
|
||||
}
|
||||
await this.#reloadMCP();
|
||||
const state = await this.#waitForServerConnectionWithAnimation(name);
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const BUILTIN_TOOL_NAMES = [
|
||||
"read",
|
||||
"bash",
|
||||
"edit",
|
||||
"ast_grep",
|
||||
"ast_edit",
|
||||
"render_mermaid",
|
||||
"ask",
|
||||
"debug",
|
||||
"eval",
|
||||
"ssh",
|
||||
"github",
|
||||
"find",
|
||||
"search",
|
||||
"lsp",
|
||||
"inspect_image",
|
||||
"browser",
|
||||
"checkpoint",
|
||||
"rewind",
|
||||
"task",
|
||||
"job",
|
||||
"irc",
|
||||
"todo",
|
||||
"web_search",
|
||||
"search_tool_bm25",
|
||||
"write",
|
||||
"memory_edit",
|
||||
"retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"learn",
|
||||
"manage_skill",
|
||||
] as const;
|
||||
|
||||
export type BuiltinToolName = (typeof BUILTIN_TOOL_NAMES)[number];
|
||||
@@ -37,6 +37,7 @@ import { AstEditTool } from "./ast-edit";
|
||||
import { AstGrepTool } from "./ast-grep";
|
||||
import { BashTool } from "./bash";
|
||||
import { BrowserTool } from "./browser";
|
||||
import type { BuiltinToolName } from "./builtin-names";
|
||||
import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
|
||||
import { DebugTool } from "./debug";
|
||||
import { EvalTool } from "./eval";
|
||||
@@ -413,7 +414,7 @@ export function filterInitialToolsForDiscoveryAll(
|
||||
* Public callable factory map. External callers may invoke `BUILTIN_TOOLS.read(session)` or
|
||||
* `BUILTIN_TOOLS[name](session)` to construct a tool directly.
|
||||
*/
|
||||
export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
||||
export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
|
||||
read: s => new ReadTool(s),
|
||||
bash: s => new BashTool(s),
|
||||
edit: s => new EditTool(s),
|
||||
@@ -455,7 +456,7 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
|
||||
goal: s => new GoalTool(s),
|
||||
};
|
||||
|
||||
export type ToolName = keyof typeof BUILTIN_TOOLS;
|
||||
export type ToolName = BuiltinToolName;
|
||||
|
||||
/**
|
||||
* Create tools from BUILTIN_TOOLS registry.
|
||||
|
||||
@@ -153,6 +153,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
expect(session.queuedMessageCount).toBe(1);
|
||||
|
||||
// Cleanup
|
||||
session.agent.clearAllQueues();
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
@@ -169,6 +170,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
expect(session.queuedMessageCount).toBe(1);
|
||||
|
||||
// Cleanup
|
||||
session.agent.clearAllQueues();
|
||||
await session.abort();
|
||||
await firstPrompt.catch(() => {});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as path from "node:path";
|
||||
import { getManagedSkillsDir } from "@oh-my-pi/pi-coding-agent/autolearn/managed-skills";
|
||||
import "@oh-my-pi/pi-coding-agent/discovery";
|
||||
import { loadSkills } from "@oh-my-pi/pi-coding-agent/extensibility/skills";
|
||||
import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils/dirs";
|
||||
|
||||
async function writeSkill(dir: string, name: string, description: string): Promise<void> {
|
||||
const file = path.join(dir, name, "SKILL.md");
|
||||
@@ -18,13 +19,16 @@ describe("managed-skills discovery", () => {
|
||||
let managedDir: string;
|
||||
let authoredDir: string;
|
||||
|
||||
let originalAgentDir: string;
|
||||
beforeEach(async () => {
|
||||
originalAgentDir = getAgentDir();
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-managed-disco-home-"));
|
||||
// cwd MUST live under the fake home so loadSkills' ancestor walk is bounded
|
||||
// and cannot pick up ambient /tmp/.omp or /.omp fixtures (full-suite-safe).
|
||||
tempCwd = path.join(tempHome, "work");
|
||||
await fs.mkdir(tempCwd, { recursive: true });
|
||||
spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
setAgentDir(path.join(tempHome, ".omp", "agent"));
|
||||
managedDir = getManagedSkillsDir();
|
||||
// Authored user skills live in the sibling `skills/` dir under .../agent.
|
||||
authoredDir = path.join(path.dirname(managedDir), "skills");
|
||||
@@ -32,6 +36,7 @@ describe("managed-skills discovery", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
spyOn(os, "homedir").mockRestore();
|
||||
setAgentDir(originalAgentDir);
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -11,17 +11,22 @@ import {
|
||||
writeManagedSkill,
|
||||
} from "@oh-my-pi/pi-coding-agent/autolearn/managed-skills";
|
||||
import { parseFrontmatter } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils/dirs";
|
||||
|
||||
describe("managed-skills primitives", () => {
|
||||
let tempHome: string;
|
||||
|
||||
let originalAgentDir: string;
|
||||
beforeEach(async () => {
|
||||
originalAgentDir = getAgentDir();
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-managed-skills-"));
|
||||
spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
setAgentDir(path.join(tempHome, ".omp", "agent"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
spyOn(os, "homedir").mockRestore();
|
||||
setAgentDir(originalAgentDir);
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { MnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/stat
|
||||
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { LearnTool } from "@oh-my-pi/pi-coding-agent/tools/learn";
|
||||
import { ManageSkillTool } from "@oh-my-pi/pi-coding-agent/tools/manage-skill";
|
||||
import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils/dirs";
|
||||
|
||||
function makeSession(
|
||||
settingsOverrides: Partial<Record<SettingPath, unknown>> = {},
|
||||
@@ -104,14 +105,18 @@ describe("autolearn tool gating", () => {
|
||||
|
||||
describe("manage_skill execute", () => {
|
||||
let tempHome: string;
|
||||
let originalAgentDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalAgentDir = getAgentDir();
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-manage-skill-"));
|
||||
spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
setAgentDir(path.join(tempHome, ".omp", "agent"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
spyOn(os, "homedir").mockRestore();
|
||||
setAgentDir(originalAgentDir);
|
||||
resetActiveSkillsForTests();
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
@@ -178,6 +183,7 @@ describe("manage_skill execute", () => {
|
||||
describe("learn execute", () => {
|
||||
let tempHome: string;
|
||||
let remembered: string[];
|
||||
let originalAgentDir: string;
|
||||
|
||||
function learnSession(): ToolSession {
|
||||
const fakeState = {
|
||||
@@ -195,13 +201,16 @@ describe("learn execute", () => {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
originalAgentDir = getAgentDir();
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-learn-"));
|
||||
spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
setAgentDir(path.join(tempHome, ".omp", "agent"));
|
||||
remembered = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
spyOn(os, "homedir").mockRestore();
|
||||
setAgentDir(originalAgentDir);
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* from both `~/.omp/agent/RULES.md` (user) and the nearest `.omp/RULES.md`
|
||||
* (project, walked up from cwd to repoRoot).
|
||||
*
|
||||
* Calls the native provider's `load` directly to bypass `loadCapability`'s
|
||||
* hardcoded `os.homedir()` so the user scope can be staged inside a tempdir.
|
||||
* Calls the native provider's `load` directly with the agent dir pointed at a
|
||||
* tempdir (via setAgentDir) so the user scope can be staged in isolation.
|
||||
*/
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
@@ -17,11 +17,15 @@ import { type Rule, ruleCapability } from "@oh-my-pi/pi-coding-agent/capability/
|
||||
import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types";
|
||||
// Register all discovery providers as a side effect.
|
||||
import "@oh-my-pi/pi-coding-agent/discovery";
|
||||
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
let tempDir: string;
|
||||
let home: string;
|
||||
let project: string;
|
||||
|
||||
const originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
@@ -44,10 +48,17 @@ beforeEach(() => {
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
fs.mkdirSync(path.join(project, ".git"), { recursive: true });
|
||||
setAgentDir(path.join(home, ".omp", "agent"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCache();
|
||||
if (originalAgentDirEnv) {
|
||||
setAgentDir(originalAgentDirEnv);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Regression: user-level MCP discovery must follow the active profile.
|
||||
*
|
||||
* A named profile relocates the agent directory to ~/.omp/profiles/<name>/agent.
|
||||
* The native config provider used to read user-scope mcp.json from the literal
|
||||
* home (~/.omp/agent/mcp.json) via `ctx.home`, so a profile never saw its own
|
||||
* user-level servers while the default profile's servers leaked into every
|
||||
* profile. Discovery now resolves the user scope through getAgentDir(), matching
|
||||
* the /mcp config writer and getMCPConfigPath("user").
|
||||
*
|
||||
* `os.homedir()` is mocked so the *old* code path (ctx.home + ".omp/agent")
|
||||
* points at the tempdir decoy below; without the fix the profile case fails
|
||||
* because it would load the decoy default server instead of the profile server.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { clearCache as clearFsCache } from "@oh-my-pi/pi-coding-agent/capability/fs";
|
||||
import { type MCPServer, mcpCapability } from "@oh-my-pi/pi-coding-agent/capability/mcp";
|
||||
import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery";
|
||||
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
async function writeMcpJson(dir: string, servers: Record<string, unknown>): Promise<void> {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "mcp.json"), JSON.stringify({ mcpServers: servers }, null, 2));
|
||||
}
|
||||
|
||||
async function loadNativeUserServers(cwd: string): Promise<MCPServer[]> {
|
||||
clearFsCache();
|
||||
const result = await loadCapability<MCPServer>(mcpCapability.id, { cwd, providers: ["native"] });
|
||||
return result.items;
|
||||
}
|
||||
|
||||
describe("native user-level MCP discovery follows the active profile", () => {
|
||||
let tempHome = "";
|
||||
let projectDir = "";
|
||||
let originalHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-profile-home-"));
|
||||
projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-profile-project-"));
|
||||
process.env.HOME = tempHome;
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
clearFsCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
clearFsCache();
|
||||
if (originalAgentDirEnv) {
|
||||
setAgentDir(originalAgentDirEnv);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("active profile loads its own user server, not the default profile's", async () => {
|
||||
// Active profile's agent dir (stand-in for ~/.omp/profiles/<name>/agent).
|
||||
const profileAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-profile-agent-"));
|
||||
setAgentDir(profileAgentDir);
|
||||
|
||||
// Decoy: the default profile's user file at the literal-home path the old
|
||||
// (buggy) loader read. It must NOT leak into the active profile.
|
||||
await writeMcpJson(path.join(tempHome, ".omp", "agent"), {
|
||||
"default-only": { command: "default-cmd" },
|
||||
});
|
||||
await writeMcpJson(profileAgentDir, {
|
||||
"profile-only": { command: "profile-cmd" },
|
||||
});
|
||||
|
||||
const servers = await loadNativeUserServers(projectDir);
|
||||
const names = servers.map(s => s.name);
|
||||
|
||||
expect(names).toContain("profile-only");
|
||||
expect(names).not.toContain("default-only");
|
||||
|
||||
const profileServer = servers.find(s => s.name === "profile-only");
|
||||
expect(profileServer?.command).toBe("profile-cmd");
|
||||
expect(profileServer?._source.level).toBe("user");
|
||||
expect(profileServer?._source.path).toBe(path.join(profileAgentDir, "mcp.json"));
|
||||
|
||||
await fs.rm(profileAgentDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("default profile loads the user server from ~/.omp/agent", async () => {
|
||||
const defaultAgentDir = path.join(tempHome, ".omp", "agent");
|
||||
setAgentDir(defaultAgentDir);
|
||||
await writeMcpJson(defaultAgentDir, {
|
||||
"default-only": { command: "default-cmd" },
|
||||
});
|
||||
|
||||
const servers = await loadNativeUserServers(projectDir);
|
||||
|
||||
const found = servers.find(s => s.name === "default-only");
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.command).toBe("default-cmd");
|
||||
expect(found?._source.level).toBe("user");
|
||||
expect(found?._source.path).toBe(path.join(defaultAgentDir, "mcp.json"));
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
clearOmpExtensionCliRoots,
|
||||
injectOmpExtensionCliRoots,
|
||||
} from "@oh-my-pi/pi-coding-agent/discovery/omp-extension-roots";
|
||||
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const PROVIDER_ID = "omp-plugins";
|
||||
|
||||
@@ -40,6 +41,9 @@ let home: string;
|
||||
let project: string;
|
||||
let ext: string;
|
||||
|
||||
const originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
@@ -92,11 +96,18 @@ beforeEach(() => {
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
fs.mkdirSync(path.join(project, ".git"), { recursive: true });
|
||||
buildExtensionPackage(ext);
|
||||
setAgentDir(path.join(home, ".omp", "agent"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCache();
|
||||
clearOmpExtensionCliRoots();
|
||||
if (originalAgentDirEnv) {
|
||||
setAgentDir(originalAgentDirEnv);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
||||
import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types";
|
||||
import { getConfigDirs } from "@oh-my-pi/pi-coding-agent/config";
|
||||
import { getUserPath } from "@oh-my-pi/pi-coding-agent/discovery/helpers";
|
||||
import { getAgentDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
describe("PI_CONFIG_DIR", () => {
|
||||
const original = process.env.PI_CONFIG_DIR;
|
||||
@@ -15,16 +16,18 @@ describe("PI_CONFIG_DIR", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("getUserPath uses PI_CONFIG_DIR for native userAgent", () => {
|
||||
process.env.PI_CONFIG_DIR = ".config/omp";
|
||||
test("getUserPath resolves the native user scope via getAgentDir (profile-aware)", () => {
|
||||
const ctx: LoadContext = {
|
||||
cwd: "/work/project",
|
||||
home: "/home/tester",
|
||||
repoRoot: null,
|
||||
};
|
||||
|
||||
const result = getUserPath(ctx, "native", "commands");
|
||||
expect(result).toBe(path.join(ctx.home, ".config/omp/agent", "commands"));
|
||||
// Native user config follows the active profile through getAgentDir(), not
|
||||
// ctx.home, so it stays in sync with builtin.ts and getMCPConfigPath("user").
|
||||
// The old behavior joined ctx.home + ".omp/agent" and leaked the default
|
||||
// profile's config into every profile.
|
||||
expect(getUserPath(ctx, "native", "commands")).toBe(path.join(getAgentDir(), "commands"));
|
||||
expect(getUserPath(ctx, "native", "commands")).not.toContain(ctx.home);
|
||||
});
|
||||
|
||||
test("getConfigDirs respects PI_CONFIG_DIR for user base", () => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Regression: OMP-native user-level config discovery must follow the active
|
||||
* profile. A profile relocates the agent directory to ~/.omp/profiles/<name>/agent;
|
||||
* the native provider used to read user config (commands, skills, rules, etc.)
|
||||
* from the literal home (~/.omp/agent) via `ctx.home`, leaking the default
|
||||
* profile's config into every profile. Discovery now resolves the user scope
|
||||
* through getAgentDir(), so a profile sees only its own config.
|
||||
*
|
||||
* Covers two code paths: getConfigDirs() (slash commands) and a direct
|
||||
* getAgentDir() join (skills). `os.homedir()` is mocked so the old code path
|
||||
* (ctx.home + ".omp/agent") points at the tempdir decoys below; without the fix
|
||||
* each test would load the default-profile fixture instead of the profile one.
|
||||
*
|
||||
* MCP has its own regression in mcp-profile.test.ts (separate paths array).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { clearCache as clearFsCache } from "@oh-my-pi/pi-coding-agent/capability/fs";
|
||||
import { type Skill, skillCapability } from "@oh-my-pi/pi-coding-agent/capability/skill";
|
||||
import { type SlashCommand, slashCommandCapability } from "@oh-my-pi/pi-coding-agent/capability/slash-command";
|
||||
import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery";
|
||||
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
async function writeFile(filePath: string, content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content);
|
||||
}
|
||||
|
||||
async function writeSkill(skillsDir: string, name: string): Promise<void> {
|
||||
await writeFile(
|
||||
path.join(skillsDir, name, "SKILL.md"),
|
||||
`---\nname: ${name}\ndescription: Skill ${name}.\n---\nBody.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("native user-level config discovery follows the active profile", () => {
|
||||
let tempHome = "";
|
||||
let projectDir = "";
|
||||
let profileAgentDir = "";
|
||||
let originalHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "omp-profile-iso-home-"));
|
||||
projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-profile-iso-project-"));
|
||||
profileAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-profile-iso-agent-"));
|
||||
process.env.HOME = tempHome;
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
setAgentDir(profileAgentDir);
|
||||
|
||||
// Active profile's config.
|
||||
await writeFile(path.join(profileAgentDir, "commands", "profile-cmd.md"), "Profile command.\n");
|
||||
await writeSkill(path.join(profileAgentDir, "skills"), "profile-skill");
|
||||
|
||||
// Decoy: default profile's config at the literal-home path the old loader read.
|
||||
const defaultAgentDir = path.join(tempHome, ".omp", "agent");
|
||||
await writeFile(path.join(defaultAgentDir, "commands", "default-cmd.md"), "Default command.\n");
|
||||
await writeSkill(path.join(defaultAgentDir, "skills"), "default-skill");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
clearFsCache();
|
||||
if (originalAgentDirEnv) {
|
||||
setAgentDir(originalAgentDirEnv);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
await fs.rm(profileAgentDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("slash commands resolve from the profile, not the default agent dir", async () => {
|
||||
clearFsCache();
|
||||
const result = await loadCapability<SlashCommand>(slashCommandCapability.id, {
|
||||
cwd: projectDir,
|
||||
providers: ["native"],
|
||||
});
|
||||
const names = result.items.map(c => c.name);
|
||||
|
||||
expect(names).toContain("profile-cmd");
|
||||
expect(names).not.toContain("default-cmd");
|
||||
expect(result.items.find(c => c.name === "profile-cmd")?._source.path).toBe(
|
||||
path.join(profileAgentDir, "commands", "profile-cmd.md"),
|
||||
);
|
||||
});
|
||||
|
||||
test("skills resolve from the profile, not the default agent dir", async () => {
|
||||
clearFsCache();
|
||||
const result = await loadCapability<Skill>(skillCapability.id, {
|
||||
cwd: projectDir,
|
||||
providers: ["native"],
|
||||
});
|
||||
const names = result.items.map(s => s.name);
|
||||
|
||||
expect(names).toContain("profile-skill");
|
||||
expect(names).not.toContain("default-skill");
|
||||
expect(result.items.find(s => s.name === "profile-skill")?._source.path).toBe(
|
||||
path.join(profileAgentDir, "skills", "profile-skill", "SKILL.md"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { applyExtensionFlags, type ExtensionFlagSink } from "../src/cli/extension-flags";
|
||||
|
||||
class FakeExtensionFlagSink implements ExtensionFlagSink {
|
||||
#values = new Map<string, boolean | string>();
|
||||
|
||||
get values(): ReadonlyMap<string, boolean | string> {
|
||||
return this.#values;
|
||||
}
|
||||
|
||||
getFlags(): Map<string, { type: "boolean" | "string" }> {
|
||||
return new Map<string, { type: "boolean" | "string" }>([
|
||||
["foo", { type: "boolean" }],
|
||||
["bar", { type: "string" }],
|
||||
]);
|
||||
}
|
||||
|
||||
setFlagValue(name: string, value: boolean | string): void {
|
||||
this.#values.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
describe("extension flag dispatch", () => {
|
||||
it("stops scanning raw argv at the end-of-options marker", () => {
|
||||
const sink = new FakeExtensionFlagSink();
|
||||
|
||||
const args = applyExtensionFlags(sink, ["--", "--foo", "bar"]);
|
||||
|
||||
expect(sink.values.size).toBe(0);
|
||||
expect(args?.messages).toEqual(["--foo", "bar"]);
|
||||
});
|
||||
|
||||
it("keeps -- as end-of-options after a string extension flag", () => {
|
||||
const sink = new FakeExtensionFlagSink();
|
||||
|
||||
const args = applyExtensionFlags(sink, ["--bar", "--", "--foo", "bar"]);
|
||||
|
||||
expect(sink.values.has("bar")).toBe(false);
|
||||
expect(sink.values.size).toBe(0);
|
||||
expect(args?.messages).toEqual(["--foo", "bar"]);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,18 @@ describe("extension flags vs initial message", () => {
|
||||
expect(parsed.print).toBeUndefined();
|
||||
expect(parsed.messages).toEqual(["hello"]);
|
||||
});
|
||||
it("keeps standalone -- as end-of-options after a string extension flag", () => {
|
||||
const parsed = parseArgs(["--spawn-peer", "--", "--model", "opus", "hello"], extFlags);
|
||||
expect(parsed.unknownFlags.has("spawn-peer")).toBe(false);
|
||||
expect(parsed.model).toBeUndefined();
|
||||
expect(parsed.messages).toEqual(["--model", "opus", "hello"]);
|
||||
});
|
||||
it("consumes literal -- string values only in equals form", () => {
|
||||
const parsed = parseArgs(["--spawn-peer=--", "--model", "opus", "hello"], extFlags);
|
||||
expect(parsed.unknownFlags.get("spawn-peer")).toBe("--");
|
||||
expect(parsed.model).toBe("opus");
|
||||
expect(parsed.messages).toEqual(["hello"]);
|
||||
});
|
||||
it("treats an @-prefixed string value as the flag's value, not a file arg (P1#1)", () => {
|
||||
const parsed = parseArgs(["--spawn-peer", "@notes.md", "hello"], extFlags);
|
||||
expect(parsed.unknownFlags.get("spawn-peer")).toBe("@notes.md");
|
||||
|
||||
@@ -290,6 +290,90 @@ describe("extensions discovery", () => {
|
||||
expect(result.extensions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("discovers a symlinked extension directory with index.ts", async () => {
|
||||
// A single extension dir shared across profiles via a symlink: the real
|
||||
// directory lives outside extensions/ and is linked into it. Native glob
|
||||
// never descends into the symlink, so this exercises the symlink fallback.
|
||||
const realDir = path.join(tempDir.path(), "external", "shared-ext");
|
||||
fs.mkdirSync(realDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(realDir, "index.ts"), extensionCode);
|
||||
fs.symlinkSync(realDir, path.join(extensionsDir, "linked-ext"), "dir");
|
||||
|
||||
const result = await discoverForTest();
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("linked-ext");
|
||||
expect(result.extensions[0].path).toContain("index.ts");
|
||||
});
|
||||
|
||||
it("discovers a symlinked extension directory with a package.json manifest", async () => {
|
||||
// Mirrors the real-world shape: a packaged extension (package.json + index.ts)
|
||||
// symlinked into a profile's extensions/ dir.
|
||||
const realDir = path.join(tempDir.path(), "external", "ctk");
|
||||
fs.mkdirSync(realDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(realDir, "index.ts"), extensionCodeWithTool("ctk-tool"));
|
||||
fs.writeFileSync(
|
||||
path.join(realDir, "package.json"),
|
||||
JSON.stringify({ name: "ctk", omp: { extensions: ["./index.ts"] } }),
|
||||
);
|
||||
fs.symlinkSync(realDir, path.join(extensionsDir, "ctk"), "dir");
|
||||
|
||||
const result = await discoverForTest();
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
// Manifest declares index.ts; it must be discovered exactly once (no double
|
||||
// from the synthesized index.ts match colliding with the manifest entry).
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("index.ts");
|
||||
expect(result.extensions[0].tools.has("ctk-tool")).toBe(true);
|
||||
});
|
||||
|
||||
it("discovers a symlinked extension file", async () => {
|
||||
// Symlinked *files* resolve through the native file-type filter; guards that
|
||||
// the directory fallback does not regress the file case.
|
||||
const realFile = path.join(tempDir.path(), "external", "shared.ts");
|
||||
fs.mkdirSync(path.dirname(realFile), { recursive: true });
|
||||
fs.writeFileSync(realFile, extensionCode);
|
||||
fs.symlinkSync(realFile, path.join(extensionsDir, "linked.ts"), "file");
|
||||
|
||||
const result = await discoverForTest();
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("linked.ts");
|
||||
});
|
||||
|
||||
it("does not crash on a dangling symlinked extension directory", async () => {
|
||||
// A profile symlink pointing at a since-deleted shared extension. The fallback
|
||||
// reads the (missing) target, gets [], and must yield no extension and no
|
||||
// error rather than throwing.
|
||||
fs.symlinkSync(path.join(tempDir.path(), "external", "gone"), path.join(extensionsDir, "broken"), "dir");
|
||||
|
||||
const result = await discoverForTest();
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("discovers a symlinked extension directory whose name ends in .ts", async () => {
|
||||
// Odd but legal: a *.ts-named symlink that targets a directory. The native
|
||||
// file-type filter rejects it as a direct file (target is a dir), so it must
|
||||
// resolve exactly once via the synthesized subdir index — never double-counted
|
||||
// as both a direct file and a subdir entry.
|
||||
const realDir = path.join(tempDir.path(), "external", "weird");
|
||||
fs.mkdirSync(realDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(realDir, "index.ts"), extensionCode);
|
||||
fs.symlinkSync(realDir, path.join(extensionsDir, "weird.ts"), "dir");
|
||||
|
||||
const result = await discoverForTest();
|
||||
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].path).toContain("weird.ts");
|
||||
expect(result.extensions[0].path).toContain("index.ts");
|
||||
});
|
||||
|
||||
it("skips non-existent paths declared in package.json", async () => {
|
||||
const subdir = path.join(extensionsDir, "my-package");
|
||||
fs.mkdirSync(subdir);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseArgs } from "../src/cli/args";
|
||||
import { OPTIONAL_VALUE_FLAGS, STRING_VALUE_FLAGS } from "../src/cli/flag-tables";
|
||||
|
||||
/**
|
||||
* Catches the set → args.ts direction of drift between
|
||||
* `cli/flag-tables.ts` and `cli/args.ts`:
|
||||
*
|
||||
* - If `STRING_VALUE_FLAGS` claims a flag consumes a value but
|
||||
* `parseArgs` treats it as boolean (or doesn't handle it), then
|
||||
* `<flag> --profile work` would leave `--profile` standing — and
|
||||
* parseArgs would activate the profile branch. We assert
|
||||
* `result.profile` is undefined: the only way that's true is if the
|
||||
* flag actually swallowed `--profile` as its value.
|
||||
*
|
||||
* - If `OPTIONAL_VALUE_FLAGS` claims a flag releases `-`-prefixed
|
||||
* tokens but `parseArgs` swallows them anyway, then
|
||||
* `<flag> --profile work` would suppress the profile activation. We
|
||||
* assert `result.profile === "work"`: the flag must NOT have eaten
|
||||
* `--profile`, so parseArgs sees and activates it.
|
||||
*
|
||||
* The reverse direction (args.ts handler missing from the set) cannot
|
||||
* be reflected on without parsing args.ts source — it's covered by
|
||||
* per-flag regression tests in `profile-bootstrap.test.ts` and by
|
||||
* user-facing scenarios in `profile-cli.test.ts`.
|
||||
*/
|
||||
describe("STRING_VALUE_FLAGS table is honored by args.ts parseArgs", () => {
|
||||
for (const flag of STRING_VALUE_FLAGS) {
|
||||
it(`${flag} consumes the next token unconditionally`, () => {
|
||||
const result = parseArgs([flag, "--profile", "work"]);
|
||||
expect(
|
||||
result.profile,
|
||||
`parseArgs should treat --profile as the value of ${flag}, not as a profile activation`,
|
||||
).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("OPTIONAL_VALUE_FLAGS table is honored by args.ts parseArgs", () => {
|
||||
for (const flag of OPTIONAL_VALUE_FLAGS) {
|
||||
it(`${flag} releases tokens that start with -`, () => {
|
||||
const result = parseArgs([flag, "--profile", "work"]);
|
||||
expect(
|
||||
result.profile,
|
||||
`parseArgs should release --profile back to its own handler when it follows ${flag}`,
|
||||
).toBe("work");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("OPTIONAL_FLAGS per-flag quirks", () => {
|
||||
it("treats empty string as bare resume for --resume", () => {
|
||||
const result = parseArgs(["--resume", ""]);
|
||||
expect(result.resume).toBe(true);
|
||||
expect(result.messages).toEqual([""]);
|
||||
});
|
||||
|
||||
it("treats empty string as bare resume for -r", () => {
|
||||
const result = parseArgs(["-r", ""]);
|
||||
expect(result.resume).toBe(true);
|
||||
expect(result.messages).toEqual([""]);
|
||||
});
|
||||
|
||||
it("treats empty string as bare resume for --session", () => {
|
||||
const result = parseArgs(["--session", ""]);
|
||||
expect(result.resume).toBe(true);
|
||||
expect(result.messages).toEqual([""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseArgs end-of-options (--)", () => {
|
||||
it("treats tokens after -- as literal messages, not flags", () => {
|
||||
const result = parseArgs(["--", "--profile", "work"]);
|
||||
expect(result.profile).toBeUndefined();
|
||||
expect(result.messages).toEqual(["--profile", "work"]);
|
||||
});
|
||||
|
||||
it("does not interpret @ args or known value flags after --", () => {
|
||||
const result = parseArgs(["--", "@file.md", "--model", "opus"]);
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.fileArgs).toEqual([]);
|
||||
expect(result.messages).toEqual(["@file.md", "--model", "opus"]);
|
||||
});
|
||||
|
||||
it("parses flags before -- and forwards the rest as text", () => {
|
||||
const result = parseArgs(["--print", "hello", "--", "--no-tools"]);
|
||||
expect(result.print).toBe(true);
|
||||
expect(result.noTools).toBeUndefined();
|
||||
expect(result.messages).toEqual(["hello", "--no-tools"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
|
||||
import * as mcpClient from "@oh-my-pi/pi-coding-agent/mcp/client";
|
||||
import * as oauthFlow from "@oh-my-pi/pi-coding-agent/mcp/oauth-flow";
|
||||
import type { MCPServerConfig } from "@oh-my-pi/pi-coding-agent/mcp/types";
|
||||
import { MCPCommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/mcp-command-controller";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { getConfigRootDir, getMCPConfigPath, getProjectDir, setAgentDir, setProjectDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const RAW_SERVER_URL = `https://\${MCP_HOST}/mcp`;
|
||||
const EXPANDED_SERVER_URL = "https://mcp.example.com/mcp";
|
||||
const AUTH_ERROR = new Error(
|
||||
'HTTP 401: {"authorization_url":"https://auth.example.com/authorize","token_url":"https://auth.example.com/token"}',
|
||||
);
|
||||
|
||||
type TestConfigFile = {
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
};
|
||||
|
||||
const originalProjectDir = getProjectDir();
|
||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
function restoreEnvValue(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete Bun.env[name];
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
Bun.env[name] = value;
|
||||
process.env[name] = value;
|
||||
}
|
||||
function createController(authStorage: AuthStorage, mcpManagerOverrides: Record<string, unknown> = {}) {
|
||||
const showError = vi.fn();
|
||||
const prepareConfig = vi.fn(async (config: MCPServerConfig) => config);
|
||||
const mcpManager = {
|
||||
prepareConfig,
|
||||
disconnectAll: vi.fn(async () => {}),
|
||||
discoverAndConnect: vi.fn(async () => ({ errors: new Map<string, string>() })),
|
||||
getTools: vi.fn(() => []),
|
||||
waitForConnection: vi.fn(async () => {}),
|
||||
getConnectionStatus: vi.fn(() => "connected"),
|
||||
...mcpManagerOverrides,
|
||||
};
|
||||
const controller = new MCPCommandController({
|
||||
chatContainer: { addChild: vi.fn() },
|
||||
present: vi.fn(),
|
||||
ui: { requestRender: vi.fn() },
|
||||
editor: {},
|
||||
showError,
|
||||
showStatus: vi.fn(),
|
||||
oauthManualInput: {
|
||||
hasPending: vi.fn(() => false),
|
||||
pendingProviderId: undefined,
|
||||
tryClaimInput: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
refreshMCPTools: vi.fn(),
|
||||
modelRegistry: { authStorage },
|
||||
},
|
||||
mcpManager,
|
||||
} as never);
|
||||
|
||||
return { controller, showError, prepareConfig, mcpManager };
|
||||
}
|
||||
|
||||
describe("/mcp auth commands", () => {
|
||||
let projectDir = "";
|
||||
let agentDir = "";
|
||||
let configPath = "";
|
||||
let originalMcpHost: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-reauth-project-"));
|
||||
agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-mcp-reauth-agent-"));
|
||||
configPath = path.join(projectDir, ".mcp.json");
|
||||
originalMcpHost = Bun.env.MCP_HOST;
|
||||
Bun.env.MCP_HOST = "mcp.example.com";
|
||||
process.env.MCP_HOST = "mcp.example.com";
|
||||
setProjectDir(projectDir);
|
||||
setAgentDir(agentDir);
|
||||
await Bun.write(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
envserver: {
|
||||
type: "http",
|
||||
url: RAW_SERVER_URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
restoreEnvValue("MCP_HOST", originalMcpHost);
|
||||
setProjectDir(originalProjectDir);
|
||||
if (originalAgentDir) {
|
||||
setAgentDir(originalAgentDir);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("stores definition-only OAuth credentials under the expanded URL key", async () => {
|
||||
const authStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")));
|
||||
await authStorage.reload();
|
||||
const connectToServer = vi.spyOn(mcpClient, "connectToServer").mockRejectedValue(AUTH_ERROR);
|
||||
vi.spyOn(oauthFlow.MCPOAuthFlow.prototype, "login").mockResolvedValue({
|
||||
access: "fresh-access",
|
||||
refresh: "fresh-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
const { controller, showError, prepareConfig } = createController(authStorage);
|
||||
|
||||
await controller.handle("/mcp reauth envserver");
|
||||
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(prepareConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: EXPANDED_SERVER_URL }),
|
||||
expect.objectContaining({ oauth: false }),
|
||||
);
|
||||
expect(connectToServer).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ url: EXPANDED_SERVER_URL }),
|
||||
);
|
||||
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL))).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "fresh-access",
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
resource: EXPANDED_SERVER_URL,
|
||||
});
|
||||
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(RAW_SERVER_URL))).toBeUndefined();
|
||||
|
||||
const saved = JSON.parse(await Bun.file(configPath).text()) as TestConfigFile;
|
||||
const savedServer = saved.mcpServers?.envserver;
|
||||
const savedUrl = savedServer?.type === "http" || savedServer?.type === "sse" ? savedServer.url : undefined;
|
||||
expect(savedUrl).toBe(RAW_SERVER_URL);
|
||||
expect(savedServer?.auth).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reuses embedded DCR client secret during reauth token exchange", async () => {
|
||||
const authStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")));
|
||||
await authStorage.reload();
|
||||
await authStorage.set(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL), {
|
||||
type: "oauth",
|
||||
access: "old-access",
|
||||
refresh: "old-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
tokenUrl: "https://auth.example.com/token",
|
||||
clientId: "dcr-client",
|
||||
clientSecret: "dcr-secret",
|
||||
resource: EXPANDED_SERVER_URL,
|
||||
} as oauthFlow.MCPStoredOAuthCredential);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: "fresh-access",
|
||||
refresh_token: "fresh-refresh",
|
||||
expires_in: 3600,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.spyOn(mcpClient, "connectToServer").mockRejectedValue(AUTH_ERROR);
|
||||
vi.spyOn(oauthFlow.MCPOAuthFlow.prototype, "login").mockImplementation(function (this: oauthFlow.MCPOAuthFlow) {
|
||||
return this.exchangeToken("authorization-code", "state", "http://127.0.0.1/callback");
|
||||
});
|
||||
const { controller, showError } = createController(authStorage);
|
||||
|
||||
await controller.handle("/mcp reauth envserver");
|
||||
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
const tokenRequestBody = String(fetchSpy.mock.calls[0]?.[1]?.body ?? "");
|
||||
const tokenRequest = new URLSearchParams(tokenRequestBody);
|
||||
expect(tokenRequest.get("client_id")).toBe("dcr-client");
|
||||
expect(tokenRequest.get("client_secret")).toBe("dcr-secret");
|
||||
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL))).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "fresh-access",
|
||||
clientId: "dcr-client",
|
||||
clientSecret: "dcr-secret",
|
||||
});
|
||||
});
|
||||
|
||||
test("clears both expanded and stale raw URL-keyed credentials on unauth", async () => {
|
||||
const authStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")));
|
||||
await authStorage.reload();
|
||||
await authStorage.set(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL), {
|
||||
type: "oauth",
|
||||
access: "expanded-access",
|
||||
refresh: "expanded-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
await authStorage.set(oauthFlow.mcpOAuthCredentialId(RAW_SERVER_URL), {
|
||||
type: "oauth",
|
||||
access: "raw-access",
|
||||
refresh: "raw-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
const { controller, showError } = createController(authStorage);
|
||||
|
||||
await controller.handle("/mcp unauth envserver");
|
||||
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL))).toBeUndefined();
|
||||
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(RAW_SERVER_URL))).toBeUndefined();
|
||||
const saved = JSON.parse(await Bun.file(configPath).text()) as TestConfigFile;
|
||||
const savedServer = saved.mcpServers?.envserver;
|
||||
const savedUrl = savedServer?.type === "http" || savedServer?.type === "sse" ? savedServer.url : undefined;
|
||||
expect(savedUrl).toBe(RAW_SERVER_URL);
|
||||
expect(savedServer?.auth).toBeUndefined();
|
||||
});
|
||||
|
||||
test("clears url-keyed auth for discovered definition-only servers", async () => {
|
||||
const authStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")));
|
||||
await authStorage.reload();
|
||||
await authStorage.set(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL), {
|
||||
type: "oauth",
|
||||
access: "discovered-access",
|
||||
refresh: "discovered-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
const { controller, showError } = createController(authStorage, {
|
||||
getServerConfig: vi.fn(() => ({ type: "http", url: EXPANDED_SERVER_URL })),
|
||||
getSource: vi.fn(() => ({ provider: "test", path: "/tmp/discovered.json" })),
|
||||
});
|
||||
|
||||
await controller.handle("/mcp unauth discovered");
|
||||
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(authStorage.get(oauthFlow.mcpOAuthCredentialId(EXPANDED_SERVER_URL))).toBeUndefined();
|
||||
const userConfigPath = getMCPConfigPath("user", projectDir);
|
||||
const userConfig = JSON.parse(
|
||||
await Bun.file(userConfigPath)
|
||||
.text()
|
||||
.catch(() => "{}"),
|
||||
) as TestConfigFile;
|
||||
expect(userConfig.mcpServers?.discovered).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Contract tests for per-profile MCP OAuth bindings (url-keyed credentials).
|
||||
*
|
||||
* A server *definition* may live in a shared project `mcp.json` while each
|
||||
* profile holds its own credential row in agent.db under the deterministic
|
||||
* `mcp_oauth:profile:<profile>:<url>` id. Before this scheme, the random
|
||||
* `auth.credentialId` written into the shared file pointed at exactly one
|
||||
* profile's row, so two profiles reauthorizing the same project server
|
||||
* clobbered each other.
|
||||
*/
|
||||
import { Database } from "bun:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
|
||||
import { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp/manager";
|
||||
import * as oauthFlow from "@oh-my-pi/pi-coding-agent/mcp/oauth-flow";
|
||||
import { mcpOAuthCredentialId } from "@oh-my-pi/pi-coding-agent/mcp/oauth-flow";
|
||||
import type { MCPServerConfig } from "@oh-my-pi/pi-coding-agent/mcp/types";
|
||||
import { getActiveProfile, setProfile } from "@oh-my-pi/pi-utils/dirs";
|
||||
|
||||
const SERVER_URL = "https://mcp.example.com/mcp";
|
||||
const URL_KEY_ID = mcpOAuthCredentialId(SERVER_URL);
|
||||
|
||||
function authorizationHeader(config: MCPServerConfig): string | undefined {
|
||||
if (config.type !== "http" && config.type !== "sse") return undefined;
|
||||
return config.headers?.Authorization;
|
||||
}
|
||||
|
||||
describe("per-profile MCP OAuth binding", () => {
|
||||
let manager: MCPManager;
|
||||
let authStorage: AuthStorage;
|
||||
let originalProfile: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalProfile = getActiveProfile();
|
||||
const store = new SqliteAuthCredentialStore(new Database(":memory:"));
|
||||
authStorage = new AuthStorage(store);
|
||||
await authStorage.reload();
|
||||
manager = new MCPManager(process.cwd());
|
||||
manager.setAuthStorage(authStorage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setProfile(originalProfile);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("scopes url-keyed credentials by active profile in a shared auth namespace", async () => {
|
||||
const workKey = mcpOAuthCredentialId(SERVER_URL, "work");
|
||||
const personalKey = mcpOAuthCredentialId(SERVER_URL, "personal");
|
||||
expect(workKey).not.toBe(personalKey);
|
||||
await authStorage.set(workKey, {
|
||||
type: "oauth",
|
||||
access: "work-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
await authStorage.set(personalKey, {
|
||||
type: "oauth",
|
||||
access: "personal-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
setProfile("work");
|
||||
expect(authorizationHeader(await manager.prepareConfig({ type: "http", url: SERVER_URL }))).toBe(
|
||||
"Bearer work-token",
|
||||
);
|
||||
|
||||
setProfile("personal");
|
||||
expect(authorizationHeader(await manager.prepareConfig({ type: "http", url: SERVER_URL }))).toBe(
|
||||
"Bearer personal-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores another profile's explicit profile-scoped credentialId in shared storage", async () => {
|
||||
const workKey = mcpOAuthCredentialId(SERVER_URL, "work");
|
||||
const personalKey = mcpOAuthCredentialId(SERVER_URL, "personal");
|
||||
await authStorage.set(workKey, {
|
||||
type: "oauth",
|
||||
access: "work-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
setProfile("personal");
|
||||
expect(
|
||||
authorizationHeader(
|
||||
await manager.prepareConfig({
|
||||
type: "http",
|
||||
url: SERVER_URL,
|
||||
auth: { type: "oauth", credentialId: workKey },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
await authStorage.set(personalKey, {
|
||||
type: "oauth",
|
||||
access: "personal-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
authorizationHeader(
|
||||
await manager.prepareConfig({
|
||||
type: "http",
|
||||
url: SERVER_URL,
|
||||
auth: { type: "oauth", credentialId: workKey },
|
||||
}),
|
||||
),
|
||||
).toBe("Bearer personal-token");
|
||||
});
|
||||
|
||||
test("resolves the url-keyed credential when the file's credentialId belongs to another profile", async () => {
|
||||
// This profile authed the server (url-keyed row exists), but the shared
|
||||
// project file still carries a credentialId minted by a different profile.
|
||||
await authStorage.set(URL_KEY_ID, {
|
||||
type: "oauth",
|
||||
access: "this-profile-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
const prepared = await manager.prepareConfig({
|
||||
type: "http",
|
||||
url: SERVER_URL,
|
||||
auth: { type: "oauth", credentialId: "mcp_oauth_1234_other_profile" },
|
||||
});
|
||||
|
||||
expect(authorizationHeader(prepared)).toBe("Bearer this-profile-token");
|
||||
});
|
||||
|
||||
test("resolves the url-keyed credential for a definition-only config (no auth block)", async () => {
|
||||
await authStorage.set(URL_KEY_ID, {
|
||||
type: "oauth",
|
||||
access: "bound-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
const prepared = await manager.prepareConfig({ type: "http", url: SERVER_URL });
|
||||
|
||||
expect(authorizationHeader(prepared)).toBe("Bearer bound-token");
|
||||
});
|
||||
|
||||
test("prepareConfig({ oauth: false }) skips injection so the reauth probe sees the bare server", async () => {
|
||||
await authStorage.set(URL_KEY_ID, {
|
||||
type: "oauth",
|
||||
access: "bound-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
const prepared = await manager.prepareConfig({ type: "http", url: SERVER_URL }, { oauth: false });
|
||||
|
||||
expect(authorizationHeader(prepared)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("never clobbers an explicitly configured Authorization header", async () => {
|
||||
await authStorage.set(URL_KEY_ID, {
|
||||
type: "oauth",
|
||||
access: "bound-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
const prepared = await manager.prepareConfig({
|
||||
type: "http",
|
||||
url: SERVER_URL,
|
||||
headers: { authorization: "Bearer user-pinned" },
|
||||
});
|
||||
|
||||
expect(prepared.type === "http" ? prepared.headers?.authorization : undefined).toBe("Bearer user-pinned");
|
||||
expect(authorizationHeader(prepared)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("refreshes with embedded material and preserves it across rotation", async () => {
|
||||
await authStorage.set(URL_KEY_ID, {
|
||||
type: "oauth",
|
||||
access: "expired-token",
|
||||
refresh: "old-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
tokenUrl: "https://mcp.example.com/token",
|
||||
clientId: "embedded-client",
|
||||
clientSecret: "embedded-secret",
|
||||
} as oauthFlow.MCPStoredOAuthCredential);
|
||||
|
||||
const refreshSpy = vi.spyOn(oauthFlow, "refreshMCPOAuthToken").mockResolvedValue({
|
||||
access: "fresh-token",
|
||||
refresh: "fresh-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
// Definition-only config: refresh material must come from the credential.
|
||||
const prepared = await manager.prepareConfig({ type: "http", url: SERVER_URL });
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith(
|
||||
"https://mcp.example.com/token",
|
||||
"old-refresh",
|
||||
"embedded-client",
|
||||
"embedded-secret",
|
||||
SERVER_URL,
|
||||
);
|
||||
expect(authorizationHeader(prepared)).toBe("Bearer fresh-token");
|
||||
// Embedded refresh material must survive rotation, or the *next* refresh
|
||||
// of this definition-only binding would be impossible.
|
||||
expect(authStorage.get(URL_KEY_ID)).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "fresh-token",
|
||||
refresh: "fresh-refresh",
|
||||
tokenUrl: "https://mcp.example.com/token",
|
||||
clientId: "embedded-client",
|
||||
resource: SERVER_URL,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not inject oauth for configs with explicit apikey auth", async () => {
|
||||
await authStorage.set(URL_KEY_ID, {
|
||||
type: "oauth",
|
||||
access: "bound-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
const prepared = await manager.prepareConfig({
|
||||
type: "http",
|
||||
url: SERVER_URL,
|
||||
auth: { type: "apikey" },
|
||||
});
|
||||
|
||||
expect(authorizationHeader(prepared)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an explicit credentialId that resolves wins over the url-keyed row", async () => {
|
||||
await authStorage.set("mcp_oauth_1234_pinned", {
|
||||
type: "oauth",
|
||||
access: "pinned-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
await authStorage.set(URL_KEY_ID, {
|
||||
type: "oauth",
|
||||
access: "url-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
const prepared = await manager.prepareConfig({
|
||||
type: "http",
|
||||
url: SERVER_URL,
|
||||
auth: { type: "oauth", credentialId: "mcp_oauth_1234_pinned" },
|
||||
});
|
||||
|
||||
expect(authorizationHeader(prepared)).toBe("Bearer pinned-token");
|
||||
});
|
||||
|
||||
test("url-keyed fallback never overrides a pinned Authorization header, even past a stale auth block", async () => {
|
||||
await authStorage.set(URL_KEY_ID, {
|
||||
type: "oauth",
|
||||
access: "bound-token",
|
||||
refresh: "r",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
const prepared = await manager.prepareConfig({
|
||||
type: "http",
|
||||
url: SERVER_URL,
|
||||
headers: { Authorization: "Bearer user-pinned" },
|
||||
auth: { type: "oauth", credentialId: "mcp_oauth_1234_other_profile" },
|
||||
});
|
||||
|
||||
expect(authorizationHeader(prepared)).toBe("Bearer user-pinned");
|
||||
});
|
||||
|
||||
test("refresh uses the credential's embedded client, not another profile's auth block", async () => {
|
||||
// Shared file carries profile A's refresh material; this profile's
|
||||
// url-keyed row embeds its own DCR client. Refresh tokens are bound to
|
||||
// the client that minted them, so the embedded material must win or the
|
||||
// refresh dies with invalid_grant and the row gets purged.
|
||||
await authStorage.set(URL_KEY_ID, {
|
||||
type: "oauth",
|
||||
access: "expired-token",
|
||||
refresh: "my-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
tokenUrl: "https://mcp.example.com/token",
|
||||
clientId: "my-dcr-client",
|
||||
} as oauthFlow.MCPStoredOAuthCredential);
|
||||
|
||||
const refreshSpy = vi.spyOn(oauthFlow, "refreshMCPOAuthToken").mockResolvedValue({
|
||||
access: "fresh-token",
|
||||
refresh: "my-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
const prepared = await manager.prepareConfig({
|
||||
type: "http",
|
||||
url: SERVER_URL,
|
||||
auth: {
|
||||
type: "oauth",
|
||||
credentialId: "mcp_oauth_1234_other_profile",
|
||||
tokenUrl: "https://mcp.example.com/token",
|
||||
clientId: "other-profiles-client",
|
||||
clientSecret: "other-profiles-secret",
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith(
|
||||
"https://mcp.example.com/token",
|
||||
"my-refresh",
|
||||
"my-dcr-client",
|
||||
undefined,
|
||||
SERVER_URL,
|
||||
);
|
||||
expect(authorizationHeader(prepared)).toBe("Bearer fresh-token");
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,68 @@ describe("mcp oauth flow", () => {
|
||||
expect(authUrl.searchParams.get("state")).toBe("test-state");
|
||||
});
|
||||
|
||||
it("defaults prompt=consent so reauth can switch accounts despite an active browser session", async () => {
|
||||
const flow = new MCPOAuthFlow(
|
||||
{
|
||||
authorizationUrl: "https://provider.example/authorize",
|
||||
tokenUrl: "https://provider.example/token",
|
||||
clientId: "client-id",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const { url } = await flow.generateAuthUrl("test-state", "http://127.0.0.1:53180/callback");
|
||||
|
||||
expect(new URL(url).searchParams.get("prompt")).toBe("consent");
|
||||
});
|
||||
|
||||
it("passes an explicit prompt value through to the authorization request", async () => {
|
||||
const flow = new MCPOAuthFlow(
|
||||
{
|
||||
authorizationUrl: "https://provider.example/authorize",
|
||||
tokenUrl: "https://provider.example/token",
|
||||
clientId: "client-id",
|
||||
prompt: "select_account",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const { url } = await flow.generateAuthUrl("s", "http://127.0.0.1:53181/callback");
|
||||
|
||||
expect(new URL(url).searchParams.get("prompt")).toBe("select_account");
|
||||
});
|
||||
|
||||
it("omits the prompt parameter entirely when configured as the empty string", async () => {
|
||||
const flow = new MCPOAuthFlow(
|
||||
{
|
||||
authorizationUrl: "https://provider.example/authorize",
|
||||
tokenUrl: "https://provider.example/token",
|
||||
clientId: "client-id",
|
||||
prompt: "",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const { url } = await flow.generateAuthUrl("s", "http://127.0.0.1:53182/callback");
|
||||
|
||||
expect(new URL(url).searchParams.has("prompt")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a prompt value already embedded in the authorization URL", async () => {
|
||||
const flow = new MCPOAuthFlow(
|
||||
{
|
||||
authorizationUrl: "https://provider.example/authorize?prompt=none",
|
||||
tokenUrl: "https://provider.example/token",
|
||||
clientId: "client-id",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const { url } = await flow.generateAuthUrl("test-state", "http://127.0.0.1:53183/callback");
|
||||
|
||||
expect(new URL(url).searchParams.get("prompt")).toBe("none");
|
||||
});
|
||||
|
||||
it("uses configured callbackPath for the local redirect URI", async () => {
|
||||
let observedRedirectUri = "";
|
||||
let tokenRequestBody = "";
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
installProfileAlias,
|
||||
readProfileAliasConfigFile,
|
||||
resolveProfileAliasCommandFromProcess,
|
||||
} from "../src/cli/profile-alias";
|
||||
|
||||
describe("profile alias installer", () => {
|
||||
it("writes a bash-compatible function that forwards subcommands through omp", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
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(result.command).toBe("omp --profile=work");
|
||||
expect(files.get("/home/me/.bashrc")).toContain("omp-work() {");
|
||||
expect(files.get("/home/me/.bashrc")).toContain('command omp --profile=work "$@"');
|
||||
});
|
||||
|
||||
it("resolves source invocations without forcing the source checkout as cwd", () => {
|
||||
const command = resolveProfileAliasCommandFromProcess(["/bin/bun", "src/cli.ts"], "/repo/packages/coding-agent");
|
||||
|
||||
expect(command.display).toBe("/bin/bun /repo/packages/coding-agent/src/cli.ts");
|
||||
expect(command.posix).toBe("'/bin/bun' '/repo/packages/coding-agent/src/cli.ts'");
|
||||
expect(command.fish).toBe("'/bin/bun' '/repo/packages/coding-agent/src/cli.ts'");
|
||||
expect(command.powerShell).toBe("'/bin/bun' '/repo/packages/coding-agent/src/cli.ts'");
|
||||
});
|
||||
|
||||
it("can target the current source invocation instead of the installed omp binary", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
const result = await installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
shellPath: "/bin/zsh",
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/me",
|
||||
command: {
|
||||
display: "bun /repo/packages/coding-agent/src/cli.ts",
|
||||
posix: "bun '/repo/packages/coding-agent/src/cli.ts'",
|
||||
fish: "bun /repo/packages/coding-agent/src/cli.ts",
|
||||
powerShell: "bun '/repo/packages/coding-agent/src/cli.ts'",
|
||||
},
|
||||
readFile: async filePath => files.get(filePath) ?? "",
|
||||
writeFile: async (filePath, content) => {
|
||||
files.set(filePath, content);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.command).toBe("bun /repo/packages/coding-agent/src/cli.ts --profile=work");
|
||||
expect(files.get("/Users/me/.zshrc")).toContain("omp-work() {");
|
||||
expect(files.get("/Users/me/.zshrc")).toContain(
|
||||
`command bun '/repo/packages/coding-agent/src/cli.ts' --profile=work "$@"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("installs the zsh alias under ZDOTDIR when set", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
const result = await installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
shellPath: "/bin/zsh",
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/me",
|
||||
env: { ZDOTDIR: "/Users/me/.config/zsh" },
|
||||
readFile: async filePath => files.get(filePath) ?? "",
|
||||
writeFile: async (filePath, content) => {
|
||||
files.set(filePath, content);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.configPath).toBe("/Users/me/.config/zsh/.zshrc");
|
||||
expect(files.get(result.configPath)).toContain("omp-work() {");
|
||||
});
|
||||
|
||||
it("writes a fish function that forwards argv", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
await installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
shellPath: "/opt/homebrew/bin/fish",
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/me",
|
||||
env: {},
|
||||
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("installs the fish alias under XDG_CONFIG_HOME when set", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
const result = await installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
shellPath: "/usr/bin/fish",
|
||||
platform: "linux",
|
||||
homeDir: "/home/me",
|
||||
env: { XDG_CONFIG_HOME: "/home/me/.dotfiles/config" },
|
||||
readFile: async filePath => files.get(filePath) ?? "",
|
||||
writeFile: async (filePath, content) => {
|
||||
files.set(filePath, content);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.configPath).toBe("/home/me/.dotfiles/config/fish/conf.d/omp-profiles.fish");
|
||||
expect(files.get(result.configPath)).toContain("function omp-work --wraps omp");
|
||||
});
|
||||
|
||||
it("writes a PowerShell function because aliases cannot carry arguments", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
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("detects pwsh from PSModulePath when SHELL is unset on Windows", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
const result = await installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
platform: "win32",
|
||||
homeDir: "C:\\Users\\me",
|
||||
env: {
|
||||
PSModulePath:
|
||||
"C:\\Users\\me\\Documents\\PowerShell\\Modules;C:\\Program Files\\PowerShell\\7\\Modules;C:\\Users\\me\\Documents\\WindowsPowerShell\\Modules",
|
||||
},
|
||||
readFile: async filePath => files.get(filePath) ?? "",
|
||||
writeFile: async (filePath, content) => {
|
||||
files.set(filePath, content);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.shell).toBe("pwsh");
|
||||
expect(result.configPath).toBe("C:\\Users\\me/Documents/PowerShell/Microsoft.PowerShell_profile.ps1");
|
||||
expect(files.get(result.configPath)).toContain("& omp --profile=work @args");
|
||||
});
|
||||
|
||||
it("selects Windows PowerShell when only WindowsPowerShell modules are present", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
const result = await installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
platform: "win32",
|
||||
homeDir: "C:\\Users\\me",
|
||||
env: {
|
||||
PSModulePath:
|
||||
"C:\\Users\\me\\Documents\\WindowsPowerShell\\Modules;C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules",
|
||||
},
|
||||
readFile: async filePath => files.get(filePath) ?? "",
|
||||
writeFile: async (filePath, content) => {
|
||||
files.set(filePath, content);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.shell).toBe("powershell");
|
||||
expect(result.configPath).toBe("C:\\Users\\me/Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1");
|
||||
});
|
||||
|
||||
it("treats POWERSHELL_DISTRIBUTION_CHANNEL as a pwsh hint when no module paths disambiguate", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
const result = await installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
platform: "win32",
|
||||
homeDir: "C:\\Users\\me",
|
||||
env: { POWERSHELL_DISTRIBUTION_CHANNEL: "MSI:Windows 10 Pro" },
|
||||
readFile: async filePath => files.get(filePath) ?? "",
|
||||
writeFile: async (filePath, content) => {
|
||||
files.set(filePath, content);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.shell).toBe("pwsh");
|
||||
expect(result.configPath).toBe("C:\\Users\\me/Documents/PowerShell/Microsoft.PowerShell_profile.ps1");
|
||||
});
|
||||
|
||||
it("replaces a previous block for the same alias", async () => {
|
||||
const files = new Map<string, string>([
|
||||
[
|
||||
"/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('command omp --profile=work "$@"');
|
||||
expect(content).not.toContain("--profile=old");
|
||||
});
|
||||
|
||||
it("refuses to rewrite a malformed managed block missing its end marker", async () => {
|
||||
// A start marker without its matching end marker means a previous install
|
||||
// was interrupted or hand-edited. Appending a fresh block would let the
|
||||
// *next* install splice from the stale start through the new end, deleting
|
||||
// the user config in between. Refuse and preserve the file untouched.
|
||||
const original = ["# >>> omp profile alias: omp-work >>>", "omp-work() {", "export SECRET=keepme"].join("\n");
|
||||
const files = new Map<string, string>([["/home/me/.zshrc", original]]);
|
||||
let wrote = false;
|
||||
|
||||
await expect(
|
||||
installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
shellPath: "/bin/zsh",
|
||||
platform: "darwin",
|
||||
homeDir: "/home/me",
|
||||
readFile: async filePath => files.get(filePath) ?? "",
|
||||
writeFile: async (filePath, content) => {
|
||||
wrote = true;
|
||||
files.set(filePath, content);
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/without a matching/);
|
||||
|
||||
expect(wrote).toBe(false);
|
||||
expect(files.get("/home/me/.zshrc")).toBe(original);
|
||||
});
|
||||
|
||||
it("refuses to shadow the base omp command case-insensitively", async () => {
|
||||
for (const aliasName of ["omp", "OMP"]) {
|
||||
await expect(
|
||||
installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName,
|
||||
shellPath: "/bin/bash",
|
||||
homeDir: "/home/me",
|
||||
}),
|
||||
).rejects.toThrow("Refusing to shadow");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects shell reserved words before rendering alias functions", async () => {
|
||||
for (const { aliasName, shellPath } of [
|
||||
{ aliasName: "if", shellPath: "/bin/bash" },
|
||||
{ aliasName: "end", shellPath: "/opt/homebrew/bin/fish" },
|
||||
{ aliasName: "foreach", shellPath: "pwsh.exe" },
|
||||
]) {
|
||||
await expect(
|
||||
installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName,
|
||||
shellPath,
|
||||
platform: shellPath === "pwsh.exe" ? "win32" : "linux",
|
||||
homeDir: "/home/me",
|
||||
}),
|
||||
).rejects.toThrow("reserved word");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects POSIX sh because it does not read bash config files", async () => {
|
||||
await expect(
|
||||
installProfileAlias({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
shellPath: "/bin/sh",
|
||||
platform: "linux",
|
||||
homeDir: "/home/me",
|
||||
}),
|
||||
).rejects.toThrow('Unsupported shell "sh"');
|
||||
});
|
||||
|
||||
it("treats missing shell config as empty but preserves other read failures", async () => {
|
||||
await expect(
|
||||
readProfileAliasConfigFile("/home/me/.bashrc", async () => {
|
||||
throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
}),
|
||||
).resolves.toBe("");
|
||||
|
||||
await expect(
|
||||
readProfileAliasConfigFile("/home/me/.bashrc", async () => {
|
||||
throw Object.assign(new Error("denied"), { code: "EACCES" });
|
||||
}),
|
||||
).rejects.toThrow("denied");
|
||||
});
|
||||
|
||||
it("validates profile names before rendering shell code", async () => {
|
||||
const files = new Map<string, string>();
|
||||
|
||||
await expect(
|
||||
installProfileAlias({
|
||||
profile: "work'; touch /tmp/pwn; #",
|
||||
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);
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Invalid OMP profile");
|
||||
expect(files.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseArgs } from "../src/cli/args";
|
||||
import { PROFILE_BOOTSTRAP_BOUNDARY_ARG } from "../src/cli/flag-tables";
|
||||
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("does not eat the value of --approval-mode", () => {
|
||||
// `--approval-mode` is a string-valued flag in args.ts (`args[++i]` with
|
||||
// no `-` check). The pre-parser must mirror that contract or
|
||||
// `omp --approval-mode --profile foo` silently activates profile `foo`
|
||||
// instead of letting the launch parser surface the invalid mode value.
|
||||
const result = extractProfileFlags(["--approval-mode", "--profile", "foo", "bar"]);
|
||||
expect(result.profile).toBeUndefined();
|
||||
expect(result.argv).toEqual(["--approval-mode", "--profile", "foo", "bar"]);
|
||||
});
|
||||
|
||||
it("honors extension-shadowed --plan before a global profile", () => {
|
||||
const extracted = extractProfileFlags(["--plan", "--profile", "work", "follow up"]);
|
||||
expect(extracted).toEqual({
|
||||
argv: ["--plan", PROFILE_BOOTSTRAP_BOUNDARY_ARG, "follow up"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
|
||||
const parsed = parseArgs(extracted.argv, new Map([["plan", { type: "boolean" }]]));
|
||||
expect(parsed.unknownFlags.get("plan")).toBe(true);
|
||||
expect(parsed.plan).toBeUndefined();
|
||||
expect(parsed.messages).toEqual(["follow up"]);
|
||||
});
|
||||
|
||||
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 <id>` 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("preserves optional-flag boundaries when stripping a profile before prompt text", () => {
|
||||
const extracted = extractProfileFlags(["--resume", "--profile", "work", "follow up"]);
|
||||
expect(extracted).toEqual({
|
||||
argv: ["--resume", PROFILE_BOOTSTRAP_BOUNDARY_ARG, "follow up"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
|
||||
const parsed = parseArgs(extracted.argv);
|
||||
expect(parsed.resume).toBe(true);
|
||||
expect(parsed.messages).toEqual(["follow up"]);
|
||||
});
|
||||
|
||||
it("preserves extension-flag boundaries when stripping a profile before prompt text", () => {
|
||||
const extracted = extractProfileFlags(["--some-ext-flag", "--profile", "work", "follow up"]);
|
||||
expect(extracted).toEqual({
|
||||
argv: ["--some-ext-flag", PROFILE_BOOTSTRAP_BOUNDARY_ARG, "follow up"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
|
||||
const parsed = parseArgs(extracted.argv, new Map([["some-ext-flag", { type: "string" }]]));
|
||||
expect(parsed.unknownFlags.has("some-ext-flag")).toBe(false);
|
||||
expect(parsed.messages).toEqual(["follow up"]);
|
||||
});
|
||||
|
||||
it("does not consume empty-string resume values before a trailing profile", () => {
|
||||
// Shared OPTIONAL_FLAGS metadata drives the bootstrap too. Empty string is
|
||||
// "no value" for resume/session aliases, so the bootstrap must release it
|
||||
// and still activate the trailing --profile.
|
||||
const result = extractProfileFlags(["--resume", "", "--profile", "work"]);
|
||||
expect(result.argv).toEqual(["--resume", ""]);
|
||||
expect(result.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");
|
||||
});
|
||||
|
||||
it("stops extracting global flags at a subcommand boundary", () => {
|
||||
// `omp grep --profile <path>` must reach the grep subcommand intact; the
|
||||
// bootstrap must not treat `--profile <path>` as a profile selection.
|
||||
const result = extractProfileFlags(["grep", "--profile", "packages/coding-agent/src/cli.ts"]);
|
||||
expect(result.profile).toBeUndefined();
|
||||
expect(result.argv).toEqual(["grep", "--profile", "packages/coding-agent/src/cli.ts"]);
|
||||
});
|
||||
|
||||
it("extracts a global --profile that precedes a subcommand", () => {
|
||||
const result = extractProfileFlags(["--profile", "work", "grep", "foo"]);
|
||||
expect(result.profile).toBe("work");
|
||||
expect(result.argv).toEqual(["grep", "foo"]);
|
||||
});
|
||||
|
||||
it("treats explicit launch as the default command and keeps extracting globals", () => {
|
||||
expect(extractProfileFlags(["launch", "--profile", "work", "--alias", "omp-work"])).toEqual({
|
||||
argv: ["launch"],
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats explicit acp as launch-shaped and keeps extracting globals", () => {
|
||||
expect(extractProfileFlags(["acp", "--profile", "work"])).toEqual({
|
||||
argv: ["acp"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats later subcommand-shaped words as launch text after explicit launch", () => {
|
||||
const result = extractProfileFlags(["launch", "grep", "--profile", "work"]);
|
||||
expect(result.profile).toBe("work");
|
||||
expect(result.argv).toEqual(["launch", "grep"]);
|
||||
});
|
||||
|
||||
it("still extracts --profile after a non-subcommand positional (launch message)", () => {
|
||||
const result = extractProfileFlags(["hello", "--profile", "work"]);
|
||||
expect(result.profile).toBe("work");
|
||||
expect(result.argv).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("continues extracting launch profiles after later subcommand-shaped words", () => {
|
||||
const result = extractProfileFlags(["hello", "grep", "--profile", "work"]);
|
||||
expect(result.profile).toBe("work");
|
||||
expect(result.argv).toEqual(["hello", "grep"]);
|
||||
});
|
||||
|
||||
it("continues extracting launch profiles after launch flags before subcommand-shaped words", () => {
|
||||
const result = extractProfileFlags(["--model", "opus", "grep", "--profile", "work"]);
|
||||
expect(result.profile).toBe("work");
|
||||
expect(result.argv).toEqual(["--model", "opus", "grep"]);
|
||||
});
|
||||
|
||||
it("does not treat a --profile value that names a subcommand as a boundary", () => {
|
||||
const result = extractProfileFlags(["--profile", "config", "later"]);
|
||||
expect(result.profile).toBe("config");
|
||||
expect(result.argv).toEqual(["later"]);
|
||||
});
|
||||
|
||||
it("exempts known value-less launch flags so a trailing profile still activates", () => {
|
||||
// Boolean launch flags (--print, --yolo, --no-tools, -p) take no value, so
|
||||
// the token after them is a fresh argument: `omp --print --profile work`
|
||||
// must still select the profile.
|
||||
expect(extractProfileFlags(["--print", "--profile", "work"])).toEqual({
|
||||
argv: ["--print"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
expect(extractProfileFlags(["--yolo", "--profile", "work"])).toEqual({
|
||||
argv: ["--yolo"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
expect(extractProfileFlags(["--no-tools", "--profile", "work"])).toEqual({
|
||||
argv: ["--no-tools"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
expect(extractProfileFlags(["-p", "--profile", "work"])).toEqual({
|
||||
argv: ["-p"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("protects a value-like successor of an unknown (extension) string flag", () => {
|
||||
// The bootstrap runs before extensions load and cannot know that `--bar`
|
||||
// is a string flag consuming its next token. When the successor is
|
||||
// value-like (does not start with `-`), `parseArgs` consumes it as the
|
||||
// extension flag's value, so the bootstrap forwards it untouched and never
|
||||
// mis-reads it as a global flag — even when it spells a subcommand name.
|
||||
expect(extractProfileFlags(["--bar", "value", "--profile", "work"])).toEqual({
|
||||
argv: ["--bar", "value"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
expect(extractProfileFlags(["--bar", "config"])).toEqual({
|
||||
argv: ["--bar", "config"],
|
||||
profile: undefined,
|
||||
aliasName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not hide a global --profile/--alias behind an unknown flag with a flag-looking successor", () => {
|
||||
// `parseArgs` never hands a flag-looking successor to an extension flag:
|
||||
// boolean extension flags consume nothing, and string extension flags only
|
||||
// consume value-like (non-`-`) successors. So `omp --some-ext-flag --profile
|
||||
// work` must still select profile `work`; the prior bootstrap forwarded
|
||||
// `--profile` as a protected successor and silently fell back to default.
|
||||
expect(extractProfileFlags(["--some-ext-flag", "--profile", "work"])).toEqual({
|
||||
argv: ["--some-ext-flag"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
expect(extractProfileFlags(["--some-ext-flag", "--alias", "omp-work"])).toEqual({
|
||||
argv: ["--some-ext-flag"],
|
||||
profile: undefined,
|
||||
aliasName: "omp-work",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a `--` successor of an unknown flag as end-of-options, not a protected value", () => {
|
||||
// `--` is the parser's end-of-options marker even after a string extension
|
||||
// flag. The bootstrap keeps that single meaning: everything after is
|
||||
// forwarded verbatim, so a `--profile` fenced behind `--` never silently
|
||||
// activates.
|
||||
expect(extractProfileFlags(["--some-ext-flag", "--", "--profile", "work"])).toEqual({
|
||||
argv: ["--some-ext-flag", "--", "--profile", "work"],
|
||||
profile: undefined,
|
||||
aliasName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("still extracts a trailing profile after an unknown flag that carries its own =value", () => {
|
||||
// `--bar=x` carries its value inline, so the following token is a fresh
|
||||
// argument and the trailing --profile is a genuine global flag.
|
||||
expect(extractProfileFlags(["--bar=x", "--profile", "work"])).toEqual({
|
||||
argv: ["--bar=x"],
|
||||
profile: "work",
|
||||
aliasName: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
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 * as url from "node:url";
|
||||
import {
|
||||
__resetProfileSnapshotForTests,
|
||||
APP_NAME,
|
||||
getActiveProfile,
|
||||
getAgentDbPath,
|
||||
getAgentDir,
|
||||
setAgentDir,
|
||||
setProfile,
|
||||
VERSION,
|
||||
} 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";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dir, "..", "..", "..");
|
||||
const cliEntry = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts");
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
describe("global --profile flag", () => {
|
||||
let configDir = "";
|
||||
let originalProfile: string | undefined;
|
||||
let originalAgentDir = "";
|
||||
let originalAgentDirEnv: string | undefined;
|
||||
let originalOmpProfileEnv: string | undefined;
|
||||
let originalPiProfileEnv: string | undefined;
|
||||
let originalConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalProfile = getActiveProfile();
|
||||
originalAgentDir = getAgentDir();
|
||||
originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
|
||||
originalOmpProfileEnv = process.env.OMP_PROFILE;
|
||||
originalPiProfileEnv = process.env.PI_PROFILE;
|
||||
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);
|
||||
}
|
||||
if (originalOmpProfileEnv === undefined) {
|
||||
delete process.env.OMP_PROFILE;
|
||||
} else {
|
||||
process.env.OMP_PROFILE = originalOmpProfileEnv;
|
||||
}
|
||||
if (originalPiProfileEnv === undefined) {
|
||||
delete process.env.PI_PROFILE;
|
||||
} else {
|
||||
process.env.PI_PROFILE = originalPiProfileEnv;
|
||||
}
|
||||
if (originalAgentDirEnv === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv;
|
||||
}
|
||||
__resetProfileSnapshotForTests();
|
||||
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("activates a profile inherited from OMP_PROFILE at run time", async () => {
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
setProfile(undefined);
|
||||
process.env.OMP_PROFILE = "work";
|
||||
delete process.env.PI_PROFILE;
|
||||
|
||||
await runCli(["--version"]);
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(writeSpy).toHaveBeenCalled();
|
||||
expect(getActiveProfile()).toBe("work");
|
||||
expect(getAgentDir()).toBe(path.join(os.homedir(), configDir, "profiles", "work", "agent"));
|
||||
expect(getAgentDbPath()).toBe(path.join(os.homedir(), configDir, "profiles", "work", "agent", "agent.db"));
|
||||
});
|
||||
|
||||
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(
|
||||
expect.objectContaining({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
}),
|
||||
);
|
||||
const output = outSpy.mock.calls.map(call => String(call[0] ?? "")).join("\n");
|
||||
expect(output).toContain("Created omp-work");
|
||||
expect(output).not.toContain(`${APP_NAME}/${VERSION}`);
|
||||
});
|
||||
|
||||
it("installs a shell alias when launch is explicit", 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(["launch", "--profile", "work", "--alias", "omp-work", "--version"]);
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(installSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
}),
|
||||
);
|
||||
const output = outSpy.mock.calls.map(call => String(call[0] ?? "")).join("\n");
|
||||
expect(output).toContain("Created omp-work");
|
||||
expect(output).not.toContain(`${APP_NAME}/${VERSION}`);
|
||||
});
|
||||
|
||||
it("installs a shell alias when acp is explicit", 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(["acp", "--profile", "work", "--alias", "omp-work", "--version"]);
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(installSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profile: "work",
|
||||
aliasName: "omp-work",
|
||||
}),
|
||||
);
|
||||
expect(getActiveProfile()).toBe("work");
|
||||
const output = outSpy.mock.calls.map(call => String(call[0] ?? "")).join("\n");
|
||||
expect(output).toContain("Created omp-work");
|
||||
expect(output).not.toContain(`${APP_NAME}/${VERSION}`);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("loads profile agent .env before command modules import pi-utils env", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-profile-cli-env-"));
|
||||
try {
|
||||
const home = path.join(root, "home");
|
||||
const configDir = ".omp-profile-cli-env";
|
||||
const defaultAgentDir = path.join(home, configDir, "agent");
|
||||
const profileAgentDir = path.join(home, configDir, "profiles", "work", "agent");
|
||||
await fs.mkdir(defaultAgentDir, { recursive: true });
|
||||
await fs.mkdir(profileAgentDir, { recursive: true });
|
||||
await Bun.write(path.join(defaultAgentDir, ".env"), "OMP_PROFILE_BOOTSTRAP_SENTINEL=default\n");
|
||||
await Bun.write(path.join(profileAgentDir, ".env"), "OMP_PROFILE_BOOTSTRAP_SENTINEL=work\n");
|
||||
|
||||
const probePath = path.join(root, "probe.ts");
|
||||
await Bun.write(
|
||||
probePath,
|
||||
[
|
||||
`import { runCli } from ${JSON.stringify(url.pathToFileURL(cliEntry).href)};`,
|
||||
'await runCli(["--profile", "work", "--help"]);',
|
||||
'process.stdout.write("\\nSENTINEL=" + (Bun.env.OMP_PROFILE_BOOTSTRAP_SENTINEL ?? ""));',
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const childEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
PI_CONFIG_DIR: configDir,
|
||||
PI_NO_TITLE: "1",
|
||||
NO_COLOR: "1",
|
||||
};
|
||||
delete childEnv.OMP_PROFILE;
|
||||
delete childEnv.PI_PROFILE;
|
||||
delete childEnv.PI_CODING_AGENT_DIR;
|
||||
delete childEnv.OMP_PROFILE_BOOTSTRAP_SENTINEL;
|
||||
|
||||
const proc = Bun.spawn([process.execPath, probePath], {
|
||||
cwd: repoRoot,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: childEnv,
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readStream(proc.stdout as ReadableStream<Uint8Array>),
|
||||
readStream(proc.stderr as ReadableStream<Uint8Array>),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("SENTINEL=work");
|
||||
expect(stdout).not.toContain("SENTINEL=default");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces an invalid OMP_PROFILE env as a clean error, not an import crash", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-profile-cli-env-bad-"));
|
||||
try {
|
||||
const home = path.join(root, "home");
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
|
||||
const probePath = path.join(root, "probe.ts");
|
||||
await Bun.write(
|
||||
probePath,
|
||||
[
|
||||
`import { runCli } from ${JSON.stringify(url.pathToFileURL(cliEntry).href)};`,
|
||||
'await runCli(["--version"]);',
|
||||
// Reached only if the module import did NOT throw — i.e. the invalid
|
||||
// env was deferred to runCli's error handler instead of crashing the
|
||||
// process during the static import of dirs.ts.
|
||||
'process.stdout.write("HANDLED");',
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const childEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
PI_CONFIG_DIR: ".omp-profile-cli-env-bad",
|
||||
OMP_PROFILE: "..",
|
||||
NO_COLOR: "1",
|
||||
};
|
||||
delete childEnv.PI_PROFILE;
|
||||
delete childEnv.PI_CODING_AGENT_DIR;
|
||||
|
||||
const proc = Bun.spawn([process.execPath, probePath], {
|
||||
cwd: repoRoot,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: childEnv,
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readStream(proc.stdout as ReadableStream<Uint8Array>),
|
||||
readStream(proc.stderr as ReadableStream<Uint8Array>),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(stdout, stderr).toContain("HANDLED");
|
||||
expect(stderr).toContain("Invalid OMP profile");
|
||||
expect(exitCode).toBe(1);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -204,6 +204,6 @@ describe("browser stealth target setup", () => {
|
||||
|
||||
const elapsed = performance.now() - started;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(45);
|
||||
expect(elapsed).toBeLessThan(150);
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
- Fixed `blockRangeAt` (and thus the edit tool's `replace block` / `insert after block` ops) failing on extensionless shell rc/profile files. `Path::extension` returns `None` for both bare (`zshrc`) and dotfile (`.zshrc`, `.bashrc`) forms, so language inference fell through to "unrecognized" and block resolution was permanently unresolvable on those files — an agent retrying the block op would loop on the same error. Known shell rc/profile basenames (`zshrc`/`zshenv`/`zprofile`/`zlogin`/`zlogout`/`bashrc`/`bash_profile`/`bash_login`/`bash_logout`/`bash_aliases`/`profile`/`kshrc`/`mkshrc`/`shrc`, with or without a leading dot) now resolve to the bash grammar.
|
||||
- Fixed native crash-log directory resolution diverging from the JS logger when `PI_CONFIG_DIR` is absolute: the config root now mirrors `path.join(homedir, PI_CONFIG_DIR)` semantics (absolute values re-rooted under `$HOME`, `.`/`..` components normalized), and an empty `PI_CODING_AGENT_DIR` no longer disables XDG state-dir resolution.
|
||||
- Fixed shell-output minimization condensing `pyright`/`basedpyright` `--outputjson` runs into a diagnostics summary; machine-readable JSON output now passes through untouched.
|
||||
- Fixed Linux native builds hard-failing when `RUSTC_WRAPPER=sccache` points at an unavailable shared cache backend. The native build script now retries the `napi` build once without the sccache wrapper after a cache-storage startup failure, so install smoke tests and local fallback builds can proceed while preserving the cached fast path when the backend is healthy.
|
||||
- Fixed `pi-natives` aborting Bun on Windows with `memory allocation of N bytes failed` and no backtrace whenever the native cdylib hit a Rust panic or out-of-memory condition. The release profile uses `panic = "abort"`, so neither default handler emitted any context — Bun received only the bare message and tore down the TUI session before flushing. Module load now installs `std::panic::set_hook` and `std::alloc::set_alloc_error_hook` via `#[napi::module_init]`; both hooks capture `Backtrace::force_capture()` (so it works without `RUST_BACKTRACE=1`) and write a structured report — pid, thread, size/alignment for OOM, source location and message for panics, full backtrace — to the same logs directory the JS logger uses (`$XDG_STATE_HOME/omp/logs/` on Linux/macOS when the user has migrated to XDG and `PI_CODING_AGENT_DIR` isn't customized, otherwise `~/.omp/logs/`) and to stderr before the host process exits. The OOM hook prints the canonical allocation-failure line before any allocation-prone diagnostics and aborts immediately on re-entry, so real process-wide OOM still surfaces the fallback message instead of recursing in the report path ([#2211](https://github.com/can1357/oh-my-pi/issues/2211)).
|
||||
- Fixed cross-line grep being a silent no-op on real files: `multiline` set the `(?m)` flag on the regex matcher but never enabled `multi_line` on the `Searcher`, which stayed line-oriented, so any pattern spanning a `\n` returned zero matches with no error.
|
||||
- Fixed the native `copyToClipboard` leaving the X11 clipboard empty on Linux even while the process kept running. arboard answers clipboard `SelectionRequest`s from a background thread that lives only as long as a `Clipboard` instance exists, and the binding dropped its transient `Clipboard` immediately after `set_text` — tearing that thread down so the selection lost its owner and the clipboard read back empty (matching the `returned ok but clipboard=''` symptom). The Linux path now holds a single `Clipboard` for the lifetime of the process so the owner thread keeps serving, with no `xclip`/`wl-copy` subprocess; macOS/Windows keep the transient write on the calling thread ([#2075](https://github.com/can1357/oh-my-pi/issues/2075)).
|
||||
@@ -49,7 +50,6 @@
|
||||
- Fixed `wrapTextWithAnsi` hanging (infinite loop) on text containing a BEL-terminated string escape — DCS/SOS/PM/APC (`ESC P`/`ESC X`/`ESC ^`/`ESC _`) closed by `BEL` instead of `ST`. `ansi_seq_len_u16` only accepted the `ST` (`ESC \`) terminator for these (OSC already accepted both), so a BEL-terminated APC such as the TUI cursor marker (`ESC _ pi:c BEL`) was left unclassified: it was miscounted as visible width and `break_long_word`'s non-ESC scan could not advance past the `ESC`, spinning forever. The terminator set now matches OSC (ST **or** BEL), and `break_long_word` defensively emits and steps over any escape it cannot classify so a malformed/unknown sequence can never wedge the wrap loop.
|
||||
- Fixed an interactive shell inside a **pipeline** (`zsh -i ... | awk`, `time zsh -i | cat`, etc.) suspending the embedded host with `suspended (tty input)`. The earlier embedded-host fix `setsid`-detached external children so they could not seize the host's controlling tty, but carved pipeline stages out because a later stage that `setpgid`-joined a detached leader failed with EPERM — leaving every pipeline stage in the host session, where an interactive child opened `/dev/tty`, `tcsetpgrp`'d itself to the foreground, and stopped the host (OMP) on its next tty read. `pi_shell` now detaches pipeline stages too: `child_session_action` returns `DetachSession` for any non-terminal-stdin child regardless of pipeline membership, and `execute_external_command` skips `process_group(...)` entirely for detached children so no cross-session `setpgid` is attempted. Pipeline stages no longer share one process group, which the embedded host does not rely on (cancellation walks the descendant tree and pipes are session-independent).
|
||||
- Fixed shell cancellation cleanup failing to reap child processes inside containers whose guest kernel was built without `CONFIG_PROC_CHILDREN` (e.g. some Kata/microVM guests): the Linux descendant walk relied solely on `/proc/<pid>/task/<tid>/children`, which does not exist there, so `children()` / `live_descendants()` returned empty and termination waves never reached the children. It now falls back to scanning `/proc` and grouping by parent pid (the primitive the macOS path already uses) when no `children` file is readable, keeping the cheap per-task fast path on kernels that support it.
|
||||
|
||||
## [15.13.0] - 2026-06-14
|
||||
|
||||
## [15.12.6] - 2026-06-14
|
||||
|
||||
@@ -333,10 +333,32 @@ if (!napiBin) {
|
||||
throw new Error("Could not locate @napi-rs/cli `napi` binary in node_modules/.bin");
|
||||
}
|
||||
|
||||
async function runNapiBuildWithSccacheFallback() {
|
||||
let buildResult = await $`${napiBin} ${napiArgs}`.nothrow();
|
||||
let stderr = buildResult.stderr?.toString("utf-8") ?? "";
|
||||
if (
|
||||
buildResult.exitCode !== 0 &&
|
||||
process.env.RUSTC_WRAPPER === "sccache" &&
|
||||
stderr.includes("sccache: error") &&
|
||||
stderr.includes("cache storage failed")
|
||||
) {
|
||||
const retryEnv = { ...process.env };
|
||||
delete retryEnv.RUSTC_WRAPPER;
|
||||
delete retryEnv.SCCACHE_BUCKET;
|
||||
delete retryEnv.SCCACHE_ENDPOINT;
|
||||
delete retryEnv.SCCACHE_REGION;
|
||||
delete retryEnv.AWS_ACCESS_KEY_ID;
|
||||
delete retryEnv.AWS_SECRET_ACCESS_KEY;
|
||||
console.log("sccache storage unavailable; retrying native build without RUSTC_WRAPPER");
|
||||
buildResult = await $`${napiBin} ${napiArgs}`.env(retryEnv).nothrow();
|
||||
stderr = buildResult.stderr?.toString("utf-8") ?? "";
|
||||
}
|
||||
return { buildResult, stderr };
|
||||
}
|
||||
|
||||
try {
|
||||
const buildResult = await $`${napiBin} ${napiArgs}`.nothrow();
|
||||
const { buildResult, stderr } = await runNapiBuildWithSccacheFallback();
|
||||
if (buildResult.exitCode !== 0) {
|
||||
const stderr = buildResult.stderr?.toString("utf-8") ?? "";
|
||||
throw new Error(`napi build failed${stderr ? `:\n${stderr}` : ""}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added profile-aware directory helpers and isolated profile state roots, while keeping the install ID shared across profiles.
|
||||
|
||||
## [15.12.4] - 2026-06-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed abortable stream wrappers to cancel the source stream on abort, so timeout watchdogs release upstream HTTP bodies instead of only stopping the local reader.
|
||||
|
||||
## [15.12.0] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for a runtime `overrides` map in `RuntimeInstallSpec`, which is now written into generated runtime `package.json` manifests to force dependency pins (including transitive ones) across the runtime tree
|
||||
- Added a lightweight loop-phase breadcrumb stack (`pushLoopPhase`/`popLoopPhase`/`currentLoopPhase`, plus `takeRecentLoopPhase` which returns the live phase or the most recently popped one and clears it) so the TUI event-loop watchdog can attribute a main-thread block to the phase that caused it — including a synchronous phase already popped before the watchdog's delayed tick runs ([#2485](https://github.com/can1357/oh-my-pi/issues/2485))
|
||||
- Added `FetchWithRetryOptions.timeout` (forwarded to the underlying `fetch` call). `false` disables Bun's native ~300s pre-response timeout; a positive number overrides the ceiling. Bare browser/Node fetch ignores it ([#2422](https://github.com/can1357/oh-my-pi/issues/2422))
|
||||
@@ -13,7 +25,7 @@
|
||||
- Added the `path-tree` module (`buildPathTree`, `walkPathTree`, `formatGroupedPaths`, `isUrlLikePath`), moved from the coding agent's grouped file output so compaction file lists can share the same prefix-folded directory-tree rendering; `formatGroupedPaths` gains an optional `annotate` callback for per-file suffixes
|
||||
- Restored `PI_DEBUG_STARTUP` streaming startup markers: `logger.time` now writes a synchronous `[startup] <op>:start` / `:done` / `:fail` stderr line per phase (independent of `PI_TIMING`), so a startup that hangs hard still names the phase it is stuck in — the `PI_TIMING` tree only prints after startup completes and is structurally unable to diagnose a hang. The CLI runner emits `cli:load:<name>` markers around each lazily-imported command module for the same reason.
|
||||
- Added `logger.openSpanPath()`: ops of the currently-open timing-span chain (root → deepest), used by the coding agent's startup watchdog to name the in-flight phase of a stalled startup.
|
||||
- Added `declareWorkerHostEntry()` / `workerHostEntry()` (env): self-dispatching CLI entrypoints declare `Bun.main` as the worker host so worker spawn sites can re-enter the single entry module with `WorkerOptions.argv` selectors across source, npm-bundle, and compiled distributions
|
||||
- Added `declareWorkerHostEntry()` / `workerHostEntry()` in the side-effect-free `worker-host` module (also re-exported from `env`): self-dispatching CLI entrypoints declare `Bun.main` as the worker host so worker spawn sites can re-enter the single entry module with `WorkerOptions.argv` selectors across source, npm-bundle, and compiled distributions
|
||||
- Added `getAuthBrokerSnapshotCachePath()` with `OMP_AUTH_BROKER_SNAPSHOT_CACHE` override support for isolating the encrypted broker snapshot cache.
|
||||
- Added color helpers `colorLuma` (perceptual luma), `relativeLuminance` (WCAG, linearized sRGB), and `hslToHex` to the color utilities. The luminance helpers parse `#rgb`/`#rrggbb` hex and 256-color palette indices, returning `undefined` for unparseable values.
|
||||
- Added `peekFileEnds`, a single-open head-and-tail file peek helper that reuses the head bytes for the tail when the file fits the head window.
|
||||
|
||||
@@ -15,7 +15,7 @@ Shared utilities for [oh-my-pi](https://github.com/can1357/oh-my-pi) packages. Z
|
||||
| `which` | `$which()` binary lookup with caching |
|
||||
| `fetch-retry` | `fetch` with retry/backoff policies |
|
||||
| `fs-error` | Errno guards (`isEnoent` and friends) |
|
||||
| `env` | Environment plumbing, worker-host entry contract (`workerHostEntry`) |
|
||||
| `env` / `worker-host` | Environment plumbing and side-effect-free worker-host entry contract (`workerHostEntry`) |
|
||||
| `abortable` / `async` | AbortSignal-aware stream/promise helpers |
|
||||
| `peek-file` | Read the first N bytes of a file with pooled buffers |
|
||||
| `frontmatter`, `glob`, `mime`, `temp`, `format`, `color`, `snowflake`, `tab-spacing`, `path-tree`, `sanitize-text` | Smaller single-purpose helpers |
|
||||
|
||||
+240
-15
@@ -28,6 +28,102 @@ 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-z0-9][a-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.<anything>` 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 === ".." ||
|
||||
normalized.endsWith(".") ||
|
||||
!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 "..", cannot end with ".", 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active profile from the two profile env vars. `OMP_PROFILE` is the
|
||||
* canonical variable and takes precedence; `PI_PROFILE` is the legacy
|
||||
* compatibility fallback, consulted only when `OMP_PROFILE` is undefined. An
|
||||
* explicitly-empty `OMP_PROFILE` therefore selects the default profile rather
|
||||
* than silently inheriting `PI_PROFILE`. Delegates validation/normalization to
|
||||
* {@link normalizeProfileName} (which throws on a syntactically invalid value).
|
||||
*/
|
||||
export function resolveProfileEnv(omp: string | undefined, pi: string | undefined): string | undefined {
|
||||
return normalizeProfileName(omp !== undefined ? omp : pi);
|
||||
}
|
||||
|
||||
function getProfileFromEnv(): string | undefined {
|
||||
return resolveProfileEnv(process.env.OMP_PROFILE, process.env.PI_PROFILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-load profile resolution. Unlike {@link getProfileFromEnv}, an invalid
|
||||
* OMP_PROFILE/PI_PROFILE value does NOT throw here — a bad env var must not
|
||||
* crash a bare `import` of this module with an uncaught stack trace before the
|
||||
* CLI's error handling is in scope. The default profile is used instead; the
|
||||
* CLI re-validates the env (see `runCli` in coding-agent/src/cli.ts) so the
|
||||
* user still gets a clean "Invalid OMP profile" message.
|
||||
*/
|
||||
function readProfileFromEnvSafe(): string | undefined {
|
||||
try {
|
||||
return getProfileFromEnv();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function readPiProfileFromEnvSafe(): string | undefined {
|
||||
try {
|
||||
return normalizeProfileName(process.env.PI_PROFILE);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getProfileAgentDir(profile: string): string {
|
||||
return path.join(getProfileConfigRoot(profile), "agent");
|
||||
}
|
||||
|
||||
function isProfileDerivedAgentDir(profile: string | undefined, agentDirEnv: string | undefined): boolean {
|
||||
return profile !== undefined && agentDirEnv === getProfileAgentDir(profile);
|
||||
}
|
||||
// =============================================================================
|
||||
// Project directory
|
||||
// =============================================================================
|
||||
@@ -96,7 +192,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 +220,47 @@ class DirResolver {
|
||||
readonly #rootCache = new Map<string, string>();
|
||||
readonly #agentCache = new Map<string, string>();
|
||||
|
||||
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/<name>` on first activation
|
||||
// (when no $XDG_*_HOME/omp exists yet) and then silently move to
|
||||
// `$XDG_*_HOME/omp/profiles/<name>` 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 +305,49 @@ class DirResolver {
|
||||
}
|
||||
}
|
||||
|
||||
let dirs = new DirResolver(process.env.PI_CODING_AGENT_DIR);
|
||||
/**
|
||||
* Decide which `PI_CODING_AGENT_DIR` value to capture as the pre-profile
|
||||
* baseline. A value equal to a profile's derived agent dir is profile-derived
|
||||
* (propagated by a parent's `setProfile`), so it must NOT be snapshotted as the
|
||||
* default-mode baseline — otherwise default mode would resolve to the profile's
|
||||
* agent dir. The profile source can be the active profile or a lower-priority
|
||||
* `PI_PROFILE` that was bypassed because `OMP_PROFILE` explicitly selected the
|
||||
* default profile. Returns `undefined` in those cases so reset falls back to the
|
||||
* standard `~/.omp/agent`.
|
||||
*/
|
||||
function resolvePreProfileAgentDir(
|
||||
profile: string | undefined,
|
||||
agentDirEnv: string | undefined,
|
||||
profileAgentDirSource: string | undefined = profile,
|
||||
): string | undefined {
|
||||
return isProfileDerivedAgentDir(profile ?? profileAgentDirSource, agentDirEnv) ? undefined : agentDirEnv;
|
||||
}
|
||||
|
||||
let activeProfile = readProfileFromEnvSafe();
|
||||
const moduleLoadProfileAgentDirSource = activeProfile ?? readPiProfileFromEnvSafe();
|
||||
const moduleLoadAgentDirOverride = activeProfile
|
||||
? undefined
|
||||
: resolvePreProfileAgentDir(undefined, process.env.PI_CODING_AGENT_DIR, moduleLoadProfileAgentDirSource);
|
||||
let dirs = new DirResolver({
|
||||
agentDirOverride: moduleLoadAgentDirOverride,
|
||||
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 — ignoring a profile-derived value
|
||||
* inherited from a parent's `setProfile` (see {@link resolvePreProfileAgentDir})
|
||||
* — and refreshed on `setAgentDir`, since that call is the user explicitly
|
||||
* redefining the baseline.
|
||||
*/
|
||||
let preProfileAgentDirEnv: string | undefined = resolvePreProfileAgentDir(
|
||||
activeProfile,
|
||||
process.env.PI_CODING_AGENT_DIR,
|
||||
moduleLoadProfileAgentDirSource,
|
||||
);
|
||||
// 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 +365,74 @@ 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 = resolvePreProfileAgentDir(
|
||||
activeProfile,
|
||||
process.env.PI_CODING_AGENT_DIR,
|
||||
activeProfile ?? readPiProfileFromEnvSafe(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 = resolvePreProfileAgentDir(
|
||||
undefined,
|
||||
process.env.PI_CODING_AGENT_DIR,
|
||||
readPiProfileFromEnvSafe(),
|
||||
);
|
||||
}
|
||||
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;
|
||||
@@ -534,10 +754,15 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
* winner's id). Survives independently of agent state: deleting
|
||||
* `~/.omp/agent/` does not regenerate it. Server-side dedup for grievance
|
||||
* pushes (and similar telemetry) keys on this id.
|
||||
*
|
||||
* Anchored to the base config root (`~/.omp/install-id`) regardless of the
|
||||
* active profile: install identity is per-install, not per-profile, so every
|
||||
* profile shares one id and the global cache stays correct no matter the
|
||||
* profile / `getInstallId` call order.
|
||||
*/
|
||||
export function getInstallId(): string {
|
||||
if (cachedInstallId) return cachedInstallId;
|
||||
const filePath = path.join(getConfigRootDir(), INSTALL_ID_FILE);
|
||||
const filePath = path.join(getBaseConfigRoot(), INSTALL_ID_FILE);
|
||||
|
||||
let observedInvalid = false;
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir, getConfigRootDir } from "./dirs";
|
||||
|
||||
export * from "./worker-host";
|
||||
|
||||
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
/**
|
||||
@@ -172,26 +174,6 @@ export function isCompiledBinary(): boolean {
|
||||
return url.includes("$bunfs") || url.includes("~BUN") || url.includes("%7EBUN");
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-module path declared by self-dispatching CLI entrypoints — entries
|
||||
* whose top-level argv handling routes hidden `__omp_*` worker selectors.
|
||||
* Worker spawn sites re-enter this module via `new Worker(entry, { argv })`,
|
||||
* so every distribution (source, npm bundle, compiled binary) needs exactly
|
||||
* one JavaScript entrypoint. Never set under `bun test`, SDK embedding, or
|
||||
* standalone package bins — those hosts load worker modules directly.
|
||||
*/
|
||||
let workerHostMain: string | null = null;
|
||||
|
||||
/** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
|
||||
export function declareWorkerHostEntry(): void {
|
||||
workerHostMain = Bun.main;
|
||||
}
|
||||
|
||||
/** Main-module path of the self-dispatching CLI host, or null outside it. */
|
||||
export function workerHostEntry(): string | null {
|
||||
return workerHostMain;
|
||||
}
|
||||
|
||||
const TRUTHY: Dict<boolean> = {
|
||||
"1": true,
|
||||
Y: true,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Main-module path declared by self-dispatching CLI entrypoints — entries
|
||||
* whose top-level argv handling routes hidden `__omp_*` worker selectors.
|
||||
* Worker spawn sites re-enter this module via `new Worker(entry, { argv })`,
|
||||
* so every distribution (source, npm bundle, compiled binary) needs exactly
|
||||
* one JavaScript entrypoint. Never set under `bun test`, SDK embedding, or
|
||||
* standalone package bins — those hosts load worker modules directly.
|
||||
*/
|
||||
let workerHostMain: string | null = null;
|
||||
|
||||
/** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
|
||||
export function declareWorkerHostEntry(): void {
|
||||
workerHostMain = Bun.main;
|
||||
}
|
||||
|
||||
/** Main-module path of the self-dispatching CLI host, or null outside it. */
|
||||
export function workerHostEntry(): string | null {
|
||||
return workerHostMain;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getConfigRootDir,
|
||||
getInstallId,
|
||||
setAgentDir,
|
||||
setProfile,
|
||||
} from "@oh-my-pi/pi-utils/dirs";
|
||||
import { Snowflake } from "@oh-my-pi/pi-utils/snowflake";
|
||||
|
||||
@@ -75,4 +76,24 @@ describe("getInstallId", () => {
|
||||
const onDisk = (await fs.readFile(path.join(getConfigRootDir(), "install-id"), "utf8")).trim();
|
||||
expect(onDisk).toBe(id);
|
||||
});
|
||||
|
||||
it("anchors the install id to the base config root regardless of active profile", async () => {
|
||||
// Default mode creates the id under the base config root.
|
||||
const baseId = getInstallId();
|
||||
const baseFile = path.join(getConfigRootDir(), "install-id");
|
||||
expect((await fs.readFile(baseFile, "utf8")).trim()).toBe(baseId);
|
||||
|
||||
// Activating a profile must not relocate the id or mint a new one: install
|
||||
// identity is per-install, and the global cache must stay correct.
|
||||
__resetInstallIdCacheForTests();
|
||||
setProfile("work");
|
||||
try {
|
||||
const profileRoot = getConfigRootDir();
|
||||
expect(profileRoot).not.toBe(path.dirname(baseFile));
|
||||
expect(getInstallId()).toBe(baseId);
|
||||
expect(await Bun.file(path.join(profileRoot, "install-id")).exists()).toBe(false);
|
||||
} finally {
|
||||
setProfile(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
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 * as url from "node:url";
|
||||
import {
|
||||
__resetProfileSnapshotForTests,
|
||||
getActiveProfile,
|
||||
getAgentDbPath,
|
||||
getAgentDir,
|
||||
getConfigAgentDirName,
|
||||
getConfigRootDir,
|
||||
getPythonGatewayDir,
|
||||
getSessionsDir,
|
||||
getStatsDbPath,
|
||||
normalizeProfileName,
|
||||
resolveProfileEnv,
|
||||
setAgentDir,
|
||||
setProfile,
|
||||
} from "@oh-my-pi/pi-utils/dirs";
|
||||
import { Snowflake } from "@oh-my-pi/pi-utils/snowflake";
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return text + decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
describe("profile directories", () => {
|
||||
let tempRoot = "";
|
||||
let configDir = "";
|
||||
let originalAgentDir = "";
|
||||
let originalProfile: string | undefined;
|
||||
let originalAgentDirEnv: string | undefined;
|
||||
let originalOmpProfileEnv: string | undefined;
|
||||
let originalPiProfileEnv: 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;
|
||||
originalOmpProfileEnv = process.env.OMP_PROFILE;
|
||||
originalPiProfileEnv = process.env.PI_PROFILE;
|
||||
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);
|
||||
}
|
||||
if (originalOmpProfileEnv === undefined) {
|
||||
delete process.env.OMP_PROFILE;
|
||||
} else {
|
||||
process.env.OMP_PROFILE = originalOmpProfileEnv;
|
||||
}
|
||||
if (originalPiProfileEnv === undefined) {
|
||||
delete process.env.PI_PROFILE;
|
||||
} else {
|
||||
process.env.PI_PROFILE = originalPiProfileEnv;
|
||||
}
|
||||
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,
|
||||
// so the profile location stays 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 ~/<config-dir>/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("rejects trailing-dot profile names to avoid Windows path collisions", () => {
|
||||
for (const name of ["work.", "work.."]) {
|
||||
expect(() => setProfile(name)).toThrow("cannot end with");
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not restore a profile-derived agent dir as the default baseline", () => {
|
||||
// Reproduces a child process that inherited OMP_PROFILE=work plus the
|
||||
// profile-derived PI_CODING_AGENT_DIR that setProfile propagates to
|
||||
// children. The module-load snapshot must not capture that profile dir as
|
||||
// the default baseline, or setProfile(undefined) would resolve default
|
||||
// mode into the work profile's agent dir.
|
||||
setProfile("work");
|
||||
const workAgentDir = path.join(os.homedir(), configDir, "profiles", "work", "agent");
|
||||
expect(getAgentDir()).toBe(workAgentDir);
|
||||
expect(process.env.PI_CODING_AGENT_DIR).toBe(workAgentDir);
|
||||
|
||||
// Re-snapshot exactly as module load would, now that OMP_PROFILE and the
|
||||
// profile-derived PI_CODING_AGENT_DIR are present in the environment.
|
||||
__resetProfileSnapshotForTests();
|
||||
|
||||
setProfile(undefined);
|
||||
expect(getActiveProfile()).toBeUndefined();
|
||||
expect(process.env.PI_CODING_AGENT_DIR).toBeUndefined();
|
||||
expect(getAgentDir()).toBe(path.join(os.homedir(), configDir, "agent"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile env + name validation", () => {
|
||||
it("honors OMP_PROFILE precedence and treats empty/default as the default profile", () => {
|
||||
// OMP_PROFILE is canonical and wins over the legacy PI_PROFILE fallback.
|
||||
expect(resolveProfileEnv("work", "other")).toBe("work");
|
||||
// PI_PROFILE is consulted only when OMP_PROFILE is undefined.
|
||||
expect(resolveProfileEnv(undefined, "work")).toBe("work");
|
||||
// An explicitly-empty OMP_PROFILE selects the default profile; it must NOT
|
||||
// fall through to the lower-precedence PI_PROFILE.
|
||||
expect(resolveProfileEnv("", "work")).toBeUndefined();
|
||||
expect(resolveProfileEnv(" ", "work")).toBeUndefined();
|
||||
expect(resolveProfileEnv("default", "work")).toBeUndefined();
|
||||
expect(resolveProfileEnv(undefined, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects uppercase profile names so isolation is filesystem-independent", () => {
|
||||
// `work` and `WORK` would collide on case-insensitive macOS/Windows but
|
||||
// differ on Linux; reject uppercase to keep profile identity stable.
|
||||
expect(() => normalizeProfileName("WORK")).toThrow("Invalid OMP profile");
|
||||
expect(() => normalizeProfileName("Work")).toThrow("Invalid OMP profile");
|
||||
expect(normalizeProfileName("work")).toBe("work");
|
||||
expect(normalizeProfileName("work-2.0_a")).toBe("work-2.0_a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirs module import behavior", () => {
|
||||
it("does not scrub inherited macOS malloc logging env variables on import", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-utils-dirs-import-"));
|
||||
try {
|
||||
const probePath = path.join(root, "probe.ts");
|
||||
const dirsUrl = url.pathToFileURL(path.join(import.meta.dir, "..", "src", "dirs.ts")).href;
|
||||
await Bun.write(
|
||||
probePath,
|
||||
[
|
||||
`import ${JSON.stringify(dirsUrl)};`,
|
||||
"process.stdout.write(JSON.stringify({",
|
||||
" malloc: process.env.MallocStackLogging,",
|
||||
" compact: process.env.MallocStackLoggingNoCompact,",
|
||||
"}));",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const childEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
MallocStackLogging: "0",
|
||||
MallocStackLoggingNoCompact: "0",
|
||||
};
|
||||
const proc = Bun.spawn([process.execPath, probePath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: childEnv,
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readStream(proc.stdout as ReadableStream<Uint8Array>),
|
||||
readStream(proc.stderr as ReadableStream<Uint8Array>),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
malloc: "0",
|
||||
compact: "0",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("exposes worker-host without loading agent env", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-utils-worker-host-import-"));
|
||||
try {
|
||||
const workerHostUrl = import.meta.resolve("@oh-my-pi/pi-utils/worker-host");
|
||||
const agentDir = path.join(root, "agent");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await Bun.write(path.join(agentDir, ".env"), "OMP_WORKER_HOST_PROBE=from-agent-env\n");
|
||||
const probePath = path.join(root, "probe.ts");
|
||||
await Bun.write(
|
||||
probePath,
|
||||
[
|
||||
`import { declareWorkerHostEntry, workerHostEntry } from ${JSON.stringify(workerHostUrl)};`,
|
||||
"declareWorkerHostEntry();",
|
||||
"process.stdout.write(JSON.stringify({",
|
||||
" envProbe: process.env.OMP_WORKER_HOST_PROBE ?? null,",
|
||||
" hostDeclared: workerHostEntry() === Bun.main,",
|
||||
"}));",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const childEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
};
|
||||
delete childEnv.OMP_WORKER_HOST_PROBE;
|
||||
const proc = Bun.spawn([process.execPath, probePath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: childEnv,
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readStream(proc.stdout as ReadableStream<Uint8Array>),
|
||||
readStream(proc.stderr as ReadableStream<Uint8Array>),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
envProbe: null,
|
||||
hostDeclared: true,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores inherited profile agent dir when OMP_PROFILE explicitly selects default", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-utils-dirs-default-profile-"));
|
||||
const probeConfigDir = `.omp-default-profile-${Snowflake.next()}`;
|
||||
try {
|
||||
const dirsUrl = url.pathToFileURL(path.join(import.meta.dir, "..", "src", "dirs.ts")).href;
|
||||
const workAgentDir = path.join(os.homedir(), probeConfigDir, "profiles", "work", "agent");
|
||||
const defaultAgentDir = path.join(os.homedir(), probeConfigDir, "agent");
|
||||
|
||||
for (const ompProfile of ["", "default"]) {
|
||||
const probePath = path.join(root, `default-profile-${ompProfile || "empty"}.ts`);
|
||||
await Bun.write(
|
||||
probePath,
|
||||
[
|
||||
`import { getActiveProfile, getAgentDir } from ${JSON.stringify(dirsUrl)};`,
|
||||
"process.stdout.write(JSON.stringify({",
|
||||
" activeProfile: getActiveProfile() ?? null,",
|
||||
" agentDir: getAgentDir(),",
|
||||
"}));",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const childEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
PI_CONFIG_DIR: probeConfigDir,
|
||||
OMP_PROFILE: ompProfile,
|
||||
PI_PROFILE: "work",
|
||||
PI_CODING_AGENT_DIR: workAgentDir,
|
||||
};
|
||||
const proc = Bun.spawn([process.execPath, probePath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: childEnv,
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
readStream(proc.stdout as ReadableStream<Uint8Array>),
|
||||
readStream(proc.stderr as ReadableStream<Uint8Array>),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
activeProfile: null,
|
||||
agentDir: defaultAgentDir,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rm(path.join(os.homedir(), probeConfigDir), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user