@oh-my-pi/pi-coding-agent 15.5.11 → 15.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.13] - 2026-05-29
6
+ ### Breaking Changes
7
+
8
+ - Changed hashline edit syntax to verb-based v4: body-bearing ops are `replace N..M:`, `insert before N:`, `insert after N:`, `insert head:`, and `insert tail:`, while bodyless `delete N..M` handles deletion. Removed `>A..B` repeat rows and the old `prepend:` / `append:` virtual insert headers; `-` rows remain rejected with a teaching error.
9
+
10
+ ### Changed
11
+
12
+ - Changed hashline tag generation to use full-file snapshots for read/search/ast-grep and related outputs, so hashline anchors now validate only when the complete file matches
13
+ - Changed hashline tagging to omit file headers for files over 4 MiB or that cannot be snapshotted, so those files are returned without editable hashline anchors
14
+ - Changed hashline context generation for line edits from partial/sparse snippets to complete-file fingerprints, reducing stale anchors for partially read files
15
+
16
+ ### Fixed
17
+
18
+ - Restored automatic repair of `edit` range hunks that break bracket balance — the failure class that previously left a duplicated closing line (a `</>` / `);` / `}` echoed just below the range) or dropped one (the range swallowed a `});` the payload never restated), leaving the file syntactically broken until a follow-up edit. The hashline applier now normalizes each replacement so its payload preserves the deleted region's delimiter balance, dropping a duplicated bordering closer or sparing a deleted one, and surfaces a warning on the tool result. Always on and balance-validated (no `edit.hashlineAutoDropPureInsertDuplicates` setting); see `@oh-my-pi/hashline` for the contract.
19
+
20
+ ## [15.5.12] - 2026-05-29
21
+
22
+ ### Added
23
+
24
+ - Added the `omp-plugins` discovery provider, which scans every extension package directory configured via `extensions:` (in `~/.omp/agent/settings.json` or `<cwd>/.omp/settings.json`) or `--extension`/`-e` on the CLI for `skills/`, `hooks/pre|post/`, `tools/`, `commands/`, `rules/`, `prompts/`, and `.mcp.json`. Prior to this, only the extension's TypeScript factory module ran; every sibling capability the docs (https://omp.sh/docs/extension-authoring) advertised was silently ignored ([#1496](https://github.com/can1357/oh-my-pi/issues/1496)).
25
+ - Added the top-level `omp install <target>` subcommand documented at https://omp.sh/docs/extension-authoring. Local paths route to `omp plugin link` (so the directory is symlinked into the plugin set), and npm/marketplace specs route to `omp plugin install`. Before this, `install` was not a registered subcommand and the CLI runner silently forwarded `install ./my-extension` to `launch` as an initial LLM prompt ([#1496](https://github.com/can1357/oh-my-pi/issues/1496)).
26
+
27
+ ### Changed
28
+
29
+ - Changed the sticky `Todos` panel above the editor to advance as tasks close, instead of pinning to the first 5 tasks of the active phase. `selectStickyTodoWindow` now shows up to 5 open (pending / in_progress) tasks in original phase order and reports the count of remaining open tasks for the `+N more` hint, so every `todo_write` flip produces a visible row shift. Closed-phase tail falls back to the last 5 tasks (with the `+N more` line suppressed) until `getActivePhase` walks to the next phase.
30
+ - Linked the sticky `Todos` panel to the live `SessionObserverRegistry` so pending todos that have an in-flight subagent doing their work light up green with an animated spinner — the same `theme.spinnerFrames` ("status" preset) the `task` tool uses for its agent rows — instead of staying greyed out as if nothing is happening. A new exported `todoMatchesAnyDescription(content, descriptions)` does case- and whitespace-insensitive equality first with a 6-char minimum-overlap substring fallback in either direction, so "Sonnet #2: shallow bug scan" and a subagent description of "Sonnet #2" still link up. Completed todos now render with `theme.status.success` (✔ / `\uf00c` / `[ok]` per symbol preset, still wrapped in the `success` colour so themed palettes can keep their purple/green/whatever) and in_progress rows render with `theme.status.running`, matching the `task` tool's icon vocabulary. The spinner interval only ticks while at least one visible open todo has a matched active subagent, and self-stops once subagents finish, so plain in_progress todos do not animate forever in the absence of subagent activity.
31
+ - Extracted the top-level CLI command table from `src/cli.ts` into a side-effect-free `src/cli-commands.ts` so test code can introspect the registered subcommands without triggering the entrypoint's top-level await.
32
+
5
33
  ## [15.5.11] - 2026-05-29
