@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.
@@ -0,0 +1,383 @@
1
+ /**
2
+ * OMP extension-package sub-discovery provider.
3
+ *
4
+ * When a user configures an extension via `extensions:` (in settings) or
5
+ * `--extension`/`-e` (on the CLI), the docs promise that the package's
6
+ * sibling directories — `skills/`, `hooks/pre|post/`, `tools/`, `commands/`,
7
+ * `rules/`, `prompts/`, and `.mcp.json` — are picked up by omp's standard
8
+ * discovery surfaces. The native `omp` provider in `builtin.ts` only walks
9
+ * `.omp/` and `~/.omp/agent/`, so without this provider those sub-trees are
10
+ * silently ignored.
11
+ *
12
+ * Provider priority is set below the native `omp` provider (100) so an
13
+ * extension package never shadows the user's own `.omp/` configuration on
14
+ * dedup.
15
+ *
16
+ * @see ./omp-extension-roots.ts
17
+ * @see ../../docs/extension-loading.md
18
+ */
19
+ import * as path from "node:path";
20
+ import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
21
+ import { registerProvider } from "../capability";
22
+ import { readDirEntries, readFile } from "../capability/fs";
23
+ import { type Hook, hookCapability } from "../capability/hook";
24
+ import { type MCPServer, mcpCapability } from "../capability/mcp";
25
+ import { type Prompt, promptCapability } from "../capability/prompt";
26
+ import { type Rule, ruleCapability } from "../capability/rule";
27
+ import { type Skill, skillCapability } from "../capability/skill";
28
+ import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
29
+ import { type CustomTool, toolCapability } from "../capability/tool";
30
+ import type { LoadContext, LoadResult } from "../capability/types";
31
+ import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
32
+ import { listOmpExtensionRoots, type OmpExtensionRoot } from "./omp-extension-roots";
33
+
34
+ const PROVIDER_ID = "omp-plugins";
35
+ const DISPLAY_NAME = "OMP Extension Packages";
36
+ const DESCRIPTION =
37
+ "Sub-discovery (skills, hooks, tools, commands, rules, prompts, .mcp.json) inside extension packages";
38
+ const PRIORITY = 90;
39
+
40
+ // =============================================================================
41
+ // Skills
42
+ // =============================================================================
43
+
44
+ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
45
+ const roots = await listOmpExtensionRoots(ctx);
46
+ const results = await Promise.all(
47
+ roots.map(root =>
48
+ scanSkillsFromDir(ctx, {
49
+ dir: path.join(root.path, "skills"),
50
+ providerId: PROVIDER_ID,
51
+ level: root.level,
52
+ requireDescription: true,
53
+ }),
54
+ ),
55
+ );
56
+ return {
57
+ items: results.flatMap(r => r.items),
58
+ warnings: results.flatMap(r => r.warnings ?? []),
59
+ };
60
+ }
61
+
62
+ // =============================================================================
63
+ // Slash Commands
64
+ // =============================================================================
65
+
66
+ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
67
+ const roots = await listOmpExtensionRoots(ctx);
68
+ const results = await Promise.all(
69
+ roots.map(root =>
70
+ loadFilesFromDir<SlashCommand>(ctx, path.join(root.path, "commands"), PROVIDER_ID, root.level, {
71
+ extensions: ["md"],
72
+ transform: (name, content, filePath, source) => ({
73
+ name: name.replace(/\.md$/, ""),
74
+ path: filePath,
75
+ content,
76
+ level: root.level,
77
+ _source: source,
78
+ }),
79
+ }),
80
+ ),
81
+ );
82
+ return {
83
+ items: results.flatMap(r => r.items),
84
+ warnings: results.flatMap(r => r.warnings ?? []),
85
+ };
86
+ }
87
+
88
+ // =============================================================================
89
+ // Rules
90
+ // =============================================================================
91
+
92
+ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
93
+ const roots = await listOmpExtensionRoots(ctx);
94
+ const results = await Promise.all(
95
+ roots.map(root =>
96
+ loadFilesFromDir<Rule>(ctx, path.join(root.path, "rules"), PROVIDER_ID, root.level, {
97
+ extensions: ["md", "mdc"],
98
+ transform: (name, content, filePath, source) =>
99
+ buildRuleFromMarkdown(name, content, filePath, source, { stripNamePattern: /\.(md|mdc)$/ }),
100
+ }),
101
+ ),
102
+ );
103
+ return {
104
+ items: results.flatMap(r => r.items),
105
+ warnings: results.flatMap(r => r.warnings ?? []),
106
+ };
107
+ }
108
+
109
+ // =============================================================================
110
+ // Prompts
111
+ // =============================================================================
112
+
113
+ async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
114
+ const roots = await listOmpExtensionRoots(ctx);
115
+ const results = await Promise.all(
116
+ roots.map(root =>
117
+ loadFilesFromDir<Prompt>(ctx, path.join(root.path, "prompts"), PROVIDER_ID, root.level, {
118
+ extensions: ["md"],
119
+ transform: (name, content, filePath, source) => ({
120
+ name: name.replace(/\.md$/, ""),
121
+ path: filePath,
122
+ content,
123
+ _source: source,
124
+ }),
125
+ }),
126
+ ),
127
+ );
128
+ return {
129
+ items: results.flatMap(r => r.items),
130
+ warnings: results.flatMap(r => r.warnings ?? []),
131
+ };
132
+ }
133
+
134
+ // =============================================================================
135
+ // Hooks
136
+ // =============================================================================
137
+
138
+ const HOOK_TYPES: ReadonlyArray<"pre" | "post"> = ["pre", "post"];
139
+
140
+ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
141
+ const roots = await listOmpExtensionRoots(ctx);
142
+ const tasks: Array<{ root: OmpExtensionRoot; hookType: "pre" | "post" }> = [];
143
+ for (const root of roots) {
144
+ for (const hookType of HOOK_TYPES) {
145
+ tasks.push({ root, hookType });
146
+ }
147
+ }
148
+ const results = await Promise.all(
149
+ tasks.map(({ root, hookType }) =>
150
+ loadFilesFromDir<Hook>(ctx, path.join(root.path, "hooks", hookType), PROVIDER_ID, root.level, {
151
+ transform: (name, _content, filePath, source) => {
152
+ const baseName = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name;
153
+ const tool = baseName === "*" ? "*" : baseName;
154
+ return {
155
+ name,
156
+ path: filePath,
157
+ type: hookType,
158
+ tool,
159
+ level: root.level,
160
+ _source: source,
161
+ };
162
+ },
163
+ }),
164
+ ),
165
+ );
166
+ return {
167
+ items: results.flatMap(r => r.items),
168
+ warnings: results.flatMap(r => r.warnings ?? []),
169
+ };
170
+ }
171
+
172
+ // =============================================================================
173
+ // Custom Tools
174
+ // =============================================================================
175
+
176
+ const TOOL_EXTENSIONS = ["json", "md", "ts", "js", "sh", "bash", "py"];
177
+
178
+ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
179
+ const roots = await listOmpExtensionRoots(ctx);
180
+ const perRoot = await Promise.all(
181
+ roots.map(async root => {
182
+ const toolsDir = path.join(root.path, "tools");
183
+ const [filesResult, entries] = await Promise.all([
184
+ loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, root.level, {
185
+ extensions: TOOL_EXTENSIONS,
186
+ transform: (name, content, filePath, source) => {
187
+ if (name.endsWith(".json")) {
188
+ const data = tryParseJson<{ name?: string; description?: string }>(content);
189
+ const toolName = data?.name || name.replace(/\.json$/, "");
190
+ const description =
191
+ typeof data?.description === "string" && data.description.trim()
192
+ ? data.description
193
+ : `${toolName} custom tool`;
194
+ return { name: toolName, path: filePath, description, level: root.level, _source: source };
195
+ }
196
+ if (name.endsWith(".md")) {
197
+ const { frontmatter } = parseFrontmatter(content, { source: filePath });
198
+ const toolName = (frontmatter.name as string) || name.replace(/\.md$/, "");
199
+ const description =
200
+ typeof frontmatter.description === "string" && frontmatter.description.trim()
201
+ ? String(frontmatter.description)
202
+ : `${toolName} custom tool`;
203
+ return { name: toolName, path: filePath, description, level: root.level, _source: source };
204
+ }
205
+ const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
206
+ return {
207
+ name: toolName,
208
+ path: filePath,
209
+ description: `${toolName} custom tool`,
210
+ level: root.level,
211
+ _source: source,
212
+ };
213
+ },
214
+ }),
215
+ readDirEntries(toolsDir),
216
+ ]);
217
+
218
+ // `<tools>/<name>/index.ts` sub-directory tools, mirroring `builtin.ts:loadTools`.
219
+ const indexCandidates = entries
220
+ .filter(e => !e.name.startsWith(".") && e.isDirectory())
221
+ .map(e => path.join(toolsDir, e.name, "index.ts"));
222
+ const indexContents = await Promise.all(indexCandidates.map(p => readFile(p)));
223
+ const indexItems: CustomTool[] = [];
224
+ for (let i = 0; i < indexCandidates.length; i++) {
225
+ if (indexContents[i] === null) continue;
226
+ const indexPath = indexCandidates[i];
227
+ const toolName = path.basename(path.dirname(indexPath));
228
+ indexItems.push({
229
+ name: toolName,
230
+ path: indexPath,
231
+ description: `${toolName} custom tool`,
232
+ level: root.level,
233
+ _source: createSourceMeta(PROVIDER_ID, indexPath, root.level),
234
+ });
235
+ }
236
+
237
+ return { filesResult, indexItems };
238
+ }),
239
+ );
240
+
241
+ const items: CustomTool[] = [];
242
+ const warnings: string[] = [];
243
+ for (const { filesResult, indexItems } of perRoot) {
244
+ items.push(...filesResult.items, ...indexItems);
245
+ if (filesResult.warnings) warnings.push(...filesResult.warnings);
246
+ }
247
+ return { items, warnings };
248
+ }
249
+
250
+ // =============================================================================
251
+ // MCP Servers
252
+ // =============================================================================
253
+
254
+ const MCP_FILENAMES = [".mcp.json", "mcp.json"] as const;
255
+
256
+ interface RawMcpServer {
257
+ enabled?: boolean;
258
+ timeout?: number;
259
+ command?: string;
260
+ args?: string[];
261
+ env?: Record<string, string>;
262
+ cwd?: string;
263
+ url?: string;
264
+ headers?: Record<string, string>;
265
+ auth?: MCPServer["auth"];
266
+ oauth?: MCPServer["oauth"];
267
+ type?: MCPServer["transport"];
268
+ }
269
+
270
+ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
271
+ const roots = await listOmpExtensionRoots(ctx);
272
+ const items: MCPServer[] = [];
273
+ const warnings: string[] = [];
274
+
275
+ const tasks: Array<{ root: OmpExtensionRoot; mcpPath: string }> = [];
276
+ for (const root of roots) {
277
+ for (const filename of MCP_FILENAMES) {
278
+ tasks.push({ root, mcpPath: path.join(root.path, filename) });
279
+ }
280
+ }
281
+ const contents = await Promise.all(tasks.map(({ mcpPath }) => readFile(mcpPath)));
282
+
283
+ for (let i = 0; i < tasks.length; i++) {
284
+ const raw = contents[i];
285
+ if (raw === null) continue;
286
+ const { root, mcpPath } = tasks[i];
287
+
288
+ const parsed = tryParseJson<{ mcpServers?: Record<string, unknown> }>(raw);
289
+ if (!parsed) {
290
+ warnings.push(`[omp-plugins] Invalid JSON in ${mcpPath}`);
291
+ logger.warn(`[omp-plugins] Invalid JSON in ${mcpPath}`);
292
+ continue;
293
+ }
294
+ const servers = parsed.mcpServers;
295
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) continue;
296
+
297
+ for (const [serverName, serverCfg] of Object.entries(servers)) {
298
+ if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
299
+ const cfg = serverCfg as RawMcpServer;
300
+ if (typeof cfg.command !== "string" && typeof cfg.url !== "string") {
301
+ warnings.push(`[omp-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
302
+ continue;
303
+ }
304
+ items.push({
305
+ name: serverName,
306
+ ...(cfg.enabled !== undefined && { enabled: cfg.enabled }),
307
+ ...(cfg.timeout !== undefined && { timeout: cfg.timeout }),
308
+ ...(cfg.command !== undefined && { command: cfg.command }),
309
+ ...(cfg.args !== undefined && { args: cfg.args }),
310
+ ...(cfg.env !== undefined && { env: cfg.env }),
311
+ ...(cfg.cwd !== undefined && { cwd: cfg.cwd }),
312
+ ...(cfg.url !== undefined && { url: cfg.url }),
313
+ ...(cfg.headers !== undefined && { headers: cfg.headers }),
314
+ ...(cfg.auth !== undefined && { auth: cfg.auth }),
315
+ ...(cfg.oauth !== undefined && { oauth: cfg.oauth }),
316
+ ...(cfg.type !== undefined && { transport: cfg.type }),
317
+ _source: createSourceMeta(PROVIDER_ID, mcpPath, root.level),
318
+ });
319
+ }
320
+ }
321
+
322
+ return { items, warnings };
323
+ }
324
+
325
+ // =============================================================================
326
+ // Provider Registration
327
+ // =============================================================================
328
+
329
+ registerProvider<Skill>(skillCapability.id, {
330
+ id: PROVIDER_ID,
331
+ displayName: DISPLAY_NAME,
332
+ description: DESCRIPTION,
333
+ priority: PRIORITY,
334
+ load: loadSkills,
335
+ });
336
+
337
+ registerProvider<SlashCommand>(slashCommandCapability.id, {
338
+ id: PROVIDER_ID,
339
+ displayName: DISPLAY_NAME,
340
+ description: DESCRIPTION,
341
+ priority: PRIORITY,
342
+ load: loadSlashCommands,
343
+ });
344
+
345
+ registerProvider<Rule>(ruleCapability.id, {
346
+ id: PROVIDER_ID,
347
+ displayName: DISPLAY_NAME,
348
+ description: DESCRIPTION,
349
+ priority: PRIORITY,
350
+ load: loadRules,
351
+ });
352
+
353
+ registerProvider<Prompt>(promptCapability.id, {
354
+ id: PROVIDER_ID,
355
+ displayName: DISPLAY_NAME,
356
+ description: DESCRIPTION,
357
+ priority: PRIORITY,
358
+ load: loadPrompts,
359
+ });
360
+
361
+ registerProvider<Hook>(hookCapability.id, {
362
+ id: PROVIDER_ID,
363
+ displayName: DISPLAY_NAME,
364
+ description: DESCRIPTION,
365
+ priority: PRIORITY,
366
+ load: loadHooks,
367
+ });
368
+
369
+ registerProvider<CustomTool>(toolCapability.id, {
370
+ id: PROVIDER_ID,
371
+ displayName: DISPLAY_NAME,
372
+ description: DESCRIPTION,
373
+ priority: PRIORITY,
374
+ load: loadTools,
375
+ });
376
+
377
+ registerProvider<MCPServer>(mcpCapability.id, {
378
+ id: PROVIDER_ID,
379
+ displayName: DISPLAY_NAME,
380
+ description: DESCRIPTION,
381
+ priority: PRIORITY,
382
+ load: loadMCPServers,
383
+ });
@@ -19,9 +19,14 @@ installLegacyPiSpecifierShim();
19
19
 
20
20
  /**
21
21
  * Load plugin runtime config from lock file.
22
+ *
23
+ * `home` controls which `<plugins>/omp-plugins.lock.json` is read — pass it
24
+ * through whenever the caller is loading plugins for a tempdir-rooted
25
+ * scenario (tests, discovery sub-surfaces that need to mirror an alternate
26
+ * `LoadContext.home`).
22
27
  */
23
- async function loadRuntimeConfig(): Promise<PluginRuntimeConfig> {
24
- const lockPath = getPluginsLockfile();
28
+ async function loadRuntimeConfig(home?: string): Promise<PluginRuntimeConfig> {
29
+ const lockPath = getPluginsLockfile(home);
25
30
  try {
26
31
  return await Bun.file(lockPath).json();
27
32
  } catch (err) {
@@ -46,44 +51,64 @@ async function loadProjectOverrides(cwd: string): Promise<ProjectPluginOverrides
46
51
  }
47
52
  /**
48
53
  * Get list of enabled plugins with their resolved configurations.
49
- * Respects both global runtime config and project overrides.
54
+ *
55
+ * Respects both global runtime config and project overrides. Iterates the
56
+ * union of `<plugins>/package.json#dependencies` (`bun install`-installed
57
+ * packages) and `<plugins>/omp-plugins.lock.json#plugins` (so locally
58
+ * `plugin link`-symlinked extensions, which never get a dependency entry,
59
+ * are still discovered). The optional `home` parameter pins the plugins
60
+ * root for callers that need to enumerate plugins relative to a non-default
61
+ * home (tests with a tempdir, discovery loaders threaded with
62
+ * `LoadContext.home`).
50
63
  */
51
- export async function getEnabledPlugins(cwd: string): Promise<InstalledPlugin[]> {
52
- const pkgJsonPath = getPluginsPackageJson();
53
- let pkg: { dependencies?: Record<string, string> };
54
- try {
55
- pkg = await Bun.file(pkgJsonPath).json();
56
- } catch (err) {
57
- if (isEnoent(err)) return [];
58
- throw err;
59
- }
64
+ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise<InstalledPlugin[]> {
65
+ const { home } = opts;
60
66
 
61
- const nodeModulesPath = getPluginsNodeModules();
67
+ const nodeModulesPath = getPluginsNodeModules(home);
62
68
  if (!fs.existsSync(nodeModulesPath)) {
63
69
  return [];
64
70
  }
65
71
 
66
- const deps = pkg.dependencies || {};
67
- const runtimeConfig = await loadRuntimeConfig();
72
+ let depsKeys: string[] = [];
73
+ const pkgJsonPath = getPluginsPackageJson(home);
74
+ try {
75
+ const pkg: { dependencies?: Record<string, string> } = await Bun.file(pkgJsonPath).json();
76
+ depsKeys = Object.keys(pkg.dependencies ?? {});
77
+ } catch (err) {
78
+ // Linked-only setups may have no `<plugins>/package.json` yet — that's
79
+ // fine, the lockfile still records the link.
80
+ if (!isEnoent(err)) throw err;
81
+ }
82
+
83
+ const runtimeConfig = await loadRuntimeConfig(home);
68
84
  const projectOverrides = await loadProjectOverrides(cwd);
85
+
86
+ // Union: dependencies (npm/marketplace installs) ∪ runtime-config plugins
87
+ // (links + already-recorded installs). Set preserves first-seen order,
88
+ // putting deps before link-only entries for deterministic output.
89
+ const names = new Set<string>(depsKeys);
90
+ for (const name of Object.keys(runtimeConfig.plugins ?? {})) {
91
+ names.add(name);
92
+ }
93
+
69
94
  const plugins: InstalledPlugin[] = [];
70
- for (const [name] of Object.entries(deps)) {
95
+ for (const name of names) {
71
96
  const pluginPkgPath = path.join(nodeModulesPath, name, "package.json");
72
97
  let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest };
73
98
  try {
74
99
  pluginPkg = await Bun.file(pluginPkgPath).json();
75
100
  } catch (err) {
101
+ // Lockfile entry without a corresponding node_modules tree means the
102
+ // link was deleted out from under us; skip silently.
76
103
  if (isEnoent(err)) continue;
77
104
  throw err;
78
105
  }
79
106
 
80
107
  const manifest: PluginManifest | undefined = pluginPkg.omp || pluginPkg.pi;
81
-
82
108
  if (!manifest) {
83
109
  // Not an omp plugin, skip
84
110
  continue;
85
111
  }
86
-
87
112
  manifest.version = pluginPkg.version;
88
113
 
89
114
  const runtimeState = runtimeConfig.plugins[name];
package/src/main.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  preloadPluginRoots,
37
37
  resolveActiveProjectRegistryPath,
38
38
  } from "./discovery/helpers";
39
+ import { injectOmpExtensionCliRoots } from "./discovery/omp-extension-roots";
39
40
  import { exportFromFile } from "./export/html";
40
41
  import type { ExtensionUIContext } from "./extensibility/extensions/types";
41
42
  import {
@@ -768,6 +769,17 @@ export async function runRootCommand(
768
769
  // warning before we reach the await site below.
769
770
  pluginPreloadPromise.catch(() => {});
770
771
 
772
+ // Register CLI-provided extension package paths (`--extension`, `--hook`) so
773
+ // the `omp-plugins` discovery provider can surface their `skills/`, `hooks/`,
774
+ // `tools/`, `commands/`, `rules/`, `prompts/`, and `.mcp.json` sub-trees.
775
+ // `--no-extensions` short-circuits both the factory load and the sub-discovery.
776
+ if (!parsedArgs.noExtensions) {
777
+ const cliExtensions = [...(parsedArgs.extensions ?? []), ...(parsedArgs.hooks ?? [])];
778
+ if (cliExtensions.length > 0) {
779
+ injectOmpExtensionCliRoots(cliExtensions, home, getProjectDir());
780
+ }
781
+ }
782
+
771
783
  const cwd = getProjectDir();
772
784
  const settingsInstance = deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd }));
773
785
  if (parsedArgs.approvalMode) {