From da1ad85dbe2efdda68e25ed0d399b28b5d6b3534 Mon Sep 17 00:00:00 2001 From: danzaio <213864024+danzaio@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:18:16 -0300 Subject: [PATCH 1/3] fix(cli): reject ambiguous extensions command --- packages/coding-agent/src/cli-commands.ts | 12 ++++++++++++ packages/coding-agent/src/cli.ts | 8 +++++++- packages/coding-agent/test/install-command.test.ts | 7 +++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/cli-commands.ts b/packages/coding-agent/src/cli-commands.ts index 3480e46ff..3932fb868 100644 --- a/packages/coding-agent/src/cli-commands.ts +++ b/packages/coding-agent/src/cli-commands.ts @@ -38,6 +38,18 @@ export const commands: CommandEntry[] = [ { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] }, ]; +const RESERVED_TOP_LEVEL_WORDS = new Map([ + [ + "extensions", + '`omp extensions` is not a management command. Use `omp plugin list` / `omp plugin install`, or run `omp launch extensions` if you meant to send "extensions" as a prompt.', + ], +]); + +export function reservedTopLevelWordMessage(first: string | undefined): string | undefined { + if (!first || first.startsWith("-") || first.startsWith("@")) return undefined; + return RESERVED_TOP_LEVEL_WORDS.get(first); +} + /** * Return true when `first` matches a registered subcommand name or alias. * diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 6c9992523..b163adc0e 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -161,13 +161,19 @@ export async function runCli(argv: string[]): Promise { if (await runWorkerEntrypoint(argv[0])) { return; } - const [{ run }, { commands, isSubcommand }] = await Promise.all([ + const [{ run }, { commands, isSubcommand, reservedTopLevelWordMessage }] = await Promise.all([ import("@oh-my-pi/pi-utils/cli"), import("./cli-commands"), ]); // --help and --version are handled by run() directly, don't rewrite those. // Everything else that isn't a known subcommand routes to "launch". const first = argv[0]; + const reservedMessage = reservedTopLevelWordMessage(first); + if (reservedMessage) { + process.stderr.write(`error: ${reservedMessage}\n`); + process.exitCode = 1; + return; + } const runArgv = first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help" ? argv diff --git a/packages/coding-agent/test/install-command.test.ts b/packages/coding-agent/test/install-command.test.ts index 90bdbff01..7921792c0 100644 --- a/packages/coding-agent/test/install-command.test.ts +++ b/packages/coding-agent/test/install-command.test.ts @@ -23,6 +23,13 @@ describe("install command is registered as a top-level subcommand", () => { expect(cli.commands.some(c => c.name === "install")).toBe(true); expect(cli.isSubcommand("install")).toBe(true); }); + + test("CLI runner rejects reserved management words instead of launching a prompt", async () => { + const cli = await import("@oh-my-pi/pi-coding-agent/cli-commands"); + expect(cli.isSubcommand("extensions")).toBe(false); + expect(cli.reservedTopLevelWordMessage("extensions")).toContain("omp plugin list"); + expect(cli.reservedTopLevelWordMessage("hello")).toBeUndefined(); + }); }); describe("looksLikeLocalPath", () => { From e2c7c45ff8743f3613c4e41b950f29a972634089 Mon Sep 17 00:00:00 2001 From: danzaio <213864024+danzaio@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:57:09 -0300 Subject: [PATCH 2/3] fix(cli): limit extensions guard to bare command --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/cli-commands.ts | 21 +++++++++++++++++-- packages/coding-agent/src/cli.ts | 17 +++++---------- .../coding-agent/test/install-command.test.ts | 13 ++++++++---- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ee70e90fe..26b2288a5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -133,6 +133,10 @@ - Fixed Windows stdio MCP servers launched through PATH shims such as `codegraph.cmd` so bare commands like `codegraph` resolve via `PATHEXT` before spawn ([#2174](https://github.com/can1357/oh-my-pi/issues/2174)). - Fixed compiled-binary extensions failing to load `@oh-my-pi/pi-*` packages when `bun --compile` quietly dropped one of the extra entrypoints (observed on macOS arm64 release builds): the legacy-pi compat shim's package-root override branch returned the bunfs path without checking the target was present, so the rewrite emitted a `file://` URL to a missing module and the #1216 fallback (scoped to the throwing `getResolvedSpecifier` path) never ran. Override targets are now validated against the on-disk filesystem at module init, missing entries are dropped, and resolution falls through to canonical lookup so Bun resolves the import from the extension's own `node_modules` ([#2168](https://github.com/can1357/oh-my-pi/issues/2168)). +### Fixed + +- Fixed bare `omp extensions` being treated as a chat prompt instead of returning an actionable plugin-command error ([#2089](https://github.com/can1357/oh-my-pi/issues/2089)). + ## [15.10.8] - 2026-06-09 ### Added diff --git a/packages/coding-agent/src/cli-commands.ts b/packages/coding-agent/src/cli-commands.ts index 3932fb868..6d6a797e9 100644 --- a/packages/coding-agent/src/cli-commands.ts +++ b/packages/coding-agent/src/cli-commands.ts @@ -45,8 +45,8 @@ const RESERVED_TOP_LEVEL_WORDS = new Map([ ], ]); -export function reservedTopLevelWordMessage(first: string | undefined): string | undefined { - if (!first || first.startsWith("-") || first.startsWith("@")) return undefined; +export function reservedTopLevelWordMessage(first: string | undefined, argc = 1): string | undefined { + if (argc !== 1 || !first || first.startsWith("-") || first.startsWith("@")) return undefined; return RESERVED_TOP_LEVEL_WORDS.get(first); } @@ -60,3 +60,20 @@ export function isSubcommand(first: string | undefined): boolean { if (!first || first.startsWith("-") || first.startsWith("@")) return false; return commands.some(entry => entry.name === first || entry.aliases?.includes(first)); } + +export type ResolvedCliArgv = { argv: string[] } | { error: string }; + +/** + * Decide what the CLI runner should do with raw argv: reject bare reserved + * management words, pass help/version through untouched, and route everything + * that is not a known subcommand to `launch`. + */ +export function resolveCliArgv(argv: string[]): ResolvedCliArgv { + const first = argv[0]; + const reservedMessage = reservedTopLevelWordMessage(first, argv.length); + if (reservedMessage) return { error: reservedMessage }; + if (first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help") { + return { argv }; + } + return { argv: isSubcommand(first) ? argv : ["launch", ...argv] }; +} diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index b163adc0e..337efd411 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -161,26 +161,19 @@ export async function runCli(argv: string[]): Promise { if (await runWorkerEntrypoint(argv[0])) { return; } - const [{ run }, { commands, isSubcommand, reservedTopLevelWordMessage }] = await Promise.all([ + const [{ run }, { commands, resolveCliArgv }] = await Promise.all([ import("@oh-my-pi/pi-utils/cli"), import("./cli-commands"), ]); // --help and --version are handled by run() directly, don't rewrite those. // Everything else that isn't a known subcommand routes to "launch". - const first = argv[0]; - const reservedMessage = reservedTopLevelWordMessage(first); - if (reservedMessage) { - process.stderr.write(`error: ${reservedMessage}\n`); + const resolved = resolveCliArgv(argv); + if ("error" in resolved) { + process.stderr.write(`error: ${resolved.error}\n`); process.exitCode = 1; return; } - const runArgv = - first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help" - ? argv - : isSubcommand(first) - ? argv - : ["launch", ...argv]; - return run({ bin: APP_NAME, version: VERSION, argv: runArgv, commands, help: showHelp }); + return run({ bin: APP_NAME, version: VERSION, argv: resolved.argv, commands, help: showHelp }); } // Floating call instead of top-level await: TLA forces `--bytecode` (CJS diff --git a/packages/coding-agent/test/install-command.test.ts b/packages/coding-agent/test/install-command.test.ts index 7921792c0..2538d7f3e 100644 --- a/packages/coding-agent/test/install-command.test.ts +++ b/packages/coding-agent/test/install-command.test.ts @@ -24,11 +24,16 @@ describe("install command is registered as a top-level subcommand", () => { expect(cli.isSubcommand("install")).toBe(true); }); - test("CLI runner rejects reserved management words instead of launching a prompt", async () => { + test("CLI runner rejects only bare reserved management words", async () => { const cli = await import("@oh-my-pi/pi-coding-agent/cli-commands"); - expect(cli.isSubcommand("extensions")).toBe(false); - expect(cli.reservedTopLevelWordMessage("extensions")).toContain("omp plugin list"); - expect(cli.reservedTopLevelWordMessage("hello")).toBeUndefined(); + + expect(cli.resolveCliArgv(["extensions"])).toEqual({ + error: '`omp extensions` is not a management command. Use `omp plugin list` / `omp plugin install`, or run `omp launch extensions` if you meant to send "extensions" as a prompt.', + }); + expect(cli.resolveCliArgv(["extensions", "are", "not", "loading"])).toEqual({ + argv: ["launch", "extensions", "are", "not", "loading"], + }); + expect(cli.resolveCliArgv(["launch", "extensions"])).toEqual({ argv: ["launch", "extensions"] }); }); }); From 3e9b5ea79631eca6868dfd638a47a6a5b39ad427 Mon Sep 17 00:00:00 2001 From: danzaio <213864024+danzaio@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:16:35 -0300 Subject: [PATCH 3/3] test(cli): avoid inline import in guard test --- .../coding-agent/test/install-command.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/test/install-command.test.ts b/packages/coding-agent/test/install-command.test.ts index 2538d7f3e..73c76ad9a 100644 --- a/packages/coding-agent/test/install-command.test.ts +++ b/packages/coding-agent/test/install-command.test.ts @@ -15,25 +15,23 @@ import { describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { commands, isSubcommand, resolveCliArgv } from "@oh-my-pi/pi-coding-agent/cli-commands"; import { looksLikeLocalPath } from "@oh-my-pi/pi-coding-agent/commands/install"; describe("install command is registered as a top-level subcommand", () => { - test("CLI runner sees `install` as a known command", async () => { - const cli = await import("@oh-my-pi/pi-coding-agent/cli-commands"); - expect(cli.commands.some(c => c.name === "install")).toBe(true); - expect(cli.isSubcommand("install")).toBe(true); + test("CLI runner sees `install` as a known command", () => { + expect(commands.some(c => c.name === "install")).toBe(true); + expect(isSubcommand("install")).toBe(true); }); - test("CLI runner rejects only bare reserved management words", async () => { - const cli = await import("@oh-my-pi/pi-coding-agent/cli-commands"); - - expect(cli.resolveCliArgv(["extensions"])).toEqual({ + test("CLI runner rejects only bare reserved management words", () => { + expect(resolveCliArgv(["extensions"])).toEqual({ error: '`omp extensions` is not a management command. Use `omp plugin list` / `omp plugin install`, or run `omp launch extensions` if you meant to send "extensions" as a prompt.', }); - expect(cli.resolveCliArgv(["extensions", "are", "not", "loading"])).toEqual({ + expect(resolveCliArgv(["extensions", "are", "not", "loading"])).toEqual({ argv: ["launch", "extensions", "are", "not", "loading"], }); - expect(cli.resolveCliArgv(["launch", "extensions"])).toEqual({ argv: ["launch", "extensions"] }); + expect(resolveCliArgv(["launch", "extensions"])).toEqual({ argv: ["launch", "extensions"] }); }); });