@oh-my-pi/pi-coding-agent 15.5.10 → 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 +34 -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/legacy-pi-coding-agent-shim.d.ts +14 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/extensibility/plugins/loader.d.ts +12 -2
- package/dist/types/index.d.ts +3 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/ultrathink.d.ts +10 -0
- package/dist/types/session/redis-session-storage.d.ts +124 -0
- package/dist/types/session/sql-session-storage.d.ts +141 -0
- package/dist/types/tools/todo-write.d.ts +30 -0
- package/examples/sdk/12-redis-sessions.ts +54 -0
- package/examples/sdk/13-sql-sessions.ts +61 -0
- package/package.json +8 -8
- package/scripts/build-binary.ts +14 -9
- 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/legacy-pi-coding-agent-shim.ts +15 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +63 -22
- package/src/extensibility/plugins/loader.ts +43 -18
- package/src/index.ts +3 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/main.ts +12 -0
- package/src/memories/index.ts +8 -3
- package/src/modes/components/custom-editor.ts +3 -0
- package/src/modes/interactive-mode.ts +243 -12
- package/src/modes/ultrathink.ts +79 -0
- package/src/prompts/system/ultrathink-notice.md +3 -0
- package/src/session/agent-session.ts +28 -0
- package/src/session/redis-session-storage.ts +481 -0
- package/src/session/sql-session-storage.ts +565 -0
- package/src/tools/read.ts +23 -6
- package/src/tools/todo-write.ts +64 -0
- package/src/tools/write.ts +40 -6
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL-Backed Sessions (PostgreSQL / MySQL / SQLite)
|
|
3
|
+
*
|
|
4
|
+
* Store session JSONL in a SQL database via `bun:sql`. One table, one row
|
|
5
|
+
* per session file — works against PostgreSQL, MySQL/MariaDB, and SQLite
|
|
6
|
+
* with the dialect picked automatically from the connection URL.
|
|
7
|
+
*
|
|
8
|
+
* Useful when:
|
|
9
|
+
* - sessions need to be queryable from existing analytics infra (just JOIN
|
|
10
|
+
* against the rest of your warehouse);
|
|
11
|
+
* - a managed Postgres/MySQL instance is already in place and adding Redis
|
|
12
|
+
* isn't worth the operational surface;
|
|
13
|
+
* - you want a single durable file at rest (SQLite) without coding directly
|
|
14
|
+
* against `bun:sqlite`.
|
|
15
|
+
*
|
|
16
|
+
* Tool artifacts and image blobs are out of scope: `ArtifactManager` /
|
|
17
|
+
* `BlobStore` keep writing to `~/.omp/agent/...`. Reach for object storage
|
|
18
|
+
* if you need those off-host too.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createAgentSession, SessionManager, SqlSessionStorage } from "@oh-my-pi/pi-coding-agent";
|
|
22
|
+
import { SQL } from "bun";
|
|
23
|
+
|
|
24
|
+
// Pick one — Bun.SQL auto-detects the dialect from the URL scheme.
|
|
25
|
+
//
|
|
26
|
+
// postgres://user:pass@host:5432/db
|
|
27
|
+
// mysql://user:pass@host:3306/db
|
|
28
|
+
// sqlite:/absolute/path/to/sessions.sqlite
|
|
29
|
+
// sqlite::memory: // ephemeral
|
|
30
|
+
const client = new SQL(process.env.SESSIONS_DB_URL ?? "sqlite::memory:");
|
|
31
|
+
|
|
32
|
+
// `create()` runs `CREATE TABLE IF NOT EXISTS` (with the right DDL for the
|
|
33
|
+
// dialect) and warms the in-memory mirror with every existing row.
|
|
34
|
+
const storage = await SqlSessionStorage.create({
|
|
35
|
+
client,
|
|
36
|
+
table: "omp_session_files", // optional, this is the default
|
|
37
|
+
// createTable: false, // set if migrations are owned elsewhere
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const sessionDir = "/sessions/my-project";
|
|
41
|
+
|
|
42
|
+
// 1) Fresh persistent session, JSONL backed by SQL.
|
|
43
|
+
const { session } = await createAgentSession({
|
|
44
|
+
sessionManager: SessionManager.create(process.cwd(), sessionDir, storage),
|
|
45
|
+
});
|
|
46
|
+
console.log(`New SQL session (${storage.adapter}):`, session.sessionFile);
|
|
47
|
+
|
|
48
|
+
// 2) Continue the most recent session for this `sessionDir`.
|
|
49
|
+
const { session: continued } = await createAgentSession({
|
|
50
|
+
sessionManager: await SessionManager.continueRecent(process.cwd(), sessionDir, storage),
|
|
51
|
+
});
|
|
52
|
+
console.log("Resumed:", continued.sessionFile);
|
|
53
|
+
|
|
54
|
+
// 3) Enumerate every session row under this directory prefix.
|
|
55
|
+
const sessions = await SessionManager.list(process.cwd(), sessionDir, storage);
|
|
56
|
+
console.log(`Found ${sessions.length} sessions under ${sessionDir}`);
|
|
57
|
+
|
|
58
|
+
// On graceful shutdown, drain any background writes the writer queued and
|
|
59
|
+
// close the connection.
|
|
60
|
+
await storage.drain();
|
|
61
|
+
await client.end?.();
|
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",
|
package/scripts/build-binary.ts
CHANGED
|
@@ -56,17 +56,22 @@ async function main(): Promise<void> {
|
|
|
56
56
|
"../stats/src/sync-worker.ts",
|
|
57
57
|
"./src/tools/browser/tab-worker-entry.ts",
|
|
58
58
|
"./src/eval/js/worker-entry.ts",
|
|
59
|
-
// Legacy pi-* extension compat
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// `legacy-pi-
|
|
59
|
+
// Legacy pi-* extension compat entrypoints served by
|
|
60
|
+
// `legacy-pi-compat.ts`. These are reached via computed bunfs paths
|
|
61
|
+
// (which `--compile`'s static analyzer cannot trace), so each must be
|
|
62
|
+
// listed here to land in bunfs at
|
|
63
|
+
// `/$bunfs/root/packages/<pkg>/<entry>.js`. The coding-agent's own
|
|
64
|
+
// `./src/index.ts` is intentionally NOT listed: bun --compile silently
|
|
65
|
+
// breaks the CLI entry when the same package's barrel appears as an
|
|
66
|
+
// extra entrypoint (issue #1474), so legacy `pi-coding-agent` imports
|
|
67
|
+
// resolve through `legacy-pi-coding-agent-shim.ts` instead.
|
|
68
|
+
"../agent/src/index.ts",
|
|
69
|
+
"../natives/native/index.js",
|
|
70
|
+
"../tui/src/index.ts",
|
|
71
|
+
"../utils/src/index.ts",
|
|
68
72
|
"./src/extensibility/typebox.ts",
|
|
69
73
|
"./src/extensibility/legacy-pi-ai-shim.ts",
|
|
74
|
+
"./src/extensibility/legacy-pi-coding-agent-shim.ts",
|
|
70
75
|
"--outfile",
|
|
71
76
|
"dist/omp",
|
|
72
77
|
],
|
|
@@ -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
|
+
}
|