@oh-my-pi/pi-coding-agent 15.5.11 → 15.5.12
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 +13 -0
- package/dist/types/cli-commands.d.ts +19 -0
- package/dist/types/commands/install.d.ts +51 -0
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/discovery/omp-extension-roots.d.ts +43 -0
- package/dist/types/discovery/omp-plugins.d.ts +1 -0
- package/dist/types/extensibility/plugins/loader.d.ts +12 -2
- package/dist/types/tools/todo-write.d.ts +30 -0
- package/package.json +8 -8
- package/src/cli-commands.ts +44 -0
- package/src/cli.ts +2 -32
- package/src/commands/install.ts +107 -0
- package/src/discovery/index.ts +1 -0
- package/src/discovery/omp-extension-roots.ts +190 -0
- package/src/discovery/omp-plugins.ts +383 -0
- package/src/extensibility/plugins/loader.ts +43 -18
- package/src/main.ts +12 -0
- package/src/modes/interactive-mode.ts +243 -12
- package/src/tools/todo-write.ts +64 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.5.12] - 2026-05-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 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)).
|
|
10
|
+
- 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)).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
5
18
|
## [15.5.11] - 2026-05-29
|
|
6
19
|
|
|
7
20
|
### 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
|
+
}
|
|
@@ -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 {};
|
|
@@ -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
|
-
*
|
|
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
|
|
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.
|
|
4
|
+
"version": "15.5.12",
|
|
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.
|
|
51
|
-
"@oh-my-pi/omp-stats": "15.5.
|
|
52
|
-
"@oh-my-pi/pi-agent-core": "15.5.
|
|
53
|
-
"@oh-my-pi/pi-ai": "15.5.
|
|
54
|
-
"@oh-my-pi/pi-natives": "15.5.
|
|
55
|
-
"@oh-my-pi/pi-tui": "15.5.
|
|
56
|
-
"@oh-my-pi/pi-utils": "15.5.
|
|
50
|
+
"@oh-my-pi/hashline": "15.5.12",
|
|
51
|
+
"@oh-my-pi/omp-stats": "15.5.12",
|
|
52
|
+
"@oh-my-pi/pi-agent-core": "15.5.12",
|
|
53
|
+
"@oh-my-pi/pi-ai": "15.5.12",
|
|
54
|
+
"@oh-my-pi/pi-natives": "15.5.12",
|
|
55
|
+
"@oh-my-pi/pi-tui": "15.5.12",
|
|
56
|
+
"@oh-my-pi/pi-utils": "15.5.12",
|
|
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,
|
|
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
|
+
}
|
package/src/discovery/index.ts
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OMP extension package roots.
|
|
3
|
+
*
|
|
4
|
+
* An "extension package root" is a directory configured via either
|
|
5
|
+
* `extensions:` in user/project settings or the `--extension`/`-e` CLI flag
|
|
6
|
+
* that points to a packaged extension on disk. The package's standard
|
|
7
|
+
* sub-directories (`skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
|
|
8
|
+
* `prompts/`, `.mcp.json`) are wired into discovery by `omp-plugins.ts`.
|
|
9
|
+
*
|
|
10
|
+
* CLI-provided paths are injected via {@link injectOmpExtensionCliRoots}
|
|
11
|
+
* before discovery runs; settings paths are read lazily from
|
|
12
|
+
* `<scope>/settings.json` in {@link listOmpExtensionRoots} to mirror what
|
|
13
|
+
* `loadExtensionModules` already does.
|
|
14
|
+
*
|
|
15
|
+
* @see ./omp-plugins.ts
|
|
16
|
+
* @see ./builtin.ts `loadExtensionModules`
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs/promises";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
21
|
+
import { readDirEntries, readFile } from "../capability/fs";
|
|
22
|
+
import type { LoadContext } from "../capability/types";
|
|
23
|
+
import { getEnabledPlugins } from "../extensibility/plugins/loader";
|
|
24
|
+
import { expandTilde } from "../tools/path-utils";
|
|
25
|
+
|
|
26
|
+
/** A resolved extension package directory wired into the discovery surfaces. */
|
|
27
|
+
export interface OmpExtensionRoot {
|
|
28
|
+
/** Absolute path to the package directory. */
|
|
29
|
+
path: string;
|
|
30
|
+
/** Stable display name (basename of the package directory). */
|
|
31
|
+
name: string;
|
|
32
|
+
/** Scope from which the path was sourced. */
|
|
33
|
+
level: "user" | "project";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface InjectedRoot {
|
|
37
|
+
path: string;
|
|
38
|
+
level: "user" | "project";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let injectedCliRoots: InjectedRoot[] = [];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register CLI-provided extension package paths (e.g. from `--extension`/`-e`)
|
|
45
|
+
* so the sub-discovery providers can find their sibling `skills/`, `hooks/`,
|
|
46
|
+
* etc. Paths that do not resolve to a directory are silently dropped — file
|
|
47
|
+
* entrypoints have no package sub-tree to scan.
|
|
48
|
+
*
|
|
49
|
+
* Call once during startup before any capability load. Repeated calls extend
|
|
50
|
+
* the registered set; {@link clearOmpExtensionCliRoots} resets for tests.
|
|
51
|
+
*/
|
|
52
|
+
export function injectOmpExtensionCliRoots(paths: readonly string[], home: string, cwd: string): void {
|
|
53
|
+
if (paths.length === 0) return;
|
|
54
|
+
const expanded = paths.map(raw => {
|
|
55
|
+
const tilde = expandTilde(raw, home);
|
|
56
|
+
return path.isAbsolute(tilde) ? tilde : path.resolve(cwd, tilde);
|
|
57
|
+
});
|
|
58
|
+
const merged = new Map<string, InjectedRoot>();
|
|
59
|
+
for (const root of injectedCliRoots) merged.set(root.path, root);
|
|
60
|
+
for (const resolved of expanded) {
|
|
61
|
+
// CLI scope mirrors how `--extension` is treated elsewhere — user-level overrides win.
|
|
62
|
+
if (!merged.has(resolved)) merged.set(resolved, { path: resolved, level: "user" });
|
|
63
|
+
}
|
|
64
|
+
injectedCliRoots = [...merged.values()];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Drop every CLI-injected root. Tests use this between cases. */
|
|
68
|
+
export function clearOmpExtensionCliRoots(): void {
|
|
69
|
+
injectedCliRoots = [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Inspect currently-injected CLI roots (read-only). Exposed for diagnostics + tests. */
|
|
73
|
+
export function getInjectedOmpExtensionCliRoots(): readonly OmpExtensionRoot[] {
|
|
74
|
+
return injectedCliRoots.map(({ path: p, level }) => ({ path: p, level, name: path.basename(p) }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ScopeDirs {
|
|
78
|
+
project: string;
|
|
79
|
+
user: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scopeDirs(ctx: LoadContext): ScopeDirs {
|
|
83
|
+
return {
|
|
84
|
+
project: path.join(ctx.cwd, ".omp"),
|
|
85
|
+
user: path.join(ctx.home, ".omp", "agent"),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readSettingsExtensions(settingsPath: string): Promise<string[]> {
|
|
90
|
+
const content = await readFile(settingsPath);
|
|
91
|
+
if (!content) return [];
|
|
92
|
+
const parsed = tryParseJson<{ extensions?: unknown }>(content);
|
|
93
|
+
const raw = parsed?.extensions;
|
|
94
|
+
if (!Array.isArray(raw)) return [];
|
|
95
|
+
return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveAgainst(raw: string, ctx: LoadContext): string {
|
|
99
|
+
const tilde = expandTilde(raw, ctx.home);
|
|
100
|
+
return path.isAbsolute(tilde) ? tilde : path.resolve(ctx.cwd, tilde);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function isDirectory(p: string): Promise<boolean> {
|
|
104
|
+
const entries = await readDirEntries(p);
|
|
105
|
+
if (entries.length > 0) return true;
|
|
106
|
+
// Empty directory still counts; cache returns [] for both empty and missing.
|
|
107
|
+
// Disambiguate with a single stat — only hit when the cached listing is empty.
|
|
108
|
+
try {
|
|
109
|
+
const stat = await fs.stat(p);
|
|
110
|
+
return stat.isDirectory();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (isEnoent(err)) return false;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve every configured extension package directory for the given context.
|
|
119
|
+
*
|
|
120
|
+
* Sources, in order of precedence (later entries with the same absolute path
|
|
121
|
+
* are dropped):
|
|
122
|
+
*
|
|
123
|
+
* 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
|
|
124
|
+
* 2. Project `<cwd>/.omp/settings.json#extensions`
|
|
125
|
+
* 3. User `~/.omp/agent/settings.json#extensions`
|
|
126
|
+
* 4. Enabled plugins installed under `<plugins>/node_modules/` (e.g. via
|
|
127
|
+
* `omp install <pkg>` / `omp plugin install` / `omp plugin link`)
|
|
128
|
+
*
|
|
129
|
+
* Only entries that resolve to a directory on disk are returned; file
|
|
130
|
+
* entrypoints contribute zero sub-discovery surface and are filtered out.
|
|
131
|
+
* Installed-plugin enumeration failures (missing lockfile, unreadable
|
|
132
|
+
* `package.json`, etc.) are logged at `debug` and degrade gracefully — the
|
|
133
|
+
* other sources still surface.
|
|
134
|
+
*/
|
|
135
|
+
export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtensionRoot[]> {
|
|
136
|
+
const { project, user } = scopeDirs(ctx);
|
|
137
|
+
const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([
|
|
138
|
+
readSettingsExtensions(path.join(project, "settings.json")),
|
|
139
|
+
readSettingsExtensions(path.join(user, "settings.json")),
|
|
140
|
+
listInstalledPluginRoots(ctx),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
const candidates: InjectedRoot[] = [
|
|
144
|
+
...injectedCliRoots,
|
|
145
|
+
...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })),
|
|
146
|
+
...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })),
|
|
147
|
+
...installedPlugins,
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
// First-seen-wins dedup preserves CLI > project-settings > user-settings > installed precedence.
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
const unique: InjectedRoot[] = [];
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
if (seen.has(candidate.path)) continue;
|
|
155
|
+
seen.add(candidate.path);
|
|
156
|
+
unique.push(candidate);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const directoryFlags = await Promise.all(unique.map(c => isDirectory(c.path)));
|
|
160
|
+
const roots: OmpExtensionRoot[] = [];
|
|
161
|
+
for (let i = 0; i < unique.length; i++) {
|
|
162
|
+
if (!directoryFlags[i]) continue;
|
|
163
|
+
const { path: p, level } = unique[i];
|
|
164
|
+
roots.push({ path: p, level, name: path.basename(p) });
|
|
165
|
+
}
|
|
166
|
+
return roots;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Enumerate every enabled installed plugin's package directory so its
|
|
171
|
+
* conventional `skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
|
|
172
|
+
* `prompts/`, and `.mcp.json` are wired into discovery — mirrors how
|
|
173
|
+
* `getAllPluginExtensionPaths` already feeds the extension factory loader.
|
|
174
|
+
*
|
|
175
|
+
* Marketplace and `omp plugin link` installs write to the plugin manager's
|
|
176
|
+
* `node_modules` (or symlink into it) rather than to `extensions:` in
|
|
177
|
+
* settings; without this branch the sub-discovery provider would still miss
|
|
178
|
+
* everything those install paths produce.
|
|
179
|
+
*/
|
|
180
|
+
async function listInstalledPluginRoots(ctx: LoadContext): Promise<InjectedRoot[]> {
|
|
181
|
+
try {
|
|
182
|
+
const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home });
|
|
183
|
+
// Installed plugins are always user-scope; project disablement is already
|
|
184
|
+
// honored by `getEnabledPlugins` via `loadProjectOverrides`.
|
|
185
|
+
return plugins.map(({ path: p }) => ({ path: p, level: "user" }));
|
|
186
|
+
} catch (err) {
|
|
187
|
+
logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) });
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|