@oh-my-pi/pi-coding-agent 1.337.0

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.
Files changed (224) hide show
  1. package/CHANGELOG.md +1228 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/custom-tools.md +541 -0
  5. package/docs/extension-loading.md +1004 -0
  6. package/docs/hooks.md +867 -0
  7. package/docs/rpc.md +1040 -0
  8. package/docs/sdk.md +994 -0
  9. package/docs/session-tree-plan.md +441 -0
  10. package/docs/session.md +240 -0
  11. package/docs/skills.md +290 -0
  12. package/docs/theme.md +637 -0
  13. package/docs/tree.md +197 -0
  14. package/docs/tui.md +341 -0
  15. package/examples/README.md +21 -0
  16. package/examples/custom-tools/README.md +124 -0
  17. package/examples/custom-tools/hello/index.ts +20 -0
  18. package/examples/custom-tools/question/index.ts +84 -0
  19. package/examples/custom-tools/subagent/README.md +172 -0
  20. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  21. package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +81 -0
  57. package/src/cli/args.ts +246 -0
  58. package/src/cli/file-processor.ts +72 -0
  59. package/src/cli/list-models.ts +104 -0
  60. package/src/cli/plugin-cli.ts +650 -0
  61. package/src/cli/session-picker.ts +41 -0
  62. package/src/cli.ts +10 -0
  63. package/src/commands/init.md +20 -0
  64. package/src/config.ts +159 -0
  65. package/src/core/agent-session.ts +1900 -0
  66. package/src/core/auth-storage.ts +236 -0
  67. package/src/core/bash-executor.ts +196 -0
  68. package/src/core/compaction/branch-summarization.ts +343 -0
  69. package/src/core/compaction/compaction.ts +742 -0
  70. package/src/core/compaction/index.ts +7 -0
  71. package/src/core/compaction/utils.ts +154 -0
  72. package/src/core/custom-tools/index.ts +21 -0
  73. package/src/core/custom-tools/loader.ts +248 -0
  74. package/src/core/custom-tools/types.ts +169 -0
  75. package/src/core/custom-tools/wrapper.ts +28 -0
  76. package/src/core/exec.ts +129 -0
  77. package/src/core/export-html/index.ts +211 -0
  78. package/src/core/export-html/template.css +781 -0
  79. package/src/core/export-html/template.html +54 -0
  80. package/src/core/export-html/template.js +1185 -0
  81. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  82. package/src/core/export-html/vendor/marked.min.js +6 -0
  83. package/src/core/hooks/index.ts +16 -0
  84. package/src/core/hooks/loader.ts +312 -0
  85. package/src/core/hooks/runner.ts +434 -0
  86. package/src/core/hooks/tool-wrapper.ts +99 -0
  87. package/src/core/hooks/types.ts +773 -0
  88. package/src/core/index.ts +52 -0
  89. package/src/core/mcp/client.ts +158 -0
  90. package/src/core/mcp/config.ts +154 -0
  91. package/src/core/mcp/index.ts +45 -0
  92. package/src/core/mcp/loader.ts +68 -0
  93. package/src/core/mcp/manager.ts +181 -0
  94. package/src/core/mcp/tool-bridge.ts +148 -0
  95. package/src/core/mcp/transports/http.ts +316 -0
  96. package/src/core/mcp/transports/index.ts +6 -0
  97. package/src/core/mcp/transports/stdio.ts +252 -0
  98. package/src/core/mcp/types.ts +220 -0
  99. package/src/core/messages.ts +189 -0
  100. package/src/core/model-registry.ts +317 -0
  101. package/src/core/model-resolver.ts +393 -0
  102. package/src/core/plugins/doctor.ts +59 -0
  103. package/src/core/plugins/index.ts +38 -0
  104. package/src/core/plugins/installer.ts +189 -0
  105. package/src/core/plugins/loader.ts +338 -0
  106. package/src/core/plugins/manager.ts +672 -0
  107. package/src/core/plugins/parser.ts +105 -0
  108. package/src/core/plugins/paths.ts +32 -0
  109. package/src/core/plugins/types.ts +190 -0
  110. package/src/core/sdk.ts +760 -0
  111. package/src/core/session-manager.ts +1128 -0
  112. package/src/core/settings-manager.ts +443 -0
  113. package/src/core/skills.ts +437 -0
  114. package/src/core/slash-commands.ts +248 -0
  115. package/src/core/system-prompt.ts +439 -0
  116. package/src/core/timings.ts +25 -0
  117. package/src/core/tools/ask.ts +211 -0
  118. package/src/core/tools/bash-interceptor.ts +120 -0
  119. package/src/core/tools/bash.ts +250 -0
  120. package/src/core/tools/context.ts +32 -0
  121. package/src/core/tools/edit-diff.ts +475 -0
  122. package/src/core/tools/edit.ts +208 -0
  123. package/src/core/tools/exa/company.ts +59 -0
  124. package/src/core/tools/exa/index.ts +64 -0
  125. package/src/core/tools/exa/linkedin.ts +59 -0
  126. package/src/core/tools/exa/logger.ts +56 -0
  127. package/src/core/tools/exa/mcp-client.ts +368 -0
  128. package/src/core/tools/exa/render.ts +196 -0
  129. package/src/core/tools/exa/researcher.ts +90 -0
  130. package/src/core/tools/exa/search.ts +337 -0
  131. package/src/core/tools/exa/types.ts +168 -0
  132. package/src/core/tools/exa/websets.ts +248 -0
  133. package/src/core/tools/find.ts +261 -0
  134. package/src/core/tools/grep.ts +555 -0
  135. package/src/core/tools/index.ts +202 -0
  136. package/src/core/tools/ls.ts +140 -0
  137. package/src/core/tools/lsp/client.ts +605 -0
  138. package/src/core/tools/lsp/config.ts +147 -0
  139. package/src/core/tools/lsp/edits.ts +101 -0
  140. package/src/core/tools/lsp/index.ts +804 -0
  141. package/src/core/tools/lsp/render.ts +447 -0
  142. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  143. package/src/core/tools/lsp/types.ts +463 -0
  144. package/src/core/tools/lsp/utils.ts +486 -0
  145. package/src/core/tools/notebook.ts +229 -0
  146. package/src/core/tools/path-utils.ts +61 -0
  147. package/src/core/tools/read.ts +240 -0
  148. package/src/core/tools/renderers.ts +540 -0
  149. package/src/core/tools/task/agents.ts +153 -0
  150. package/src/core/tools/task/artifacts.ts +114 -0
  151. package/src/core/tools/task/bundled-agents/browser.md +71 -0
  152. package/src/core/tools/task/bundled-agents/explore.md +82 -0
  153. package/src/core/tools/task/bundled-agents/plan.md +54 -0
  154. package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
  155. package/src/core/tools/task/bundled-agents/task.md +53 -0
  156. package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
  157. package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
  158. package/src/core/tools/task/bundled-commands/implement.md +11 -0
  159. package/src/core/tools/task/commands.ts +213 -0
  160. package/src/core/tools/task/discovery.ts +208 -0
  161. package/src/core/tools/task/executor.ts +367 -0
  162. package/src/core/tools/task/index.ts +388 -0
  163. package/src/core/tools/task/model-resolver.ts +115 -0
  164. package/src/core/tools/task/parallel.ts +38 -0
  165. package/src/core/tools/task/render.ts +232 -0
  166. package/src/core/tools/task/types.ts +99 -0
  167. package/src/core/tools/truncate.ts +265 -0
  168. package/src/core/tools/web-fetch.ts +2370 -0
  169. package/src/core/tools/web-search/auth.ts +193 -0
  170. package/src/core/tools/web-search/index.ts +537 -0
  171. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  172. package/src/core/tools/web-search/providers/exa.ts +302 -0
  173. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  174. package/src/core/tools/web-search/render.ts +182 -0
  175. package/src/core/tools/web-search/types.ts +180 -0
  176. package/src/core/tools/write.ts +99 -0
  177. package/src/index.ts +176 -0
  178. package/src/main.ts +464 -0
  179. package/src/migrations.ts +135 -0
  180. package/src/modes/index.ts +43 -0
  181. package/src/modes/interactive/components/armin.ts +382 -0
  182. package/src/modes/interactive/components/assistant-message.ts +86 -0
  183. package/src/modes/interactive/components/bash-execution.ts +196 -0
  184. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  185. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  186. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  187. package/src/modes/interactive/components/custom-editor.ts +122 -0
  188. package/src/modes/interactive/components/diff.ts +147 -0
  189. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  190. package/src/modes/interactive/components/footer.ts +381 -0
  191. package/src/modes/interactive/components/hook-editor.ts +117 -0
  192. package/src/modes/interactive/components/hook-input.ts +64 -0
  193. package/src/modes/interactive/components/hook-message.ts +96 -0
  194. package/src/modes/interactive/components/hook-selector.ts +91 -0
  195. package/src/modes/interactive/components/model-selector.ts +247 -0
  196. package/src/modes/interactive/components/oauth-selector.ts +120 -0
  197. package/src/modes/interactive/components/plugin-settings.ts +479 -0
  198. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  199. package/src/modes/interactive/components/session-selector.ts +204 -0
  200. package/src/modes/interactive/components/settings-selector.ts +453 -0
  201. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  202. package/src/modes/interactive/components/theme-selector.ts +62 -0
  203. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  204. package/src/modes/interactive/components/tool-execution.ts +675 -0
  205. package/src/modes/interactive/components/tree-selector.ts +866 -0
  206. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  207. package/src/modes/interactive/components/user-message.ts +18 -0
  208. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  209. package/src/modes/interactive/components/welcome.ts +183 -0
  210. package/src/modes/interactive/interactive-mode.ts +2516 -0
  211. package/src/modes/interactive/theme/dark.json +101 -0
  212. package/src/modes/interactive/theme/light.json +98 -0
  213. package/src/modes/interactive/theme/theme-schema.json +308 -0
  214. package/src/modes/interactive/theme/theme.ts +998 -0
  215. package/src/modes/print-mode.ts +128 -0
  216. package/src/modes/rpc/rpc-client.ts +527 -0
  217. package/src/modes/rpc/rpc-mode.ts +483 -0
  218. package/src/modes/rpc/rpc-types.ts +203 -0
  219. package/src/utils/changelog.ts +99 -0
  220. package/src/utils/clipboard.ts +265 -0
  221. package/src/utils/fuzzy.ts +108 -0
  222. package/src/utils/mime.ts +30 -0
  223. package/src/utils/shell.ts +276 -0
  224. package/src/utils/tools-manager.ts +274 -0
