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:
@@ -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:
|
||||
|
||||
|
||||
+15
-2
@@ -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}$`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user