diff --git a/docs/extension-loading.md b/docs/extension-loading.md index 3ed7eb08d..67748d620 100644 --- a/docs/extension-loading.md +++ b/docs/extension-loading.md @@ -98,8 +98,19 @@ extensions: Behavior split: -- SDK: when `disableExtensionDiscovery=true`, it still loads `additionalExtensionPaths` via `loadExtensions()`. -- CLI path building (`main.ts`) currently clears CLI extension paths when `--no-extensions` is set, so explicit `-e/--hook` are not forwarded in that mode. +- SDK: when `disableExtensionDiscovery=true`, ambient extension factories are + excluded, while `additionalExtensionPaths` are still resolved normally + (including package directories with `package.json#omp.extensions`). +- CLI: `--no-extensions` follows the same explicit-only contract. Explicit + `-e/--extension` and `--hook` paths still load, and only sibling capability + roots from explicitly named extension packages remain eligible. Project/user + `extensions:` settings and installed OMP extension packages are excluded from + that sibling surface. + +This flag governs extension factories and OMP extension-package sibling roots; +it is not a whole-process capability-isolation switch. Skills, MCP servers, +tools, prompts, and rules owned by other discovery subsystems retain their own +enable/disable controls. ### Disable specific extension modules diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1b632e9c5..2f71bce42 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -681,6 +681,11 @@ - Fixed the Cursor-backed advisor losing entire turns when it selected server-native tools (`bash`, `grep`, etc.) outside its grant: exec-resolved native blocks are already rejected in-band by the advisor-scoped bridge, so they no longer trip the unavailable-tool quarantine and discard the `advise` emitted in the same turn ([#5900](https://github.com/can1357/oh-my-pi/issues/5900)). - Fixed custom `anthropic-messages` OAuth providers being unable to opt into configured Claude Code fingerprint header overrides. ([#5888](https://github.com/can1357/oh-my-pi/issues/5888)) - Fixed authoritative providers (e.g. `openai-codex`) keeping unsupported bundled models selectable when a fresh model cache and an expired OAuth token coincided: built-in discovery now forces the OAuth refresh so the provider's model manager is constructed and prunes stale bundled entries (e.g. `gpt-5.4-nano`) instead of waiting out the cache TTL. ([#5364](https://github.com/can1357/oh-my-pi/issues/5364)) +### Fixed + +- Preserved explicit `-e`/`--extension` and `--hook` packages under + `--no-extensions` while excluding ambient extension factories and sibling + capabilities from settings or installed OMP packages. ## [17.0.5] - 2026-07-18 diff --git a/packages/coding-agent/src/cli/models-cli.ts b/packages/coding-agent/src/cli/models-cli.ts index 2928f5bfb..eb9ba643d 100644 --- a/packages/coding-agent/src/cli/models-cli.ts +++ b/packages/coding-agent/src/cli/models-cli.ts @@ -17,12 +17,7 @@ import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; import { ModelRegistry } from "../config/model-registry"; import { Settings } from "../config/settings"; -import { - discoverAndLoadExtensions, - ExtensionRunner, - emitSessionShutdownEvent, - loadExtensions, -} from "../extensibility/extensions"; +import { discoverAndLoadExtensions, ExtensionRunner, emitSessionShutdownEvent } from "../extensibility/extensions"; import { discoverAuthStorage } from "../sdk"; import { SessionManager } from "../session/session-manager"; import { EventBus } from "../utils/event-bus"; @@ -283,7 +278,7 @@ export interface RunModelsListingOptions { settingsExtensions?: string[]; /** Disabled extension ids from settings (`disabledExtensions`). */ disabledExtensionIds?: string[]; - /** When true, skip discovery and only load `additionalExtensionPaths`. */ + /** When true, exclude ambient factories and resolve only `additionalExtensionPaths`. */ disableExtensionDiscovery?: boolean; } @@ -301,14 +296,16 @@ export async function runModelsListing(options: RunModelsListingOptions): Promis } = options; const eventBus = new EventBus(); - const extensionsResult = disableExtensionDiscovery - ? await loadExtensions(additionalExtensionPaths, cwd, eventBus) - : await discoverAndLoadExtensions( - [...additionalExtensionPaths, ...settingsExtensions], - cwd, - eventBus, - disabledExtensionIds, - ); + const configuredPaths = disableExtensionDiscovery + ? additionalExtensionPaths + : [...additionalExtensionPaths, ...settingsExtensions]; + const extensionsResult = await discoverAndLoadExtensions( + configuredPaths, + cwd, + eventBus, + disableExtensionDiscovery ? undefined : disabledExtensionIds, + { ambient: !disableExtensionDiscovery }, + ); const extensionRunner = extensionsResult.extensions.length > 0 ? new ExtensionRunner( @@ -370,7 +367,7 @@ export async function runModelsCommand(command: ModelsCommandArgs): Promise { const tilde = expandTilde(raw, home); @@ -68,6 +87,7 @@ export function injectOmpExtensionCliRoots(paths: readonly string[], home: strin /** Drop every CLI-injected root. Tests use this between cases. */ export function clearOmpExtensionCliRoots(): void { injectedCliRoots = []; + injectedCliRootMode = "merge"; } /** Inspect currently-injected CLI roots (read-only). Exposed for diagnostics + tests. */ @@ -134,19 +154,21 @@ async function isDirectory(p: string): Promise { * other sources still surface. */ export async function listOmpExtensionRoots(ctx: LoadContext): Promise { - const { project, user } = scopeDirs(ctx); - const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([ - readSettingsExtensions(path.join(project, "settings.json")), - readSettingsExtensions(path.join(user, "settings.json")), - listInstalledPluginRoots(ctx), - ]); - - const candidates: InjectedRoot[] = [ - ...injectedCliRoots, - ...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })), - ...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })), - ...installedPlugins, - ]; + let candidates: InjectedRoot[] = [...injectedCliRoots]; + if (injectedCliRootMode === "merge") { + const { project, user } = scopeDirs(ctx); + const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([ + readSettingsExtensions(path.join(project, "settings.json")), + readSettingsExtensions(path.join(user, "settings.json")), + listInstalledPluginRoots(ctx), + ]); + candidates = [ + ...candidates, + ...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })), + ...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })), + ...installedPlugins, + ]; + } // First-seen-wins dedup preserves CLI > project-settings > user-settings > installed precedence. const seen = new Set(); diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index 35d978110..36c0f1005 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -522,10 +522,16 @@ async function discoverExtensionsInDir(dir: string): Promise { * `LoadExtensionsResult` directly would reuse handlers/tools/commands that * closed over the parent's `cwd` and event bus. */ +export interface DiscoverExtensionPathOptions { + /** Include ambient native extensions, hooks, and installed plugins. */ + ambient?: boolean; +} + export async function discoverExtensionPaths( configuredPaths: string[], cwd: string, disabledExtensionIds?: string[], + options: DiscoverExtensionPathOptions = {}, ): Promise { const allPaths: string[] = []; const seen = new Set(); @@ -549,33 +555,41 @@ export async function discoverExtensionPaths( } }; - // 1. Discover extension modules via capability API (native .omp/.pi only). - // Scope the load to the native provider — the extension-module capability - // also has claude/codex/gemini/opencode providers, and their items were - // discarded here anyway (see #4198). The provider filter skips the walk - // entirely instead of running four foreign directory scans and dropping - // the results. - const discovered = await loadCapability(extensionModuleCapability.id, { - ...loadOptions, - providers: ["native"], - }); - for (const ext of discovered.items) { - addPath(ext.path); + const ambient = options.ambient !== false; + if (ambient) { + // 1. Discover extension modules via capability API (native .omp/.pi only). + // Scope the load to the native provider — the extension-module capability + // also has claude/codex/gemini/opencode providers, and their items were + // discarded here anyway (see #4198). The provider filter skips the walk + // entirely instead of running four foreign directory scans and dropping + // the results. + const discovered = await loadCapability(extensionModuleCapability.id, { + ...loadOptions, + providers: ["native"], + }); + for (const ext of discovered.items) { + addPath(ext.path); + } } - // 2. Discover JS/TS hook factories from hookCapability and bind them through - // the extension runner, which owns the current runtime event bus. Hook - // capability loading already applies hook-specific disabled ids; do not also - // filter them through extension-module names. - const hooks = await loadCapability(hookCapability.id, loadOptions); + // 2. Discover JS/TS hook factories and bind them through the extension + // runner, which owns the current runtime event bus. Explicit-only discovery + // still loads hooks from explicitly injected OMP package roots, while + // excluding every ambient hook provider. + const hooks = await loadCapability( + hookCapability.id, + ambient ? loadOptions : { ...loadOptions, providers: ["omp-plugins"] }, + ); for (const hookPath of hooks.items .map(hook => hook.path) .filter(hookPath => isExtensionFile(path.basename(hookPath)))) { addPath(hookPath); } - // 3. Discover extension entry points from installed plugins - addPaths(await getAllPluginExtensionPaths(cwd)); + // 3. Discover extension entry points from installed plugins. + if (ambient) { + addPaths(await getAllPluginExtensionPaths(cwd)); + } // 4. Explicitly configured paths for (const configuredPath of configuredPaths) { @@ -618,7 +632,8 @@ export async function discoverAndLoadExtensions( cwd: string, eventBus?: EventBus, disabledExtensionIds?: string[], + options: DiscoverExtensionPathOptions = {}, ): Promise { - const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds); + const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds, options); return loadExtensions(paths, cwd, eventBus); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 03534dd4b..be67909a1 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -1110,14 +1110,13 @@ export async function buildSessionOptions( } // Additional extension paths from CLI - const cliExtensionPaths = parsed.noExtensions ? [] : [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])]; + const cliExtensionPaths = [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])]; if (cliExtensionPaths.length > 0) { options.additionalExtensionPaths = cliExtensionPaths; } if (parsed.noExtensions) { options.disableExtensionDiscovery = true; - options.additionalExtensionPaths = []; } return options; @@ -1197,13 +1196,13 @@ export async function runRootCommand( // Register CLI-provided extension package paths (`--extension`, `--hook`) so // the `omp-plugins` discovery provider can surface their `skills/`, `hooks/`, // `tools/`, `commands/`, `rules/`, `prompts/`, and `.mcp.json` sub-trees. - // `--no-extensions` short-circuits both the factory load and the sub-discovery. - if (!parsedArgs.noExtensions) { - const cliExtensions = [...(parsedArgs.extensions ?? []), ...(parsedArgs.hooks ?? [])]; - if (cliExtensions.length > 0) { - injectOmpExtensionCliRoots(cliExtensions, home, getProjectDir()); - } - } + // Explicit roots remain authorized under `--no-extensions`; only ambient + // extension discovery is disabled. + const cliExtensions = [...(parsedArgs.extensions ?? []), ...(parsedArgs.hooks ?? [])]; + injectOmpExtensionCliRoots(cliExtensions, home, getProjectDir(), { + mode: parsedArgs.noExtensions ? "explicit-only" : "merge", + replace: true, + }); let cwd = getProjectDir(); const settingsInstance = diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 393044ead..3e12a1b70 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -695,12 +695,15 @@ export async function discoverSessionExtensionPaths( cwd: string, settings: Settings, ): Promise { - if (options.disableExtensionDiscovery) { - return options.additionalExtensionPaths ?? []; - } - const configuredPaths = [...(options.additionalExtensionPaths ?? []), ...(settings.get("extensions") ?? [])]; - const disabledExtensionIds = settings.get("disabledExtensions") ?? []; - return discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds); + const configuredPaths = options.disableExtensionDiscovery + ? (options.additionalExtensionPaths ?? []) + : [...(options.additionalExtensionPaths ?? []), ...(settings.get("extensions") ?? [])]; + const disabledExtensionIds = options.disableExtensionDiscovery + ? undefined + : (settings.get("disabledExtensions") ?? []); + return discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds, { + ambient: !options.disableExtensionDiscovery, + }); } /** diff --git a/packages/coding-agent/test/cli-explicit-extension-isolation.test.ts b/packages/coding-agent/test/cli-explicit-extension-isolation.test.ts new file mode 100644 index 000000000..6caf8ae45 --- /dev/null +++ b/packages/coding-agent/test/cli-explicit-extension-isolation.test.ts @@ -0,0 +1,34 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { AuthStorage } from "@oh-my-pi/pi-ai"; +import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { buildSessionOptions } from "@oh-my-pi/pi-coding-agent/main"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +let tempDir: TempDir; +let authStorage: AuthStorage; + +beforeEach(async () => { + tempDir = await TempDir.create("@cli-explicit-extension-isolation-"); + authStorage = await AuthStorage.create(tempDir.join("auth.db")); +}); + +afterEach(async () => { + authStorage.close(); + await tempDir.remove(); +}); + +test("buildSessionOptions retains explicit extensions and hooks under --no-extensions", async () => { + const extensionPath = tempDir.join("extension-package"); + const hookPath = tempDir.join("hook.ts"); + const parsed = parseArgs(["--no-extensions", "--extension", extensionPath, "--hook", hookPath]); + const settings = Settings.isolated(); + const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); + + const options = await buildSessionOptions(parsed, [], SessionManager.inMemory(), modelRegistry, settings); + + expect(options.disableExtensionDiscovery).toBe(true); + expect(options.additionalExtensionPaths).toEqual([extensionPath, hookPath]); +}); diff --git a/packages/coding-agent/test/discovery/omp-plugins.test.ts b/packages/coding-agent/test/discovery/omp-plugins.test.ts index 07cdb40ea..dc85124f6 100644 --- a/packages/coding-agent/test/discovery/omp-plugins.test.ts +++ b/packages/coding-agent/test/discovery/omp-plugins.test.ts @@ -31,7 +31,9 @@ import "@oh-my-pi/pi-coding-agent/discovery"; import { clearOmpExtensionCliRoots, injectOmpExtensionCliRoots, + listOmpExtensionRoots, } from "@oh-my-pi/pi-coding-agent/discovery/omp-extension-roots"; +import { discoverExtensionPaths } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { getConfigRootDir, removeSyncWithRetries, setAgentDir } from "@oh-my-pi/pi-utils"; const PROVIDER_ID = "omp-plugins"; @@ -62,21 +64,22 @@ async function loadFromPlugin(capabilityId: string, ctx: LoadContext): Promis return result.items as T[]; } -function buildExtensionPackage(packageDir: string): void { +function buildExtensionPackage(packageDir: string, skillName = "my-skill"): void { writeFile( path.join(packageDir, "package.json"), JSON.stringify({ name: path.basename(packageDir), omp: { extensions: ["./src/main.ts"] } }), ); writeFile(path.join(packageDir, "src", "main.ts"), "export default function (_pi) {}\n"); writeFile( - path.join(packageDir, "skills", "my-skill", "SKILL.md"), - "---\nname: my-skill\ndescription: Hello from extension skill\n---\nbody\n", + path.join(packageDir, "skills", skillName, "SKILL.md"), + `---\nname: ${skillName}\ndescription: Hello from extension skill\n---\nbody\n`, ); writeFile(path.join(packageDir, "commands", "greet.md"), "---\ndescription: greet user\n---\nHello {{name}}\n"); writeFile(path.join(packageDir, "rules", "style.md"), "---\ndescription: style rule\n---\nUse tabs.\n"); writeFile(path.join(packageDir, "prompts", "review.md"), "Review this code.\n"); writeFile(path.join(packageDir, "hooks", "pre", "bash.sh"), "#!/bin/sh\necho pre\n"); writeFile(path.join(packageDir, "hooks", "post", "edit.sh"), "#!/bin/sh\necho post\n"); + writeFile(path.join(packageDir, "hooks", "pre", "extension.ts"), "export default function (_pi) {}\n"); writeFile(path.join(packageDir, "tools", "wcount.sh"), "#!/bin/sh\nwc -w\n"); writeFile(path.join(packageDir, "tools", "deep-tool", "index.ts"), "export default { name: 'deep-tool' };\n"); writeFile( @@ -155,6 +158,43 @@ test("`--extension` CLI injection is wired through the same provider", async () expect(tools.map(t => t.name)).toEqual(expect.arrayContaining(["wcount", "deep-tool"])); }); +test("explicit-only CLI roots replace stale state and exclude every ambient package source", async () => { + const stale = path.join(tempDir, "stale-extension"); + const projectExt = path.join(tempDir, "project-extension"); + const userExt = path.join(tempDir, "user-extension"); + const installed = path.join(home, ".omp", "plugins", "node_modules", "installed-extension"); + buildExtensionPackage(stale, "stale-skill"); + buildExtensionPackage(projectExt, "project-skill"); + buildExtensionPackage(userExt, "user-skill"); + buildExtensionPackage(installed, "installed-skill"); + writeFile(path.join(project, ".omp", "settings.json"), JSON.stringify({ extensions: [projectExt] })); + writeFile(path.join(home, ".omp", "agent", "settings.json"), JSON.stringify({ extensions: [userExt] })); + writeFile( + path.join(home, ".omp", "plugins", "package.json"), + JSON.stringify({ name: "omp-plugins", dependencies: { "installed-extension": "1.0.0" } }), + ); + + injectOmpExtensionCliRoots([stale], home, project); + injectOmpExtensionCliRoots([ext], home, project, { mode: "explicit-only", replace: true }); + + const roots = await listOmpExtensionRoots(ctx()); + const skills = await loadFromPlugin<{ name: string }>(skillCapability.id, ctx()); + const extensionPaths = await discoverExtensionPaths([ext], project, undefined, { ambient: false }); + + expect(roots).toHaveLength(1); + expect(path.basename(roots[0].path)).toBe("my-extension"); + expect(skills.map(skill => skill.name)).toContain("my-skill"); + expect(skills.map(skill => skill.name)).not.toEqual( + expect.arrayContaining(["stale-skill", "project-skill", "user-skill", "installed-skill"]), + ); + expect(extensionPaths).toContain(path.join(ext, "hooks", "pre", "extension.ts")); + expect( + extensionPaths.some(candidate => + [stale, projectExt, userExt, installed].some(ambientRoot => candidate.startsWith(ambientRoot)), + ), + ).toBe(false); +}); + test("file-extension entrypoints contribute zero sub-surface (the file has no siblings to scan)", async () => { const standaloneFile = path.join(tempDir, "standalone.ts"); fs.writeFileSync(standaloneFile, "export default function (_pi) {}\n"); diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 7379cb8a3..a382d2028 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -138,6 +138,31 @@ describe("extensions discovery", () => { expect(result.extensions[0].path).toContain("main.ts"); }); + it("explicit-only discovery resolves a package manifest and excludes ambient factories", async () => { + fs.writeFileSync(path.join(extensionsDir, "ambient.ts"), extensionCodeWithTool("ambient-tool")); + const packageDir = path.join(tempDir.path(), "explicit-package"); + const sourceDir = path.join(packageDir, "src"); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(sourceDir, "main.ts"), extensionCodeWithTool("explicit-tool")); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "explicit-package", + omp: { + extensions: ["./src/main.ts"], + }, + }), + ); + + const result = await discoverAndLoadExtensions([packageDir], tempDir.path(), undefined, undefined, { + ambient: false, + }); + + expect(result.errors).toHaveLength(0); + expect(result.extensions.map(extension => extension.path)).toEqual([path.join(sourceDir, "main.ts")]); + expect(result.extensions.flatMap(extension => [...extension.tools.keys()])).toEqual(["explicit-tool"]); + }); + it("discovers a symlinked extension package directory", async () => { const packageDir = path.join(tempDir.path(), "linked-package"); const sourceDir = path.join(packageDir, "src"); diff --git a/packages/coding-agent/test/issue-905-repro.test.ts b/packages/coding-agent/test/issue-905-repro.test.ts index dc2d9647c..8d7d1aa94 100644 --- a/packages/coding-agent/test/issue-905-repro.test.ts +++ b/packages/coding-agent/test/issue-905-repro.test.ts @@ -21,6 +21,8 @@ import { TempDir } from "@oh-my-pi/pi-utils"; let tmp: TempDir; let extPath: string; +let explicitPackagePath: string; +let ambientExtPath: string; let dbPath: string; let shutdownExtPath: string; let shutdownPath: string; @@ -58,6 +60,53 @@ beforeAll(async () => { await Bun.write(${JSON.stringify(shutdownPath)}, "shutdown"); }); } +`, + ); + explicitPackagePath = tmp.join("explicit-package"); + ambientExtPath = tmp.join("ambient.ts"); + await fs.mkdir(tmp.join("explicit-package", "src"), { recursive: true }); + await fs.writeFile( + tmp.join("explicit-package", "package.json"), + JSON.stringify({ name: "explicit-package", omp: { extensions: ["./src/main.ts"] } }), + ); + await fs.writeFile( + tmp.join("explicit-package", "src", "main.ts"), + `export default function (pi) { + pi.registerProvider("explicit-gw", { + baseUrl: "https://explicit.example.com/v1", + apiKey: "literal-test-key", + api: "openai-completions", + models: [{ + id: "explicit-model", + name: "Explicit Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }], + }); +} +`, + ); + await fs.writeFile( + ambientExtPath, + `export default function (pi) { + pi.registerProvider("ambient-gw", { + baseUrl: "https://ambient.example.com/v1", + apiKey: "literal-test-key", + api: "openai-completions", + models: [{ + id: "ambient-model", + name: "Ambient Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }], + }); +} `, ); }); @@ -118,6 +167,40 @@ test("omp models emits extension shutdown after listing (issue #6297)", async () } }); +test("omp models explicit-only mode resolves a package and excludes settings providers", async () => { + const authStorage = await AuthStorage.create(":memory:"); + try { + const modelRegistry = new ModelRegistry(authStorage); + const captured: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + captured.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stdout.write; + + try { + await runModelsListing({ + modelRegistry, + cwd: tmp.path(), + action: "ls", + additionalExtensionPaths: [explicitPackagePath], + settingsExtensions: [ambientExtPath], + disableExtensionDiscovery: true, + }); + } finally { + process.stdout.write = originalWrite; + } + + const output = captured.join(""); + expect(output).toContain("explicit-gw"); + expect(output).toContain("explicit-model"); + expect(output).not.toContain("ambient-gw"); + expect(output).not.toContain("ambient-model"); + } finally { + authStorage.close(); + } +}); + test("omp models prints invalid models.yml schema errors before listing output", async () => { const modelsPath = tmp.join("invalid-models.yml"); await fs.writeFile( diff --git a/packages/coding-agent/test/task/discovery.test.ts b/packages/coding-agent/test/task/discovery.test.ts index 886a35b28..a210d3fe8 100644 --- a/packages/coding-agent/test/task/discovery.test.ts +++ b/packages/coding-agent/test/task/discovery.test.ts @@ -139,4 +139,36 @@ describe("discoverAgents", () => { expect(collide?.description).toBe("from-cli"); expect(collide?.filePath).toBe(path.join(cliExt, "agents", "collide.md")); }); + + test("explicit-only CLI roots expose only explicitly named package agents", async () => { + const staleExt = path.join(tempHome, "stale-ext"); + const explicitExt = path.join(tempHome, "explicit-ext"); + const settingsExt = path.join(tempHome, "settings-ext"); + for (const [root, name] of [ + [staleExt, "stale-agent"], + [explicitExt, "explicit-agent"], + [settingsExt, "settings-agent"], + ] as const) { + await fs.mkdir(path.join(root, "agents"), { recursive: true }); + await fs.writeFile( + path.join(root, "agents", `${name}.md`), + ["---", `name: ${name}`, `description: ${name}`, "---", `${name} body`].join("\n"), + ); + } + await fs.mkdir(path.join(projectDir, ".omp"), { recursive: true }); + await fs.writeFile(path.join(projectDir, ".omp", "settings.json"), JSON.stringify({ extensions: [settingsExt] })); + await writeOmpPluginAgent(tempHome); + + injectOmpExtensionCliRoots([staleExt], tempHome, projectDir); + injectOmpExtensionCliRoots([explicitExt], tempHome, projectDir, { + mode: "explicit-only", + replace: true, + }); + + const { agents } = await discoverAgents(projectDir, tempHome); + const names = agents.map(agent => agent.name); + + expect(names).toContain("explicit-agent"); + expect(names).not.toEqual(expect.arrayContaining(["stale-agent", "settings-agent", "loom-verify-spec"])); + }); });