@@ -0,0 +1,672 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { extractPackageName, parsePluginSpec } from "./parser.js";
4
+ import {
5
+ getPluginsDir,
6
+ getPluginsLockfile,
7
+ getPluginsNodeModules,
8
+ getPluginsPackageJson,
9
+ getProjectPluginOverrides,
10
+ } from "./paths.js";
11
+ import type {
12
+ DoctorCheck,
13
+ DoctorOptions,
14
+ InstalledPlugin,
15
+ InstallOptions,
16
+ PluginManifest,
17
+ PluginRuntimeConfig,
18
+ PluginSettingSchema,
19
+ ProjectPluginOverrides,
20
+ } from "./types.js";
21
+
22
+ // =============================================================================
23
+ // Validation
24
+ // =============================================================================
25
+
26
+ /** Valid npm package name pattern (scoped and unscoped, with optional version) */
27
+ const VALID_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9-._^~>=<]+)?$/i;
28
+
29
+ /**
30
+ * Validate package name to prevent command injection.
31
+ */
32
+ function validatePackageName(name: string): void {
33
+ // Remove version specifier for validation
34
+ const baseName = extractPackageName(name);
35
+ if (!VALID_PACKAGE_NAME.test(baseName)) {
36
+ throw new Error(`Invalid package name: ${name}`);
37
+ }
38
+ // Extra safety: no shell metacharacters
39
+ if (/[;&|`$(){}[\]<>\\]/.test(name)) {
40
+ throw new Error(`Invalid characters in package name: ${name}`);
41
+ }
42
+ }
43
+
44
+ // =============================================================================
45
+ // Plugin Manager
46
+ // =============================================================================
47
+
48
+ export class PluginManager {
49
+ private runtimeConfig: PluginRuntimeConfig;
50
+ private cwd: string;
51
+
52
+ constructor(cwd: string = process.cwd()) {
53
+ this.cwd = cwd;
54
+ this.runtimeConfig = this.loadRuntimeConfig();
55
+ }
56
+
57
+ // ==========================================================================
58
+ // Runtime Config Management
59
+ // ==========================================================================
60
+
61
+ private loadRuntimeConfig(): PluginRuntimeConfig {
62
+ const lockPath = getPluginsLockfile();
63
+ if (!existsSync(lockPath)) {
64
+ return { plugins: {}, settings: {} };
65
+ }
66
+ try {
67
+ return JSON.parse(readFileSync(lockPath, "utf-8"));
68
+ } catch {
69
+ return { plugins: {}, settings: {} };
70
+ }
71
+ }
72
+
73
+ private saveRuntimeConfig(): void {
74
+ this.ensurePluginsDir();
75
+ writeFileSync(getPluginsLockfile(), JSON.stringify(this.runtimeConfig, null, 2));
76
+ }
77
+
78
+ private loadProjectOverrides(): ProjectPluginOverrides {
79
+ const overridesPath = getProjectPluginOverrides(this.cwd);
80
+ if (!existsSync(overridesPath)) {
81
+ return {};
82
+ }
83
+ try {
84
+ return JSON.parse(readFileSync(overridesPath, "utf-8"));
85
+ } catch {
86
+ return {};
87
+ }
88
+ }
89
+
90
+ // ==========================================================================
91
+ // Directory Management
92
+ // ==========================================================================
93
+
94
+ private ensurePluginsDir(): void {
95
+ const dir = getPluginsDir();
96
+ if (!existsSync(dir)) {
97
+ mkdirSync(dir, { recursive: true });
98
+ }
99
+ const nodeModules = getPluginsNodeModules();
100
+ if (!existsSync(nodeModules)) {
101
+ mkdirSync(nodeModules, { recursive: true });
102
+ }
103
+ }
104
+
105
+ private ensurePackageJson(): void {
106
+ this.ensurePluginsDir();
107
+ const pkgJsonPath = getPluginsPackageJson();
108
+ if (!existsSync(pkgJsonPath)) {
109
+ writeFileSync(
110
+ pkgJsonPath,
111
+ JSON.stringify(
112
+ {
113
+ name: "pi-plugins",
114
+ private: true,
115
+ dependencies: {},
116
+ },
117
+ null,
118
+ 2,
119
+ ),
120
+ );
121
+ }
122
+ }
123
+
124
+ // ==========================================================================
125
+ // Install / Uninstall
126
+ // ==========================================================================
127
+
128
+ /**
129
+ * Install a plugin from npm with optional feature selection.
130
+ *
131
+ * @param specString - Package specifier with optional features: "pkg", "pkg[feat]", "pkg[*]", "pkg[]"
132
+ * @param options - Install options
133
+ * @returns Installed plugin metadata
134
+ */
135
+ async install(specString: string, options: InstallOptions = {}): Promise<InstalledPlugin> {
136
+ const spec = parsePluginSpec(specString);
137
+ validatePackageName(spec.packageName);
138
+
139
+ this.ensurePackageJson();
140
+
141
+ if (options.dryRun) {
142
+ return {
143
+ name: spec.packageName,
144
+ version: "0.0.0-dryrun",
145
+ path: "",
146
+ manifest: { version: "0.0.0-dryrun" },
147
+ enabledFeatures: spec.features === "*" ? null : (spec.features as string[] | null),
148
+ enabled: true,
149
+ };
150
+ }
151
+
152
+ // Run npm install
153
+ const proc = Bun.spawn(["npm", "install", spec.packageName], {
154
+ cwd: getPluginsDir(),
155
+ stdin: "ignore",
156
+ stdout: "pipe",
157
+ stderr: "pipe",
158
+ });
159
+
160
+ const exitCode = await proc.exited;
161
+ if (exitCode !== 0) {
162
+ const stderr = await new Response(proc.stderr).text();
163
+ throw new Error(`npm install failed: ${stderr}`);
164
+ }
165
+
166
+ // Resolve actual package name (strip version specifier)
167
+ const actualName = extractPackageName(spec.packageName);
168
+ const pkgPath = join(getPluginsNodeModules(), actualName, "package.json");
169
+
170
+ if (!existsSync(pkgPath)) {
171
+ throw new Error(`Package installed but package.json not found at ${pkgPath}`);
172
+ }
173
+
174
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
175
+ const manifest: PluginManifest = pkg.omp || pkg.pi || { version: pkg.version };
176
+ manifest.version = pkg.version;
177
+
178
+ // Resolve enabled features
179
+ let enabledFeatures: string[] | null = null;
180
+ if (spec.features === "*") {
181
+ // All features
182
+ enabledFeatures = manifest.features ? Object.keys(manifest.features) : null;
183
+ } else if (Array.isArray(spec.features)) {
184
+ if (spec.features.length > 0) {
185
+ // Validate requested features exist
186
+ if (manifest.features) {
187
+ for (const feat of spec.features) {
188
+ if (!(feat in manifest.features)) {
189
+ throw new Error(
190
+ `Unknown feature "${feat}" in ${actualName}. Available: ${Object.keys(manifest.features).join(", ")}`,
191
+ );
192
+ }
193
+ }
194
+ }
195
+ enabledFeatures = spec.features;
196
+ } else {
197
+ // Empty array = no optional features
198
+ enabledFeatures = [];
199
+ }
200
+ }
201
+ // null = use defaults
202
+
203
+ // Update runtime config
204
+ this.runtimeConfig.plugins[pkg.name] = {
205
+ version: pkg.version,
206
+ enabledFeatures,
207
+ enabled: true,
208
+ };
209
+ this.saveRuntimeConfig();
210
+
211
+ return {
212
+ name: pkg.name,
213
+ version: pkg.version,
214
+ path: join(getPluginsNodeModules(), actualName),
215
+ manifest,
216
+ enabledFeatures,
217
+ enabled: true,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Uninstall a plugin.
223
+ */
224
+ async uninstall(name: string): Promise<void> {
225
+ validatePackageName(name);
226
+ this.ensurePackageJson();
227
+
228
+ const proc = Bun.spawn(["npm", "uninstall", name], {
229
+ cwd: getPluginsDir(),
230
+ stdin: "ignore",
231
+ stdout: "pipe",
232
+ stderr: "pipe",
233
+ });
234
+
235
+ const exitCode = await proc.exited;
236
+ if (exitCode !== 0) {
237
+ throw new Error(`npm uninstall failed for ${name}`);
238
+ }
239
+
240
+ // Remove from runtime config
241
+ delete this.runtimeConfig.plugins[name];
242
+ delete this.runtimeConfig.settings[name];
243
+ this.saveRuntimeConfig();
244
+ }
245
+
246
+ /**
247
+ * List all installed plugins.
248
+ */
249
+ async list(): Promise<InstalledPlugin[]> {
250
+ const pkgJsonPath = getPluginsPackageJson();
251
+ if (!existsSync(pkgJsonPath)) {
252
+ return [];
253
+ }
254
+
255
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
256
+ const deps = pkg.dependencies || {};
257
+ const projectOverrides = this.loadProjectOverrides();
258
+ const plugins: InstalledPlugin[] = [];
259
+
260
+ for (const [name] of Object.entries(deps)) {
261
+ const pluginPkgPath = join(getPluginsNodeModules(), name, "package.json");
262
+ if (existsSync(pluginPkgPath)) {
263
+ const pluginPkg = JSON.parse(readFileSync(pluginPkgPath, "utf-8"));
264
+ const manifest: PluginManifest = pluginPkg.omp || pluginPkg.pi || { version: pluginPkg.version };
265
+ manifest.version = pluginPkg.version;
266
+
267
+ const runtimeState = this.runtimeConfig.plugins[name] || {
268
+ version: pluginPkg.version,
269
+ enabledFeatures: null,
270
+ enabled: true,
271
+ };
272
+
273
+ // Apply project overrides
274
+ const isDisabledInProject = projectOverrides.disabled?.includes(name) ?? false;
275
+ const projectFeatures = projectOverrides.features?.[name];
276
+
277
+ plugins.push({
278
+ name,
279
+ version: pluginPkg.version,
280
+ path: join(getPluginsNodeModules(), name),
281
+ manifest,
282
+ enabledFeatures: projectFeatures ?? runtimeState.enabledFeatures,
283
+ enabled: runtimeState.enabled && !isDisabledInProject,
284
+ });
285
+ }
286
+ }
287
+
288
+ return plugins;
289
+ }
290
+
291
+ /**
292
+ * Link a local plugin for development.
293
+ */
294
+ async link(localPath: string): Promise<InstalledPlugin> {
295
+ const absolutePath = resolve(this.cwd, localPath);
296
+
297
+ const pkgFile = join(absolutePath, "package.json");
298
+ if (!existsSync(pkgFile)) {
299
+ throw new Error(`package.json not found at ${absolutePath}`);
300
+ }
301
+
302
+ const pkg = JSON.parse(readFileSync(pkgFile, "utf-8"));
303
+ if (!pkg.name) {
304
+ throw new Error("package.json must have a name field");
305
+ }
306
+
307
+ this.ensurePluginsDir();
308
+
309
+ const linkPath = join(getPluginsNodeModules(), pkg.name);
310
+
311
+ // Handle scoped packages
312
+ if (pkg.name.startsWith("@")) {
313
+ const scopeDir = join(getPluginsNodeModules(), pkg.name.split("/")[0]);
314
+ if (!existsSync(scopeDir)) {
315
+ mkdirSync(scopeDir, { recursive: true });
316
+ }
317
+ }
318
+
319
+ // Remove existing
320
+ try {
321
+ const stat = lstatSync(linkPath);
322
+ if (stat.isSymbolicLink() || stat.isDirectory()) {
323
+ unlinkSync(linkPath);
324
+ }
325
+ } catch {
326
+ // Doesn't exist
327
+ }
328
+
329
+ symlinkSync(absolutePath, linkPath);
330
+
331
+ const manifest: PluginManifest = pkg.omp || pkg.pi || { version: pkg.version };
332
+ manifest.version = pkg.version;
333
+
334
+ // Add to runtime config
335
+ this.runtimeConfig.plugins[pkg.name] = {
336
+ version: pkg.version,
337
+ enabledFeatures: null,
338
+ enabled: true,
339
+ };
340
+ this.saveRuntimeConfig();
341
+
342
+ return {
343
+ name: pkg.name,
344
+ version: pkg.version,
345
+ path: absolutePath,
346
+ manifest,
347
+ enabledFeatures: null,
348
+ enabled: true,
349
+ };
350
+ }
351
+
352
+ // ==========================================================================
353
+ // Enable / Disable
354
+ // ==========================================================================
355
+
356
+ /**
357
+ * Enable or disable a plugin globally.
358
+ */
359
+ async setEnabled(name: string, enabled: boolean): Promise<void> {
360
+ if (!this.runtimeConfig.plugins[name]) {
361
+ throw new Error(`Plugin ${name} not found in runtime config`);
362
+ }
363
+ this.runtimeConfig.plugins[name].enabled = enabled;
364
+ this.saveRuntimeConfig();
365
+ }
366
+
367
+ // ==========================================================================
368
+ // Features
369
+ // ==========================================================================
370
+
371
+ /**
372
+ * Get enabled features for a plugin.
373
+ */
374
+ getEnabledFeatures(name: string): string[] | null {
375
+ return this.runtimeConfig.plugins[name]?.enabledFeatures ?? null;
376
+ }
377
+
378
+ /**
379
+ * Set enabled features for a plugin.
380
+ */
381
+ async setEnabledFeatures(name: string, features: string[] | null): Promise<void> {
382
+ if (!this.runtimeConfig.plugins[name]) {
383
+ throw new Error(`Plugin ${name} not found in runtime config`);
384
+ }
385
+
386
+ // Validate features if setting specific ones
387
+ if (features && features.length > 0) {
388
+ const plugins = await this.list();
389
+ const plugin = plugins.find((p) => p.name === name);
390
+ if (plugin?.manifest.features) {
391
+ for (const feat of features) {
392
+ if (!(feat in plugin.manifest.features)) {
393
+ throw new Error(
394
+ `Unknown feature "${feat}" in ${name}. Available: ${Object.keys(plugin.manifest.features).join(", ")}`,
395
+ );
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ this.runtimeConfig.plugins[name].enabledFeatures = features;
402
+ this.saveRuntimeConfig();
403
+ }
404
+
405
+ // ==========================================================================
406
+ // Settings
407
+ // ==========================================================================
408
+
409
+ /**
410
+ * Get all settings for a plugin.
411
+ */
412
+ getPluginSettings(name: string): Record<string, unknown> {
413
+ const global = this.runtimeConfig.settings[name] || {};
414
+ const projectOverrides = this.loadProjectOverrides();
415
+ const project = projectOverrides.settings?.[name] || {};
416
+
417
+ // Project settings override global
418
+ return { ...global, ...project };
419
+ }
420
+
421
+ /**
422
+ * Set a plugin setting value.
423
+ */
424
+ setPluginSetting(name: string, key: string, value: unknown): void {
425
+ if (!this.runtimeConfig.settings[name]) {
426
+ this.runtimeConfig.settings[name] = {};
427
+ }
428
+ this.runtimeConfig.settings[name][key] = value;
429
+ this.saveRuntimeConfig();
430
+ }
431
+
432
+ /**
433
+ * Delete a plugin setting.
434
+ */
435
+ deletePluginSetting(name: string, key: string): void {
436
+ if (this.runtimeConfig.settings[name]) {
437
+ delete this.runtimeConfig.settings[name][key];
438
+ this.saveRuntimeConfig();
439
+ }
440
+ }
441
+
442
+ // ==========================================================================
443
+ // Doctor
444
+ // ==========================================================================
445
+
446
+ /**
447
+ * Run health checks on the plugin system.
448
+ */
449
+ async doctor(options: DoctorOptions = {}): Promise<DoctorCheck[]> {
450
+ const checks: DoctorCheck[] = [];
451
+
452
+ // Check 1: Plugins directory exists
453
+ const pluginsDir = getPluginsDir();
454
+ checks.push({
455
+ name: "plugins_directory",
456
+ status: existsSync(pluginsDir) ? "ok" : "warning",
457
+ message: existsSync(pluginsDir) ? `Found at ${pluginsDir}` : "Not created yet",
458
+ });
459
+
460
+ // Check 2: package.json exists
461
+ const pkgJsonPath = getPluginsPackageJson();
462
+ const hasPkgJson = existsSync(pkgJsonPath);
463
+ checks.push({
464
+ name: "package_manifest",
465
+ status: hasPkgJson ? "ok" : "warning",
466
+ message: hasPkgJson ? "Found" : "Not created yet",
467
+ });
468
+
469
+ // Check 3: node_modules exists
470
+ const nodeModulesPath = getPluginsNodeModules();
471
+ const hasNodeModules = existsSync(nodeModulesPath);
472
+ checks.push({
473
+ name: "node_modules",
474
+ status: hasNodeModules ? "ok" : hasPkgJson ? "error" : "warning",
475
+ message: hasNodeModules ? "Found" : "Missing (run npm install in plugins dir)",
476
+ });
477
+
478
+ if (!hasPkgJson) {
479
+ return checks;
480
+ }
481
+
482
+ // Check each installed plugin
483
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
484
+ const deps = pkg.dependencies || {};
485
+
486
+ for (const [name] of Object.entries(deps)) {
487
+ const pluginPath = join(nodeModulesPath, name);
488
+ const pluginPkgPath = join(pluginPath, "package.json");
489
+
490
+ if (!existsSync(pluginPath)) {
491
+ const fixed = options.fix ? await this.fixMissingPlugin() : false;
492
+ checks.push({
493
+ name: `plugin:${name}`,
494
+ status: "error",
495
+ message: "Missing from node_modules",
496
+ fixed,
497
+ });
498
+ continue;
499
+ }
500
+
501
+ if (!existsSync(pluginPkgPath)) {
502
+ checks.push({
503
+ name: `plugin:${name}`,
504
+ status: "error",
505
+ message: "Missing package.json",
506
+ });
507
+ continue;
508
+ }
509
+
510
+ const pluginPkg = JSON.parse(readFileSync(pluginPkgPath, "utf-8"));
511
+ const hasManifest = !!(pluginPkg.omp || pluginPkg.pi);
512
+ const manifest: PluginManifest | undefined = pluginPkg.omp || pluginPkg.pi;
513
+
514
+ checks.push({
515
+ name: `plugin:${name}`,
516
+ status: hasManifest ? "ok" : "warning",
517
+ message: hasManifest
518
+ ? `v${pluginPkg.version}${pluginPkg.description ? ` - ${pluginPkg.description}` : ""}`
519
+ : `v${pluginPkg.version} - No omp/pi manifest (not a pi plugin)`,
520
+ });
521
+
522
+ // Check tools path exists if specified
523
+ if (manifest?.tools) {
524
+ const toolsPath = join(pluginPath, manifest.tools);
525
+ if (!existsSync(toolsPath)) {
526
+ checks.push({
527
+ name: `plugin:${name}:tools`,
528
+ status: "error",
529
+ message: `Tools entry "${manifest.tools}" not found`,
530
+ });
531
+ }
532
+ }
533
+
534
+ // Check hooks path exists if specified
535
+ if (manifest?.hooks) {
536
+ const hooksPath = join(pluginPath, manifest.hooks);
537
+ if (!existsSync(hooksPath)) {
538
+ checks.push({
539
+ name: `plugin:${name}:hooks`,
540
+ status: "error",
541
+ message: `Hooks entry "${manifest.hooks}" not found`,
542
+ });
543
+ }
544
+ }
545
+
546
+ // Check enabled features exist in manifest
547
+ const runtimeState = this.runtimeConfig.plugins[name];
548
+ if (runtimeState?.enabledFeatures && manifest?.features) {
549
+ for (const feat of runtimeState.enabledFeatures) {
550
+ if (!(feat in manifest.features)) {
551
+ const fixed = options.fix ? this.removeInvalidFeature(name, feat) : false;
552
+ checks.push({
553
+ name: `plugin:${name}:feature:${feat}`,
554
+ status: "warning",
555
+ message: `Enabled feature "${feat}" not in manifest`,
556
+ fixed,
557
+ });
558
+ }
559
+ }
560
+ }
561
+ }
562
+
563
+ // Check for orphaned runtime config entries
564
+ for (const name of Object.keys(this.runtimeConfig.plugins)) {
565
+ if (!(name in deps)) {
566
+ const fixed = options.fix ? this.removeOrphanedConfig(name) : false;
567
+ checks.push({
568
+ name: `orphan:${name}`,
569
+ status: "warning",
570
+ message: "Plugin in config but not installed",
571
+ fixed,
572
+ });
573
+ }
574
+ }
575
+
576
+ return checks;
577
+ }
578
+
579
+ private async fixMissingPlugin(): Promise<boolean> {
580
+ try {
581
+ const proc = Bun.spawn(["npm", "install"], {
582
+ cwd: getPluginsDir(),
583
+ stdin: "ignore",
584
+ stdout: "pipe",
585
+ stderr: "pipe",
586
+ });
587
+ return (await proc.exited) === 0;
588
+ } catch {
589
+ return false;
590
+ }
591
+ }
592
+
593
+ private removeInvalidFeature(name: string, feat: string): boolean {
594
+ const state = this.runtimeConfig.plugins[name];
595
+ if (state?.enabledFeatures) {
596
+ state.enabledFeatures = state.enabledFeatures.filter((f) => f !== feat);
597
+ this.saveRuntimeConfig();
598
+ return true;
599
+ }
600
+ return false;
601
+ }
602
+
603
+ private removeOrphanedConfig(name: string): boolean {
604
+ delete this.runtimeConfig.plugins[name];
605
+ delete this.runtimeConfig.settings[name];
606
+ this.saveRuntimeConfig();
607
+ return true;
608
+ }
609
+ }
610
+
611
+ // =============================================================================
612
+ // Setting Validation
613
+ // =============================================================================
614
+
615
+ export interface ValidationResult {
616
+ valid: boolean;
617
+ error?: string;
618
+ }
619
+
620
+ /**
621
+ * Validate a setting value against its schema.
622
+ */
623
+ export function validateSetting(value: unknown, schema: PluginSettingSchema): ValidationResult {
624
+ switch (schema.type) {
625
+ case "string":
626
+ if (typeof value !== "string") {
627
+ return { valid: false, error: "Expected string" };
628
+ }
629
+ break;
630
+
631
+ case "number":
632
+ if (typeof value !== "number" || Number.isNaN(value)) {
633
+ return { valid: false, error: "Expected number" };
634
+ }
635
+ if (schema.min !== undefined && value < schema.min) {
636
+ return { valid: false, error: `Must be >= ${schema.min}` };
637
+ }
638
+ if (schema.max !== undefined && value > schema.max) {
639
+ return { valid: false, error: `Must be <= ${schema.max}` };
640
+ }
641
+ break;
642
+
643
+ case "boolean":
644
+ if (typeof value !== "boolean") {
645
+ return { valid: false, error: "Expected boolean" };
646
+ }
647
+ break;
648
+
649
+ case "enum":
650
+ if (!schema.values.includes(String(value))) {
651
+ return { valid: false, error: `Must be one of: ${schema.values.join(", ")}` };
652
+ }
653
+ break;
654
+ }
655
+
656
+ return { valid: true };
657
+ }
658
+
659
+ /**
660
+ * Parse a string value according to a setting schema's type.
661
+ */
662
+ export function parseSettingValue(valueStr: string, schema: PluginSettingSchema): unknown {
663
+ switch (schema.type) {
664
+ case "number":
665
+ return Number(valueStr);
666
+
667
+ case "boolean":
668
+ return valueStr === "true" || valueStr === "yes" || valueStr === "1";
669
+ default:
670
+ return valueStr;
671
+ }
672
+ }