6
34
 
7
35
  ### Added
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Top-level CLI command table.
3
+ *
4
+ * Lives in its own module (importable without side effects) so that tests can
5
+ * inspect the registered subcommands without triggering the side-effectful
6
+ * top-level await in `cli.ts`. Adding a new subcommand here is enough to make
7
+ * `runCli` route to it instead of forwarding the argv as a prompt to
8
+ * `launch` — see #1496 for the original "args silently leak to the LLM"
9
+ * regression that motivated the split.
10
+ */
11
+ import type { CommandEntry } from "@oh-my-pi/pi-utils/cli";
12
+ export declare const commands: CommandEntry[];
13
+ /**
14
+ * Return true when `first` matches a registered subcommand name or alias.
15
+ *
16
+ * Flags (`-…`) and `@file` arguments are never subcommands; for those the CLI
17
+ * runner skips ahead to the default `launch` command.
18
+ */
19
+ export declare function isSubcommand(first: string | undefined): boolean;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `omp install <target>` — top-level convenience over `omp plugin install` /
3
+ * `omp plugin link`.
4
+ *
5
+ * The docs (omp.sh/docs/extension-authoring) advertise
6
+ *
7
+ * omp install ./my-extension
8
+ *
9
+ * as a third loading mechanism that "symlinks the directory into the plugin
10
+ * set and watches it for changes". Before this command existed, `install` was
11
+ * not a registered subcommand, so the CLI runner forwarded the argv to the
12
+ * default `launch` command and the model received `install ./my-extension`
13
+ * as an initial prompt — see #1496.
14
+ *
15
+ * Local-path targets (`./foo`, `/abs/foo`, `~/foo`, or an existing directory)
16
+ * route to `plugin link` so they are symlinked into the plugin set, matching
17
+ * the documented behavior. Everything else (`pkg`, `pkg@1.2.3`,
18
+ * `name@marketplace`) routes to `plugin install`.
19
+ */
20
+ import { Command } from "@oh-my-pi/pi-utils/cli";
21
+ /**
22
+ * Heuristic used to decide whether `omp install <target>` should `link` a
23
+ * local directory or `install` a remote spec. Exported for tests.
24
+ */
25
+ export declare function looksLikeLocalPath(target: string): boolean;
26
+ export default class Install extends Command {
27
+ static description: string;
28
+ static args: {
29
+ targets: import("@oh-my-pi/pi-utils/cli").ArgDescriptor & {
30
+ description: string;
31
+ required: false;
32
+ multiple: true;
33
+ };
34
+ };
35
+ static flags: {
36
+ json: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
37
+ description: string;
38
+ };
39
+ force: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
40
+ description: string;
41
+ };
42
+ "dry-run": import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
43
+ description: string;
44
+ };
45
+ scope: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
46
+ description: string;
47
+ options: string[];
48
+ };
49
+ };
50
+ run(): Promise<void>;
51
+ }
@@ -30,6 +30,7 @@ import "./gemini";
30
30
  import "./opencode";
31
31
  import "./github";
32
32
  import "./mcp-json";
33
+ import "./omp-plugins";
33
34
  import "./ssh";
34
35
  import "./vscode";
35
36
  import "./windsurf";
