fix(coding-agent): profile-scope native config discovery and load symlinked extension dirs

Native user-level config discovery (MCP, skills, rules, slash commands, prompts, instructions, hooks, tools, settings, extensions, and the top-level SYSTEM.md/RULES.md/AGENTS.md) now resolves the user scope through getAgentDir() in builtin.ts, omp-extension-roots.ts, and the discovery-layer getUserPath() helper. A named profile sees only its own ~/.omp/profiles/<name>/agent config instead of the default profile's ~/.omp/agent leaking into every profile, matching the /mcp config writer and getMCPConfigPath("user").

discoverExtensionModulePaths now detects top-level symlinked directories that the native glob skips (follow_links=false) and synthesizes their index/package.json entry-point matches, so an extension shared across profiles via a symlink loads like a real directory. Symlinked extension files were already handled.

cli: check --tiny-worker on the profile-flag-stripped resolvedArgv, matching the adjacent --smoke-test check and launch routing.
This commit is contained in:
Ogrodev
2026-06-04 15:07:55 -03:00
parent 93d9af2a99
commit b99039ded2
18 changed files with 417 additions and 36 deletions
+8
View File
@@ -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.
+1 -1
View File
@@ -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:
+15 -2
View File
@@ -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}$`.
+6
View File
@@ -5,6 +5,12 @@
### Added
- Added isolated profile support via `--profile <name>` / `OMP_PROFILE` and shell alias bootstrap via `--alias <command>`, including launch/ACP bootstrap handling and extension-flag-safe parsing.
### Fixed
- Made native user-level config discovery follow the active profile. Skills, rules, slash commands, prompts, instructions, hooks, tools, settings, extensions, MCP servers, and the top-level `SYSTEM.md`/`RULES.md`/`AGENTS.md` now resolve the user scope through `getAgentDir()`, so a named profile sees only its own `~/.omp/profiles/<name>/agent` config instead of the default profile's `~/.omp/agent` leaking into every profile. This matches the `/mcp` config writer and `getMCPConfigPath("user")`.
- Fixed symlinked extension directories being skipped by native auto-discovery. The glob walker runs with `follow_links=false`, so a symlinked directory under `extensions/` was yielded as a symlink but never descended into — its `index.{ts,js}`/`package.json` stayed invisible while real directories loaded normally. `discoverExtensionModulePaths` now detects top-level symlinked directories and resolves their entry points, so an extension shared across profiles via a symlink loads like a real directory (symlinked extension *files* were already handled).
## [15.9.0] - 2026-06-04
### Breaking Changes
+1 -1
View File
@@ -154,7 +154,7 @@ export async function runCli(argv: string[]): Promise<void> {
await runSmokeTest();
return;
}
if (argv[0] === "--tiny-worker") {
if (resolvedArgv[0] === "--tiny-worker") {
await runTinyWorker();
return;
}
+4 -8
View File
@@ -18,8 +18,7 @@
* 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. There is no inline `args[++i]` chain in `args.ts` left to
* drift out of sync with the bootstrap.
* 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
@@ -65,11 +64,10 @@ export type OptionalSetter = (result: Args, value: string | undefined) => void;
*
* 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 that previously lived inline in `args.ts`:
* per-flag quirks:
*
* - `rejectEmpty`: treat `""` like “no value provided”. Needed for
* `--resume` / `-r` / `--session`, which historically used a truthiness
* check (`next && !next.startsWith("-")`). Without this, an empty string
* `--resume` / `-r` / `--session`. Without it, an empty string
* gets consumed as the session prefix and downstream resolution can match
* every session.
* - `rejectAtPrefix`: reject `@foo` as a value. Used only by
@@ -93,9 +91,7 @@ const setResume: OptionalSetter = (result, value) => {
/**
* Setters for flags that ALWAYS consume the next argv token, even when that
* token starts with `-`. Mirrors the
* `arg === "--xxx" && i + 1 < args.length ? args[++i]` pattern in the old
* `parseArgs`.
* token starts with `-`.
*/
export const STRING_SETTERS: Record<string, StringSetter> = {
"--mode": (result, value) => {
@@ -14,7 +14,7 @@
* consume the next token only when it doesn't look like another flag. Without
* this, `omp --system-prompt --profile foo` silently activates profile `foo`
* instead of passing the literal `--profile` to the system prompt and `foo`
* as a positional message (issue raised by code review).
* 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
+13 -8
View File
@@ -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 { registerProvider } from "../capability";
import { type ContextFile, contextFileCapability } from "../capability/context-file";
@@ -60,7 +60,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" });
}
@@ -186,11 +188,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(
@@ -225,7 +230,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({
@@ -276,7 +281,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,
@@ -351,7 +356,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);
@@ -868,7 +873,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);
@@ -526,6 +532,25 @@ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: strin
globIf(dir, "*/package.json", FileType.File, false),
]);
// 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;
@@ -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(),
};
}
@@ -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"),
);
});
});
@@ -241,6 +241,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);
@@ -24,7 +24,7 @@ describe("extractProfileFlags", () => {
expect(result.profile).toBeUndefined();
expect(result.argv).toEqual(["--system-prompt", "--profile", "foo", "bar"]);
});
it("does not eat the value of --approval-mode (regression: PR #1435 review)", () => {
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`
@@ -193,7 +193,7 @@ describe("extractProfileFlags", () => {
});
});
it("does not hide a global --profile/--alias behind an unknown flag with a flag-looking successor (regression: PR #1435 review)", () => {
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
+2 -4
View File
@@ -146,10 +146,8 @@ describe("profile directories", () => {
process.env.XDG_DATA_HOME = path.join(tempRoot, "data");
process.env.XDG_STATE_HOME = path.join(tempRoot, "state");
process.env.XDG_CACHE_HOME = path.join(tempRoot, "cache");
// Named profiles only adopt XDG when their *own* XDG path already exists.
// Mkdir'ing only the base app root used to be enough (bug); the resolver
// now requires the profile-specific path so the profile location is stable
// across activations.
// 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 });