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