@oh-my-pi/pi-coding-agent 3.15.0 → 3.20.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 (193) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/gemini-image.ts +361 -0
  62. package/src/core/tools/git.ts +216 -0
  63. package/src/core/tools/index.ts +28 -15
  64. package/src/core/tools/lsp/config.ts +5 -4
  65. package/src/core/tools/lsp/index.ts +17 -12
  66. package/src/core/tools/lsp/render.ts +39 -47
  67. package/src/core/tools/read.ts +66 -29
  68. package/src/core/tools/render-utils.ts +268 -0
  69. package/src/core/tools/renderers.ts +243 -225
  70. package/src/core/tools/task/discovery.ts +2 -2
  71. package/src/core/tools/task/executor.ts +66 -58
  72. package/src/core/tools/task/index.ts +29 -10
  73. package/src/core/tools/task/model-resolver.ts +8 -13
  74. package/src/core/tools/task/omp-command.ts +24 -0
  75. package/src/core/tools/task/render.ts +35 -60
  76. package/src/core/tools/task/types.ts +3 -0
  77. package/src/core/tools/web-fetch.ts +29 -28
  78. package/src/core/tools/web-search/index.ts +6 -5
  79. package/src/core/tools/web-search/providers/exa.ts +6 -5
  80. package/src/core/tools/web-search/render.ts +66 -111
  81. package/src/core/voice-controller.ts +135 -0
  82. package/src/core/voice-supervisor.ts +1003 -0
  83. package/src/core/voice.ts +308 -0
  84. package/src/discovery/builtin.ts +75 -1
  85. package/src/discovery/claude.ts +47 -1
  86. package/src/discovery/codex.ts +54 -2
  87. package/src/discovery/gemini.ts +55 -2
  88. package/src/discovery/helpers.ts +100 -1
  89. package/src/discovery/index.ts +2 -0
  90. package/src/index.ts +14 -9
  91. package/src/lib/worktree/collapse.ts +179 -0
  92. package/src/lib/worktree/constants.ts +14 -0
  93. package/src/lib/worktree/errors.ts +23 -0
  94. package/src/lib/worktree/git.ts +110 -0
  95. package/src/lib/worktree/index.ts +23 -0
  96. package/src/lib/worktree/operations.ts +216 -0
  97. package/src/lib/worktree/session.ts +114 -0
  98. package/src/lib/worktree/stats.ts +67 -0
  99. package/src/main.ts +61 -37
  100. package/src/migrations.ts +37 -7
  101. package/src/modes/interactive/components/bash-execution.ts +6 -4
  102. package/src/modes/interactive/components/custom-editor.ts +55 -0
  103. package/src/modes/interactive/components/custom-message.ts +95 -0
  104. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  105. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  106. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  107. package/src/modes/interactive/components/extensions/types.ts +1 -0
  108. package/src/modes/interactive/components/footer.ts +324 -0
  109. package/src/modes/interactive/components/hook-editor.ts +1 -0
  110. package/src/modes/interactive/components/hook-selector.ts +3 -3
  111. package/src/modes/interactive/components/model-selector.ts +7 -6
  112. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  113. package/src/modes/interactive/components/settings-defs.ts +55 -6
  114. package/src/modes/interactive/components/status-line/separators.ts +4 -4
  115. package/src/modes/interactive/components/status-line.ts +45 -35
  116. package/src/modes/interactive/components/tool-execution.ts +95 -23
  117. package/src/modes/interactive/interactive-mode.ts +644 -113
  118. package/src/modes/interactive/theme/defaults/alabaster.json +99 -0
  119. package/src/modes/interactive/theme/defaults/amethyst.json +103 -0
  120. package/src/modes/interactive/theme/defaults/anthracite.json +100 -0
  121. package/src/modes/interactive/theme/defaults/basalt.json +90 -0
  122. package/src/modes/interactive/theme/defaults/birch.json +101 -0
  123. package/src/modes/interactive/theme/defaults/dark-abyss.json +97 -0
  124. package/src/modes/interactive/theme/defaults/dark-aurora.json +94 -0
  125. package/src/modes/interactive/theme/defaults/dark-cavern.json +97 -0
  126. package/src/modes/interactive/theme/defaults/dark-copper.json +94 -0
  127. package/src/modes/interactive/theme/defaults/dark-cosmos.json +96 -0
  128. package/src/modes/interactive/theme/defaults/dark-eclipse.json +97 -0
  129. package/src/modes/interactive/theme/defaults/dark-ember.json +94 -0
  130. package/src/modes/interactive/theme/defaults/dark-equinox.json +96 -0
  131. package/src/modes/interactive/theme/defaults/dark-lavender.json +94 -0
  132. package/src/modes/interactive/theme/defaults/dark-lunar.json +95 -0
  133. package/src/modes/interactive/theme/defaults/dark-midnight.json +94 -0
  134. package/src/modes/interactive/theme/defaults/dark-nebula.json +96 -0
  135. package/src/modes/interactive/theme/defaults/dark-rainforest.json +97 -0
  136. package/src/modes/interactive/theme/defaults/dark-reef.json +97 -0
  137. package/src/modes/interactive/theme/defaults/dark-sakura.json +94 -0
  138. package/src/modes/interactive/theme/defaults/dark-slate.json +94 -0
  139. package/src/modes/interactive/theme/defaults/dark-solstice.json +96 -0
  140. package/src/modes/interactive/theme/defaults/dark-starfall.json +97 -0
  141. package/src/modes/interactive/theme/defaults/dark-swamp.json +96 -0
  142. package/src/modes/interactive/theme/defaults/dark-taiga.json +97 -0
  143. package/src/modes/interactive/theme/defaults/dark-terminal.json +94 -0
  144. package/src/modes/interactive/theme/defaults/dark-tundra.json +97 -0
  145. package/src/modes/interactive/theme/defaults/dark-twilight.json +97 -0
  146. package/src/modes/interactive/theme/defaults/dark-volcanic.json +97 -0
  147. package/src/modes/interactive/theme/defaults/graphite.json +99 -0
  148. package/src/modes/interactive/theme/defaults/index.ts +128 -0
  149. package/src/modes/interactive/theme/defaults/light-aurora-day.json +97 -0
  150. package/src/modes/interactive/theme/defaults/light-canyon.json +97 -0
  151. package/src/modes/interactive/theme/defaults/light-cirrus.json +96 -0
  152. package/src/modes/interactive/theme/defaults/light-coral.json +94 -0
  153. package/src/modes/interactive/theme/defaults/light-dawn.json +96 -0
  154. package/src/modes/interactive/theme/defaults/light-dunes.json +97 -0
  155. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +94 -0
  156. package/src/modes/interactive/theme/defaults/light-frost.json +94 -0
  157. package/src/modes/interactive/theme/defaults/light-glacier.json +97 -0
  158. package/src/modes/interactive/theme/defaults/light-haze.json +96 -0
  159. package/src/modes/interactive/theme/defaults/light-honeycomb.json +94 -0
  160. package/src/modes/interactive/theme/defaults/light-lagoon.json +97 -0
  161. package/src/modes/interactive/theme/defaults/light-lavender.json +94 -0
  162. package/src/modes/interactive/theme/defaults/light-meadow.json +97 -0
  163. package/src/modes/interactive/theme/defaults/light-mint.json +94 -0
  164. package/src/modes/interactive/theme/defaults/light-opal.json +97 -0
  165. package/src/modes/interactive/theme/defaults/light-orchard.json +97 -0
  166. package/src/modes/interactive/theme/defaults/light-paper.json +94 -0
  167. package/src/modes/interactive/theme/defaults/light-prism.json +96 -0
  168. package/src/modes/interactive/theme/defaults/light-sand.json +94 -0
  169. package/src/modes/interactive/theme/defaults/light-savanna.json +97 -0
  170. package/src/modes/interactive/theme/defaults/light-soleil.json +96 -0
  171. package/src/modes/interactive/theme/defaults/light-wetland.json +97 -0
  172. package/src/modes/interactive/theme/defaults/light-zenith.json +95 -0
  173. package/src/modes/interactive/theme/defaults/limestone.json +100 -0
  174. package/src/modes/interactive/theme/defaults/mahogany.json +104 -0
  175. package/src/modes/interactive/theme/defaults/marble.json +99 -0
  176. package/src/modes/interactive/theme/defaults/obsidian.json +90 -0
  177. package/src/modes/interactive/theme/defaults/onyx.json +90 -0
  178. package/src/modes/interactive/theme/defaults/pearl.json +99 -0
  179. package/src/modes/interactive/theme/defaults/porcelain.json +90 -0
  180. package/src/modes/interactive/theme/defaults/quartz.json +102 -0
  181. package/src/modes/interactive/theme/defaults/sandstone.json +101 -0
  182. package/src/modes/interactive/theme/defaults/titanium.json +89 -0
  183. package/src/modes/print-mode.ts +14 -72
  184. package/src/modes/rpc/rpc-client.ts +23 -9
  185. package/src/modes/rpc/rpc-mode.ts +137 -125
  186. package/src/modes/rpc/rpc-types.ts +46 -24
  187. package/src/prompts/task.md +1 -0
  188. package/src/prompts/tools/gemini-image.md +4 -0
  189. package/src/prompts/tools/git.md +9 -0
  190. package/src/prompts/voice-summary.md +12 -0
  191. package/src/utils/image-convert.ts +26 -0
  192. package/src/utils/image-resize.ts +215 -0
  193. package/src/utils/shell-snapshot.ts +22 -20