@@ -0,0 +1,43 @@
1
+ import type { LoadContext } from "../capability/types";
2
+ /** A resolved extension package directory wired into the discovery surfaces. */
3
+ export interface OmpExtensionRoot {
4
+ /** Absolute path to the package directory. */
5
+ path: string;
6
+ /** Stable display name (basename of the package directory). */
7
+ name: string;
8
+ /** Scope from which the path was sourced. */
9
+ level: "user" | "project";
10
+ }
11
+ /**
12
+ * Register CLI-provided extension package paths (e.g. from `--extension`/`-e`)
13
+ * so the sub-discovery providers can find their sibling `skills/`, `hooks/`,
14
+ * etc. Paths that do not resolve to a directory are silently dropped — file
15
+ * entrypoints have no package sub-tree to scan.
16
+ *
17
+ * Call once during startup before any capability load. Repeated calls extend
18
+ * the registered set; {@link clearOmpExtensionCliRoots} resets for tests.
19
+ */
20
+ export declare function injectOmpExtensionCliRoots(paths: readonly string[], home: string, cwd: string): void;
21
+ /** Drop every CLI-injected root. Tests use this between cases. */
22
+ export declare function clearOmpExtensionCliRoots(): void;
23
+ /** Inspect currently-injected CLI roots (read-only). Exposed for diagnostics + tests. */
24
+ export declare function getInjectedOmpExtensionCliRoots(): readonly OmpExtensionRoot[];
25
+ /**
26
+ * Resolve every configured extension package directory for the given context.
27
+ *
28
+ * Sources, in order of precedence (later entries with the same absolute path
29
+ * are dropped):
30
+ *
31
+ * 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
32
+ * 2. Project `<cwd>/.omp/settings.json#extensions`
33
+ * 3. User `~/.omp/agent/settings.json#extensions`
34
+ * 4. Enabled plugins installed under `<plugins>/node_modules/` (e.g. via
35
+ * `omp install <pkg>` / `omp plugin install` / `omp plugin link`)
36
+ *
37
+ * Only entries that resolve to a directory on disk are returned; file
38
+ * entrypoints contribute zero sub-discovery surface and are filtered out.
39
+ * Installed-plugin enumeration failures (missing lockfile, unreadable
40
+ * `package.json`, etc.) are logged at `debug` and degrade gracefully — the
41
+ * other sources still surface.
42
+ */
43
+ export declare function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtensionRoot[]>;
@@ -0,0 +1 @@
1
+ export {};
@@ -9,6 +9,13 @@
9
9
  * is wiring it onto the per-session owner object.
10
10
  */
11
11
  import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
12
+ /**
13
+ * Upper bound on the file size we snapshot. A section tag is a content hash of
14
+ * the *whole* file, so minting one means holding the full normalized text in
15
+ * the store. Files above this cap emit no `¶path#tag` header — line-anchored
16
+ * editing of multi-megabyte files is out of scope under the full-content model.
17
+ */
18
+ export declare const SNAPSHOT_MAX_BYTES: number;
12
19
  interface FileSnapshotStoreOwner {
13
20
  fileSnapshotStore?: InMemorySnapshotStore;
14
21
  }
@@ -18,4 +25,16 @@ interface FileSnapshotStoreOwner {
18
25
  * the session itself.
19
26
  */
20
27
  export declare function getFileSnapshotStore(session: FileSnapshotStoreOwner): InMemorySnapshotStore;
28
+ /**
29
+ * Read the full text of `absolutePath` (within {@link SNAPSHOT_MAX_BYTES}),
30
+ * record it as a version snapshot, and return its content-hash tag. Returns
31
+ * `undefined` when the file exceeds the cap or cannot be read — callers then
32
+ * omit the section header so the model never sees a tag it can't anchor against.
33
+ *
34
+ * Producers that only displayed a slice of the file (range reads, search hits)
35
+ * use this to mint a whole-file tag: the displayed lines stay partial, but the
36
+ * tag fingerprints the entire file so a follow-up edit anchored at any line
37
+ * validates whenever the live file is byte-identical to what was read.
38
+ */
39
+ export declare function recordFileSnapshot(session: FileSnapshotStoreOwner, absolutePath: string): Promise<string | undefined>;
21
40
  export {};
@@ -1,9 +1,19 @@
1
1
  import type { InstalledPlugin } from "./types";
2
2
  /**
3
3
  * Get list of enabled plugins with their resolved configurations.
4
- * Respects both global runtime config and project overrides.
4
+ *
5
+ * Respects both global runtime config and project overrides. Iterates the
6
+ * union of `<plugins>/package.json#dependencies` (`bun install`-installed
7
+ * packages) and `<plugins>/omp-plugins.lock.json#plugins` (so locally
8
+ * `plugin link`-symlinked extensions, which never get a dependency entry,
9
+ * are still discovered). The optional `home` parameter pins the plugins
10
+ * root for callers that need to enumerate plugins relative to a non-default
11
+ * home (tests with a tempdir, discovery loaders threaded with
12
+ * `LoadContext.home`).
5
13
  */
6
- export declare function getEnabledPlugins(cwd: string): Promise<InstalledPlugin[]>;
14
+ export declare function getEnabledPlugins(cwd: string, opts?: {
15
+ home?: string;
16
+ }): Promise<InstalledPlugin[]>;
7
17
  export declare function resolvePluginToolPaths(plugin: InstalledPlugin): string[];
8
18
  export declare function resolvePluginHookPaths(plugin: InstalledPlugin): string[];
9
19
  export declare function resolvePluginCommandPaths(plugin: InstalledPlugin): string[];
@@ -49,6 +49,36 @@ declare const todoWriteSchema: z.ZodObject<{
49
49
  type TodoWriteParams = z.infer<typeof todoWriteSchema>;
50
50
  export declare const USER_TODO_EDIT_CUSTOM_TYPE = "user_todo_edit";
51
51
  export declare function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPhase[];
52
+ /**
53
+ * Pick the actionable window of tasks to display in the sticky todo panel.
54
+ *
55
+ * Returns up to `maxVisible` open (pending / in_progress) tasks in their
56
+ * original phase order, plus the count of remaining open tasks not shown so
57
+ * the caller can render a `+N more` hint. When every task in `tasks` is
58
+ * closed (completed or abandoned), returns the trailing `maxVisible` tasks
59
+ * with `hiddenOpenCount = 0`, so the panel keeps useful context until the
60
+ * active-phase pointer advances on the next `todo_write`.
61
+ *
62
+ * Task identity and order are preserved — this is a slice, never a sort.
63
+ */
64
+ export declare function selectStickyTodoWindow(tasks: TodoItem[], maxVisible?: number): {
65
+ visible: TodoItem[];
66
+ hiddenOpenCount: number;
67
+ };
68
+ /**
69
+ * Report whether `content` likely names the same work as any entry in
70
+ * `descriptions`. Used by the sticky todo panel to light up a pending todo
71
+ * when an in-flight subagent is doing the work for it, without requiring
72
+ * the caller to flip the todo's status.
73
+ *
74
+ * Matching is normalize-then-equal first (lowercased; punctuation and
75
+ * whitespace runs both collapsed to a single space; trimmed), with a
76
+ * substring fallback in either direction so minor wording drift
77
+ * ("Sonnet #2: bug scan" vs "Sonnet #2") still links up. The substring
78
+ * fallback requires at least {@link TODO_DESCRIPTION_MIN_OVERLAP} chars on
79
+ * the contained side.
80
+ */
81
+ export declare function todoMatchesAnyDescription(content: string, descriptions: readonly string[]): boolean;
52
82
  /** Apply an array of `todo_write`-style ops to existing phases. Used by /todo slash command. */
53
83
  export declare function applyOpsToPhases(currentPhases: TodoPhase[], ops: TodoWriteParams["ops"]): {
54
84
  phases: TodoPhase[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.5.11",
4
+ "version": "15.5.13",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,13 +47,13 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.5.11",
51
- "@oh-my-pi/omp-stats": "15.5.11",
52
- "@oh-my-pi/pi-agent-core": "15.5.11",
53
- "@oh-my-pi/pi-ai": "15.5.11",
54
- "@oh-my-pi/pi-natives": "15.5.11",
55
- "@oh-my-pi/pi-tui": "15.5.11",
56
- "@oh-my-pi/pi-utils": "15.5.11",
50
+ "@oh-my-pi/hashline": "15.5.13",
51
+ "@oh-my-pi/omp-stats": "15.5.13",
52
+ "@oh-my-pi/pi-agent-core": "15.5.13",
53
+ "@oh-my-pi/pi-ai": "15.5.13",
54
+ "@oh-my-pi/pi-natives": "15.5.13",
55
+ "@oh-my-pi/pi-tui": "15.5.13",
56
+ "@oh-my-pi/pi-utils": "15.5.13",
57
57
  "@puppeteer/browsers": "^2.13.0",
58
58
  "@types/turndown": "5.0.6",
59
59
  "@xterm/headless": "^6.0.0",
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Top-level CLI command table.
3
+ *
4
+ * Lives in its own module (importable without side effects) so that tests can
5
+ * inspect the registered subcommands without triggering the side-effectful
6
+ * top-level await in `cli.ts`. Adding a new subcommand here is enough to make
7
+ * `runCli` route to it instead of forwarding the argv as a prompt to
8
+ * `launch` — see #1496 for the original "args silently leak to the LLM"
9
+ * regression that motivated the split.
10
+ */
11
+ import type { CommandEntry } from "@oh-my-pi/pi-utils/cli";
12
+
13
+ export const commands: CommandEntry[] = [
14
+ { name: "launch", load: () => import("./commands/launch").then(m => m.default) },
15
+ { name: "acp", load: () => import("./commands/acp").then(m => m.default) },
16
+ { name: "auth-broker", load: () => import("./commands/auth-broker").then(m => m.default) },
17
+ { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) },
18
+ { name: "agents", load: () => import("./commands/agents").then(m => m.default) },
19
+ { name: "commit", load: () => import("./commands/commit").then(m => m.default) },
20
+ { name: "config", load: () => import("./commands/config").then(m => m.default) },
21
+ { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
22
+ { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
23
+ { name: "install", load: () => import("./commands/install").then(m => m.default) },
24
+ { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
25
+ { name: "setup", load: () => import("./commands/setup").then(m => m.default) },
26
+ { name: "shell", load: () => import("./commands/shell").then(m => m.default) },
27
+ { name: "read", load: () => import("./commands/read").then(m => m.default) },
28
+ { name: "ssh", load: () => import("./commands/ssh").then(m => m.default) },
29
+ { name: "stats", load: () => import("./commands/stats").then(m => m.default) },
30
+ { name: "update", load: () => import("./commands/update").then(m => m.default) },
31
+ { name: "worktree", load: () => import("./commands/worktree").then(m => m.default), aliases: ["wt"] },
32
+ { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
33
+ ];
34
+
35
+ /**
36
+ * Return true when `first` matches a registered subcommand name or alias.
37
+ *
38
+ * Flags (`-…`) and `@file` arguments are never subcommands; for those the CLI
39
+ * runner skips ahead to the default `launch` command.
40
+ */
41
+ export function isSubcommand(first: string | undefined): boolean {
42
+ if (!first || first.startsWith("-") || first.startsWith("@")) return false;
43
+ return commands.some(entry => entry.name === first || entry.aliases?.includes(first));
44
+ }
package/src/cli.ts CHANGED
@@ -10,7 +10,8 @@ procmgr.scrubProcessEnv();
10
10
  * CLI entry point — registers all commands explicitly and delegates to the
11
11
  * lightweight CLI runner from pi-utils.
12
12
  */
13
- import { type CliConfig, type CommandEntry, run } from "@oh-my-pi/pi-utils/cli";
13
+ import { type CliConfig, run } from "@oh-my-pi/pi-utils/cli";
14
+ import { commands, isSubcommand } from "./cli-commands";
14
15
 
15
16
  if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
16
17
  process.stderr.write(
@@ -21,27 +22,6 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
21
22
 
22
23
  process.title = APP_NAME;
23
24
 
24
- const commands: CommandEntry[] = [
25
- { name: "launch", load: () => import("./commands/launch").then(m => m.default) },
26
- { name: "acp", load: () => import("./commands/acp").then(m => m.default) },
27
- { name: "auth-broker", load: () => import("./commands/auth-broker").then(m => m.default) },
28
- { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) },
29
- { name: "agents", load: () => import("./commands/agents").then(m => m.default) },
30
- { name: "commit", load: () => import("./commands/commit").then(m => m.default) },
31
- { name: "config", load: () => import("./commands/config").then(m => m.default) },
32
- { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
33
- { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
34
- { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
35
- { name: "setup", load: () => import("./commands/setup").then(m => m.default) },
36
- { name: "shell", load: () => import("./commands/shell").then(m => m.default) },
37
- { name: "read", load: () => import("./commands/read").then(m => m.default) },
38
- { name: "ssh", load: () => import("./commands/ssh").then(m => m.default) },
39
- { name: "stats", load: () => import("./commands/stats").then(m => m.default) },
40
- { name: "update", load: () => import("./commands/update").then(m => m.default) },
41
- { name: "worktree", load: () => import("./commands/worktree").then(m => m.default), aliases: ["wt"] },
42
- { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
43
- ];
44
-
45
25
  async function showHelp(config: CliConfig): Promise<void> {
46
26
  const { renderRootHelp } = await import("@oh-my-pi/pi-utils/cli");
47
27
  const { getExtraHelpText } = await import("./cli/args");
@@ -51,16 +31,6 @@ async function showHelp(config: CliConfig): Promise<void> {
51
31
  process.stdout.write(`\n${extra}\n`);
52
32
  }
53
33
  }
54
-
55
- /**
56
- * Determine whether argv[0] is a known subcommand name.
57
- * If not, the entire argv is treated as args to the default "launch" command.
58
- */
59
- function isSubcommand(first: string | undefined): boolean {
60
- if (!first || first.startsWith("-") || first.startsWith("@")) return false;
61
- return commands.some(e => e.name === first || e.aliases?.includes(first));
62
- }
63
-
64
34
  /**
65
35
  * Smoke-test entry. Spawns the stats sync worker, pings it, exits.
66
36
  *
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `omp install <target>` — top-level convenience over `omp plugin install` /
3
+ * `omp plugin link`.
4
+ *
5
+ * The docs (omp.sh/docs/extension-authoring) advertise
6
+ *
7
+ * omp install ./my-extension
8
+ *
9
+ * as a third loading mechanism that "symlinks the directory into the plugin
10
+ * set and watches it for changes". Before this command existed, `install` was
11
+ * not a registered subcommand, so the CLI runner forwarded the argv to the
12
+ * default `launch` command and the model received `install ./my-extension`
13
+ * as an initial prompt — see #1496.
14
+ *
15
+ * Local-path targets (`./foo`, `/abs/foo`, `~/foo`, or an existing directory)
16
+ * route to `plugin link` so they are symlinked into the plugin set, matching
17
+ * the documented behavior. Everything else (`pkg`, `pkg@1.2.3`,
18
+ * `name@marketplace`) routes to `plugin install`.
19
+ */
20
+
21
+ import { existsSync } from "node:fs";
22
+ import * as path from "node:path";
23
+ import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
24
+ import { type PluginAction, type PluginCommandArgs, runPluginCommand } from "../cli/plugin-cli";
25
+ import { initTheme } from "../modes/theme/theme";
26
+
27
+ /**
28
+ * Heuristic used to decide whether `omp install <target>` should `link` a
29
+ * local directory or `install` a remote spec. Exported for tests.
30
+ */
31
+ export function looksLikeLocalPath(target: string): boolean {
32
+ if (target.startsWith(".") || target.startsWith("/") || target.startsWith("~")) return true;
33
+ // Windows drive prefix (e.g. `C:\foo`).
34
+ if (/^[a-zA-Z]:[\\/]/.test(target)) return true;
35
+ // Bare names that happen to exist as a local directory.
36
+ try {
37
+ return existsSync(path.resolve(target));
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export default class Install extends Command {
44
+ static description = "Install or link an extension package (alias of `plugin install`/`plugin link`)";
45
+
46
+ static args = {
47
+ targets: Args.string({
48
+ description: "Local path, npm spec, or marketplace ref (e.g. ./my-ext, my-pkg@1.2.3, name@marketplace)",
49
+ required: false,
50
+ multiple: true,
51
+ }),
52
+ };
53
+
54
+ static flags = {
55
+ json: Flags.boolean({ description: "Output JSON" }),
56
+ force: Flags.boolean({ description: "Force install" }),
57
+ "dry-run": Flags.boolean({ description: "Show actions without applying changes" }),
58
+ scope: Flags.string({
59
+ description: 'Install scope: "user" (default) or "project" (marketplace installs only)',
60
+ options: ["user", "project"],
61
+ }),
62
+ };
63
+
64
+ async run(): Promise<void> {
65
+ const { args, flags } = await this.parse(Install);
66
+ const targets = Array.isArray(args.targets) ? args.targets : args.targets ? [args.targets] : [];
67
+
68
+ if (targets.length === 0) {
69
+ process.stderr.write("Usage: omp install <path | npm-spec | name@marketplace> [...]\n");
70
+ process.exit(1);
71
+ }
72
+
73
+ await initTheme();
74
+
75
+ // Split into local-paths (→ link) and remote specs (→ install). Each batch
76
+ // preserves user-supplied order so progress output reads naturally.
77
+ const localPaths: string[] = [];
78
+ const remoteSpecs: string[] = [];
79
+ for (const target of targets) {
80
+ if (looksLikeLocalPath(target)) localPaths.push(target);
81
+ else remoteSpecs.push(target);
82
+ }
83
+
84
+ const baseFlags: PluginCommandArgs["flags"] = {
85
+ json: flags.json,
86
+ force: flags.force,
87
+ dryRun: flags["dry-run"],
88
+ scope: flags.scope as "user" | "project" | undefined,
89
+ };
90
+
91
+ for (const localPath of localPaths) {
92
+ await runPluginCommand({
93
+ action: "link" satisfies PluginAction,
94
+ args: [localPath],
95
+ flags: baseFlags,
96
+ });
97
+ }
98
+
99
+ if (remoteSpecs.length > 0) {
100
+ await runPluginCommand({
101
+ action: "install" satisfies PluginAction,
102
+ args: remoteSpecs,
103
+ flags: baseFlags,
104
+ });
105
+ }
106
+ }
107
+ }
@@ -32,6 +32,7 @@ import "./gemini";
32
32
  import "./opencode";
33
33
  import "./github";
34
34
  import "./mcp-json";
35
+ import "./omp-plugins";
35
36
  import "./ssh";
36
37
  import "./vscode";
37
38
  import "./windsurf";