@@ -0,0 +1,308 @@
1
+ import { unlinkSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { completeSimple, type Model } from "@oh-my-pi/pi-ai";
5
+ import voiceSummaryPrompt from "../prompts/voice-summary.md" with { type: "text" };
6
+ import { logger } from "./logger";
7
+ import type { ModelRegistry } from "./model-registry";
8
+ import { findSmolModel } from "./model-resolver";
9
+ import type { VoiceSettings } from "./settings-manager";
10
+
11
+ const DEFAULT_SAMPLE_RATE = 16000;
12
+ const DEFAULT_CHANNELS = 1;
13
+ const DEFAULT_BITS = 16;
14
+ const SUMMARY_MAX_CHARS = 6000;
15
+
16
+ export interface VoiceRecordingHandle {
17
+ filePath: string;
18
+ stop: () => Promise<void>;
19
+ cancel: () => Promise<void>;
20
+ cleanup: () => void;
21
+ }
22
+
23
+ export interface VoiceTranscriptionResult {
24
+ text: string;
25
+ }
26
+
27
+ export interface VoiceSynthesisResult {
28
+ audio: Uint8Array;
29
+ format: "wav" | "mp3" | "opus" | "aac" | "flac";
30
+ }
31
+
32
+ function buildRecordingCommand(filePath: string, sampleRate: number, channels: number): string[] | null {
33
+ const soxPath = Bun.which("sox") ?? Bun.which("rec");
34
+ if (soxPath) {
35
+ return [soxPath, "-d", "-r", String(sampleRate), "-c", String(channels), "-b", String(DEFAULT_BITS), filePath];
36
+ }
37
+
38
+ const arecordPath = Bun.which("arecord");
39
+ if (arecordPath) {
40
+ return [arecordPath, "-f", "S16_LE", "-r", String(sampleRate), "-c", String(channels), filePath];
41
+ }
42
+
43
+ const ffmpegPath = Bun.which("ffmpeg");
44
+ if (ffmpegPath) {
45
+ const platform = process.platform;
46
+ if (platform === "darwin") {
47
+ // avfoundation default input device; users can override by installing sox for reliability.
48
+ return [
49
+ ffmpegPath,
50
+ "-f",
51
+ "avfoundation",
52
+ "-i",
53
+ ":0",
54
+ "-ac",
55
+ String(channels),
56
+ "-ar",
57
+ String(sampleRate),
58
+ "-y",
59
+ filePath,
60
+ ];
61
+ }
62
+ if (platform === "linux") {
63
+ // alsa default input device (commonly "default").
64
+ return [
65
+ ffmpegPath,
66
+ "-f",
67
+ "alsa",
68
+ "-i",
69
+ "default",
70
+ "-ac",
71
+ String(channels),
72
+ "-ar",
73
+ String(sampleRate),
74
+ "-y",
75
+ filePath,
76
+ ];
77
+ }
78
+ if (platform === "win32") {
79
+ // dshow default input device name varies; "audio=default" is a best-effort fallback.
80
+ return [
81
+ ffmpegPath,
82
+ "-f",
83
+ "dshow",
84
+ "-i",
85
+ "audio=default",
86
+ "-ac",
87
+ String(channels),
88
+ "-ar",
89
+ String(sampleRate),
90
+ "-y",
91
+ filePath,
92
+ ];
93
+ }
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ export async function startVoiceRecording(_settings: VoiceSettings): Promise<VoiceRecordingHandle> {
100
+ const sampleRate = DEFAULT_SAMPLE_RATE;
101
+ const channels = DEFAULT_CHANNELS;
102
+ const filePath = join(tmpdir(), `omp-voice-${Date.now()}.wav`);
103
+ const command = buildRecordingCommand(filePath, sampleRate, channels);
104
+ if (!command) {
105
+ throw new Error("No audio recorder found (install sox, arecord, or ffmpeg).");
106
+ }
107
+
108
+ logger.debug("voice: starting recorder", { command });
109
+ const proc = Bun.spawn(command, {
110
+ stdin: "ignore",
111
+ stdout: "ignore",
112
+ stderr: "pipe",
113
+ });
114
+
115
+ const stop = async (): Promise<void> => {
116
+ try {
117
+ proc.kill();
118
+ } catch {
119
+ // ignore
120
+ }
121
+ await proc.exited;
122
+ };
123
+
124
+ const cleanup = (): void => {
125
+ try {
126
+ unlinkSync(filePath);
127
+ } catch {
128
+ // ignore cleanup errors
129
+ }
130
+ };
131
+
132
+ const cancel = async (): Promise<void> => {
133
+ await stop();
134
+ cleanup();
135
+ };
136
+
137
+ return { filePath, stop, cancel, cleanup };
138
+ }
139
+
140
+ export async function transcribeAudio(
141
+ filePath: string,
142
+ apiKey: string,
143
+ settings: VoiceSettings,
144
+ ): Promise<VoiceTranscriptionResult> {
145
+ const file = Bun.file(filePath);
146
+ const buffer = await file.arrayBuffer();
147
+ const blob = new File([buffer], "speech.wav", { type: "audio/wav" });
148
+ const form = new FormData();
149
+ form.append("file", blob);
150
+ form.append("model", settings.transcriptionModel ?? "whisper-1");
151
+ if (settings.transcriptionLanguage) {
152
+ form.append("language", settings.transcriptionLanguage);
153
+ }
154
+
155
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
156
+ method: "POST",
157
+ headers: { Authorization: `Bearer ${apiKey}` },
158
+ body: form,
159
+ });
160
+
161
+ if (!response.ok) {
162
+ const errText = await response.text();
163
+ throw new Error(`Whisper transcription failed: ${response.status} ${errText}`);
164
+ }
165
+
166
+ const data = (await response.json()) as { text?: string };
167
+ return { text: (data.text ?? "").trim() };
168
+ }
169
+
170
+ export async function synthesizeSpeech(
171
+ text: string,
172
+ apiKey: string,
173
+ settings: VoiceSettings,
174
+ ): Promise<VoiceSynthesisResult> {
175
+ const format = settings.ttsFormat ?? "wav";
176
+ const response = await fetch("https://api.openai.com/v1/audio/speech", {
177
+ method: "POST",
178
+ headers: {
179
+ Authorization: `Bearer ${apiKey}`,
180
+ "Content-Type": "application/json",
181
+ },
182
+ body: JSON.stringify({
183
+ model: settings.ttsModel ?? "tts-1",
184
+ voice: settings.ttsVoice ?? "alloy",
185
+ format,
186
+ input: text,
187
+ }),
188
+ });
189
+
190
+ if (!response.ok) {
191
+ const errText = await response.text();
192
+ throw new Error(`TTS synthesis failed: ${response.status} ${errText}`);
193
+ }
194
+
195
+ const audio = new Uint8Array(await response.arrayBuffer());
196
+ return { audio, format };
197
+ }
198
+
199
+ function getPlayerCommand(filePath: string, format: VoiceSynthesisResult["format"]): string[] | null {
200
+ const platform = process.platform;
201
+ if (platform === "darwin") {
202
+ const afplay = Bun.which("afplay");
203
+ if (afplay) return [afplay, filePath];
204
+ }
205
+
206
+ if (platform === "linux") {
207
+ const paplay = Bun.which("paplay");
208
+ if (paplay) return [paplay, filePath];
209
+ const aplay = Bun.which("aplay");
210
+ if (aplay) return [aplay, filePath];
211
+ const ffplay = Bun.which("ffplay");
212
+ if (ffplay) return [ffplay, "-autoexit", "-nodisp", filePath];
213
+ const play = Bun.which("play");
214
+ if (play) return [play, filePath];
215
+ }
216
+
217
+ if (platform === "win32") {
218
+ if (format !== "wav") {
219
+ return null;
220
+ }
221
+ const ps = Bun.which("powershell");
222
+ if (ps) {
223
+ return [
224
+ ps,
225
+ "-NoProfile",
226
+ "-Command",
227
+ `(New-Object Media.SoundPlayer '${filePath.replace(/'/g, "''")}').PlaySync()`,
228
+ ];
229
+ }
230
+ }
231
+
232
+ return null;
233
+ }
234
+
235
+ export async function playAudio(audio: Uint8Array, format: VoiceSynthesisResult["format"]): Promise<void> {
236
+ const filePath = join(tmpdir(), `omp-voice-tts-${Date.now()}.${format}`);
237
+ await Bun.write(filePath, audio);
238
+
239
+ const command = getPlayerCommand(filePath, format);
240
+ if (!command) {
241
+ throw new Error("No audio player available for playback.");
242
+ }
243
+
244
+ const proc = Bun.spawn(command, {
245
+ stdin: "ignore",
246
+ stdout: "ignore",
247
+ stderr: "pipe",
248
+ });
249
+ await proc.exited;
250
+
251
+ try {
252
+ unlinkSync(filePath);
253
+ } catch {
254
+ // ignore cleanup errors
255
+ }
256
+ }
257
+
258
+ function extractTextFromResponse(response: { content: Array<{ type: string; text?: string }> }): string {
259
+ let text = "";
260
+ for (const content of response.content) {
261
+ if (content.type === "text" && content.text) {
262
+ text += content.text;
263
+ }
264
+ }
265
+ return text.trim();
266
+ }
267
+
268
+ export async function summarizeForVoice(
269
+ text: string,
270
+ registry: ModelRegistry,
271
+ savedSmolModel?: string,
272
+ ): Promise<string | null> {
273
+ const model = await findSmolModel(registry, savedSmolModel);
274
+ if (!model) {
275
+ logger.debug("voice: no smol model found for summary");
276
+ return null;
277
+ }
278
+
279
+ const apiKey = await registry.getApiKey(model);
280
+ if (!apiKey) {
281
+ logger.debug("voice: no API key for summary model", { provider: model.provider, id: model.id });
282
+ return null;
283
+ }
284
+
285
+ const truncated = text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}...` : text;
286
+ const request = {
287
+ model: `${model.provider}/${model.id}`,
288
+ systemPrompt: voiceSummaryPrompt,
289
+ userMessage: `<assistant_response>\n${truncated}\n</assistant_response>`,
290
+ };
291
+ logger.debug("voice: summary request", request);
292
+
293
+ try {
294
+ const response = await completeSimple(
295
+ model as Model<any>,
296
+ {
297
+ systemPrompt: request.systemPrompt,
298
+ messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
299
+ },
300
+ { apiKey, maxTokens: 120 },
301
+ );
302
+ const summary = extractTextFromResponse(response);
303
+ return summary || null;
304
+ } catch (error) {
305
+ logger.debug("voice: summary error", { error: error instanceof Error ? error.message : String(error) });
306
+ return null;
307
+ }
308
+ }
@@ -5,9 +5,10 @@
5
5
  * .pi is an alias for backwards compatibility.
6
6
  */
7
7
 
8
- import { basename, dirname, join } from "node:path";
8
+ import { basename, dirname, isAbsolute, join, resolve } from "path";
9
9
  import { type ContextFile, contextFileCapability } from "../capability/context-file";
10
10
  import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension";
11
+ import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
11
12
  import { type Hook, hookCapability } from "../capability/hook";
12
13
  import { registerProvider } from "../capability/index";
13
14
  import { type Instruction, instructionCapability } from "../capability/instruction";
@@ -22,7 +23,9 @@ import { type CustomTool, toolCapability } from "../capability/tool";
22
23
  import type { LoadContext, LoadResult } from "../capability/types";
23
24
  import {
24
25
  createSourceMeta,
26
+ discoverExtensionModulePaths,
25
27
  expandEnvVarsDeep,
28
+ getExtensionNameFromPath,
26
29
  loadFilesFromDir,
27
30
  parseFrontmatter,
28
31
  parseJSON,
@@ -361,6 +364,77 @@ registerProvider<Prompt>(promptCapability.id, {
361
364
  load: loadPrompts,
362
365
  });
363
366
 
367
+ // Extension Modules
368
+ function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
369
+ const items: ExtensionModule[] = [];
370
+ const warnings: string[] = [];
371
+
372
+ const resolveExtensionPath = (rawPath: string): string => {
373
+ if (rawPath.startsWith("~/")) {
374
+ return join(ctx.home, rawPath.slice(2));
375
+ }
376
+ if (rawPath.startsWith("~")) {
377
+ return join(ctx.home, rawPath.slice(1));
378
+ }
379
+ if (isAbsolute(rawPath)) {
380
+ return rawPath;
381
+ }
382
+ return resolve(ctx.cwd, rawPath);
383
+ };
384
+
385
+ const addExtensionPath = (extPath: string, level: "user" | "project"): void => {
386
+ items.push({
387
+ name: getExtensionNameFromPath(extPath),
388
+ path: extPath,
389
+ level,
390
+ _source: createSourceMeta(PROVIDER_ID, extPath, level),
391
+ });
392
+ };
393
+
394
+ for (const { dir, level } of getConfigDirs(ctx)) {
395
+ const extensionsDir = join(dir, "extensions");
396
+ const discovered = discoverExtensionModulePaths(ctx, extensionsDir);
397
+ for (const extPath of discovered) {
398
+ addExtensionPath(extPath, level);
399
+ }
400
+
401
+ const settingsPath = join(dir, "settings.json");
402
+ const settingsContent = ctx.fs.readFile(settingsPath);
403
+ if (settingsContent) {
404
+ const settingsData = parseJSON<{ extensions?: unknown }>(settingsContent);
405
+ const extensions = settingsData?.extensions;
406
+ if (Array.isArray(extensions)) {
407
+ for (const entry of extensions) {
408
+ if (typeof entry !== "string") {
409
+ warnings.push(`Invalid extension path in ${settingsPath}: ${String(entry)}`);
410
+ continue;
411
+ }
412
+ const resolvedPath = resolveExtensionPath(entry);
413
+ if (ctx.fs.isDir(resolvedPath)) {
414
+ for (const extPath of discoverExtensionModulePaths(ctx, resolvedPath)) {
415
+ addExtensionPath(extPath, level);
416
+ }
417
+ } else if (ctx.fs.isFile(resolvedPath)) {
418
+ addExtensionPath(resolvedPath, level);
419
+ } else {
420
+ warnings.push(`Extension path not found: ${resolvedPath}`);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ return { items, warnings };
428
+ }
429
+
430
+ registerProvider<ExtensionModule>(extensionModuleCapability.id, {
431
+ id: PROVIDER_ID,
432
+ displayName: DISPLAY_NAME,
433
+ description: DESCRIPTION,
434
+ priority: PRIORITY,
435
+ load: loadExtensionModules,
436
+ });
437
+
364
438
  // Extensions
365
439
  function loadExtensions(ctx: LoadContext): LoadResult<Extension> {
366
440
  const items: Extension[] = [];
@@ -5,8 +5,9 @@
5
5
  * Priority: 80 (tool-specific, below builtin but above shared standards)
6
6
  */
7
7
 
8
- import { dirname, join, sep } from "node:path";
8
+ import { dirname, join, sep } from "path";
9
9
  import { type ContextFile, contextFileCapability } from "../capability/context-file";
10
+ import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
10
11
  import { type Hook, hookCapability } from "../capability/hook";
11
12
  import { registerProvider } from "../capability/index";
12
13
  import { type MCPServer, mcpCapability } from "../capability/mcp";
@@ -19,7 +20,9 @@ import type { LoadContext, LoadResult } from "../capability/types";
19
20
  import {
20
21
  calculateDepth,
21
22
  createSourceMeta,
23
+ discoverExtensionModulePaths,
22
24
  expandEnvVarsDeep,
25
+ getExtensionNameFromPath,
23
26
  loadFilesFromDir,
24
27
  parseFrontmatter,
25
28
  parseJSON,
@@ -292,6 +295,41 @@ function loadSkills(ctx: LoadContext): LoadResult<Skill> {
292
295
  return { items, warnings };
293
296
  }
294
297
 
298
+ // =============================================================================
299
+ // Extension Modules
300
+ // =============================================================================
301
+
302
+ function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
303
+ const items: ExtensionModule[] = [];
304
+ const warnings: string[] = [];
305
+
306
+ const userBase = getUserClaude(ctx);
307
+ const userExtensionsDir = join(userBase, "extensions");
308
+ for (const extPath of discoverExtensionModulePaths(ctx, userExtensionsDir)) {
309
+ items.push({
310
+ name: getExtensionNameFromPath(extPath),
311
+ path: extPath,
312
+ level: "user",
313
+ _source: createSourceMeta(PROVIDER_ID, extPath, "user"),
314
+ });
315
+ }
316
+
317
+ const projectBase = getProjectClaude(ctx);
318
+ if (projectBase) {
319
+ const projectExtensionsDir = join(projectBase, "extensions");
320
+ for (const extPath of discoverExtensionModulePaths(ctx, projectExtensionsDir)) {
321
+ items.push({
322
+ name: getExtensionNameFromPath(extPath),
323
+ path: extPath,
324
+ level: "project",
325
+ _source: createSourceMeta(PROVIDER_ID, extPath, "project"),
326
+ });
327
+ }
328
+ }
329
+
330
+ return { items, warnings };
331
+ }
332
+
295
333
  // =============================================================================
296
334
  // Slash Commands
297
335
  // =============================================================================
@@ -582,6 +620,14 @@ registerProvider<Skill>(skillCapability.id, {
582
620
  load: loadSkills,
583
621
  });
584
622
 
623
+ registerProvider<ExtensionModule>(extensionModuleCapability.id, {
624
+ id: PROVIDER_ID,
625
+ displayName: DISPLAY_NAME,
626
+ description: "Load extension modules from .claude/extensions",
627
+ priority: PRIORITY,
628
+ load: loadExtensionModules,
629
+ });
630
+
585
631
  registerProvider<SlashCommand>(slashCommandCapability.id, {
586
632
  id: PROVIDER_ID,
587
633
  displayName: DISPLAY_NAME,
@@ -7,10 +7,11 @@
7
7
  * User directory: ~/.codex
8
8
  */
9
9
 
10
- import { join } from "node:path";
10
+ import { join } from "path";
11
11
  import { parse as parseToml } from "smol-toml";
12
12
  import type { ContextFile } from "../capability/context-file";
13
13
  import { contextFileCapability } from "../capability/context-file";
14
+ import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
14
15
  import type { Hook } from "../capability/hook";
15
16
  import { hookCapability } from "../capability/hook";
16
17
  import { registerProvider } from "../capability/index";
@@ -27,7 +28,14 @@ import { slashCommandCapability } from "../capability/slash-command";
27
28
  import type { CustomTool } from "../capability/tool";
28
29
  import { toolCapability } from "../capability/tool";
29
30
  import type { LoadContext, LoadResult } from "../capability/types";
30
- import { createSourceMeta, loadFilesFromDir, parseFrontmatter, SOURCE_PATHS } from "./helpers";
31
+ import {
32
+ createSourceMeta,
33
+ discoverExtensionModulePaths,
34
+ getExtensionNameFromPath,
35
+ loadFilesFromDir,
36
+ parseFrontmatter,
37
+ SOURCE_PATHS,
38
+ } from "./helpers";
31
39
 
32
40
  const PROVIDER_ID = "codex";
33
41
  const DISPLAY_NAME = "OpenAI Codex";
@@ -251,6 +259,42 @@ function loadSkills(ctx: LoadContext): LoadResult<Skill> {
251
259
  return { items, warnings };
252
260
  }
253
261
 
262
+ // =============================================================================
263
+ // Extension Modules (extensions/)
264
+ // =============================================================================
265
+
266
+ function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
267
+ const items: ExtensionModule[] = [];
268
+ const warnings: string[] = [];
269
+
270
+ // User level: ~/.codex/extensions/
271
+ const userExtensionsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "extensions");
272
+ for (const extPath of discoverExtensionModulePaths(ctx, userExtensionsDir)) {
273
+ items.push({
274
+ name: getExtensionNameFromPath(extPath),
275
+ path: extPath,
276
+ level: "user",
277
+ _source: createSourceMeta(PROVIDER_ID, extPath, "user"),
278
+ });
279
+ }
280
+
281
+ // Project level: .codex/extensions/
282
+ const codexDir = ctx.fs.walkUp(".codex", { dir: true });
283
+ if (codexDir) {
284
+ const projectExtensionsDir = join(codexDir, "extensions");
285
+ for (const extPath of discoverExtensionModulePaths(ctx, projectExtensionsDir)) {
286
+ items.push({
287
+ name: getExtensionNameFromPath(extPath),
288
+ path: extPath,
289
+ level: "project",
290
+ _source: createSourceMeta(PROVIDER_ID, extPath, "project"),
291
+ });
292
+ }
293
+ }
294
+
295
+ return { items, warnings };
296
+ }
297
+
254
298
  // =============================================================================
255
299
  // Slash Commands (commands/)
256
300
  // =============================================================================
@@ -530,6 +574,14 @@ registerProvider<Skill>(skillCapability.id, {
530
574
  load: loadSkills,
531
575
  });
532
576
 
577
+ registerProvider<ExtensionModule>(extensionModuleCapability.id, {
578
+ id: PROVIDER_ID,
579
+ displayName: DISPLAY_NAME,
580
+ description: "Load extension modules from ~/.codex/extensions and .codex/extensions/",
581
+ priority: PRIORITY,
582
+ load: loadExtensionModules,
583
+ });
584
+
533
585
  registerProvider<SlashCommand>(slashCommandCapability.id, {
534
586
  id: PROVIDER_ID,
535
587
  displayName: DISPLAY_NAME,
@@ -16,15 +16,25 @@
16
16
  * - settings: From settings.json
17
17
  */
18
18
 
19
- import { dirname, join, sep } from "node:path";
19
+ import { dirname, join, sep } from "path";
20
20
  import { type ContextFile, contextFileCapability } from "../capability/context-file";
21
21
  import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension";
22
+ import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
22
23
  import { registerProvider } from "../capability/index";
23
24
  import { type MCPServer, mcpCapability } from "../capability/mcp";
24
25
  import { type Settings, settingsCapability } from "../capability/settings";
25
26
  import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
26
27
  import type { LoadContext, LoadResult } from "../capability/types";
27
- import { calculateDepth, createSourceMeta, expandEnvVarsDeep, getProjectPath, getUserPath, parseJSON } from "./helpers";
28
+ import {
29
+ calculateDepth,
30
+ createSourceMeta,
31
+ discoverExtensionModulePaths,
32
+ expandEnvVarsDeep,
33
+ getExtensionNameFromPath,
34
+ getProjectPath,
35
+ getUserPath,
36
+ parseJSON,
37
+ } from "./helpers";
28
38
 
29
39
  const PROVIDER_ID = "gemini";
30
40
  const DISPLAY_NAME = "Gemini CLI";
@@ -236,6 +246,41 @@ function loadExtensionsFromDir(
236
246
  return { items, warnings };
237
247
  }
238
248
 
249
+ // =============================================================================
250
+ // Extension Modules
251
+ // =============================================================================
252
+
253
+ function loadExtensionModules(ctx: LoadContext): LoadResult<ExtensionModule> {
254
+ const items: ExtensionModule[] = [];
255
+ const warnings: string[] = [];
256
+
257
+ const userExtensionsDir = getUserPath(ctx, "gemini", "extensions");
258
+ if (userExtensionsDir) {
259
+ for (const extPath of discoverExtensionModulePaths(ctx, userExtensionsDir)) {
260
+ items.push({
261
+ name: getExtensionNameFromPath(extPath),
262
+ path: extPath,
263
+ level: "user",
264
+ _source: createSourceMeta(PROVIDER_ID, extPath, "user"),
265
+ });
266
+ }
267
+ }
268
+
269
+ const projectExtensionsDir = getProjectPath(ctx, "gemini", "extensions");
270
+ if (projectExtensionsDir) {
271
+ for (const extPath of discoverExtensionModulePaths(ctx, projectExtensionsDir)) {
272
+ items.push({
273
+ name: getExtensionNameFromPath(extPath),
274
+ path: extPath,
275
+ level: "project",
276
+ _source: createSourceMeta(PROVIDER_ID, extPath, "project"),
277
+ });
278
+ }
279
+ }
280
+
281
+ return { items, warnings };
282
+ }
283
+
239
284
  // =============================================================================
240
285
  // Settings
241
286
  // =============================================================================
@@ -359,6 +404,14 @@ registerProvider(extensionCapability.id, {
359
404
  load: loadExtensions,
360
405
  });
361
406
 
407
+ registerProvider(extensionModuleCapability.id, {
408
+ id: PROVIDER_ID,
409
+ displayName: DISPLAY_NAME,
410
+ description: "Load extension modules from ~/.gemini/extensions/ and .gemini/extensions/",
411
+ priority: PRIORITY,
412
+ load: loadExtensionModules,
413
+ });
414
+
362
415
  registerProvider(settingsCapability.id, {
363
416
  id: PROVIDER_ID,
364
417
  displayName: DISPLAY_NAME,