pneuma-skills 1.4.1 → 1.6.2

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 (158) hide show
  1. package/README.md +54 -15
  2. package/bin/pneuma.ts +54 -13
  3. package/core/__tests__/fixtures/test-mode/manifest.ts +20 -0
  4. package/core/__tests__/mode-loader-external.test.ts +36 -0
  5. package/core/__tests__/mode-resolver.test.ts +155 -0
  6. package/core/mode-loader.ts +93 -26
  7. package/core/mode-resolver.ts +235 -0
  8. package/dist/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
  9. package/dist/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
  10. package/dist/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
  11. package/dist/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
  12. package/dist/assets/{EditorPanel-CfeaFr28.js → EditorPanel-MdL_JiTY.js} +17 -17
  13. package/dist/assets/Tableau10-B-NsZVaP.js +1 -0
  14. package/dist/assets/{TerminalPanel-mxIXJFij.js → TerminalPanel-CbFaJVv1.js} +1 -1
  15. package/dist/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
  16. package/dist/assets/ar-SA-G6X2FPQ2-DI1IvzZy.js +10 -0
  17. package/dist/assets/arc-D165-OAx.js +1 -0
  18. package/dist/assets/array-BKyUJesY.js +1 -0
  19. package/dist/assets/az-AZ-76LH7QW2-DYGh7wld.js +1 -0
  20. package/dist/assets/bg-BG-XCXSNQG7-BPdSa6MK.js +5 -0
  21. package/dist/assets/blockDiagram-38ab4fdb-CEu2OzeC.js +118 -0
  22. package/dist/assets/bn-BD-2XOGV67Q-DkIs-slB.js +5 -0
  23. package/dist/assets/c4Diagram-3d4e48cf-DoIsm8EF.js +10 -0
  24. package/dist/assets/ca-ES-6MX7JW3Y-L170HCw7.js +8 -0
  25. package/dist/assets/channel-D5iBNjbH.js +1 -0
  26. package/dist/assets/classDiagram-70f12bd4-QQ76MqW5.js +2 -0
  27. package/dist/assets/classDiagram-v2-f2320105-BmabMbok.js +2 -0
  28. package/dist/assets/clone-BEE8hBIa.js +1 -0
  29. package/dist/assets/createText-2e5e7dd3-tL3VdL-p.js +7 -0
  30. package/dist/assets/cs-CZ-2BRQDIVT-d9QyNfe3.js +11 -0
  31. package/dist/assets/da-DK-5WZEPLOC-C9h-0UH1.js +5 -0
  32. package/dist/assets/de-DE-XR44H4JA-0_J2pE7J.js +8 -0
  33. package/dist/assets/directory-open-01563666-DWU9wJ6I.js +1 -0
  34. package/dist/assets/directory-open-4ed118d0-CunoC1EB.js +1 -0
  35. package/dist/assets/edges-e0da2a9e-9Gd7e5_q.js +4 -0
  36. package/dist/assets/el-GR-BZB4AONW-CPGoB07j.js +10 -0
  37. package/dist/assets/erDiagram-9861fffd--6rJdAlm.js +51 -0
  38. package/dist/assets/es-ES-U4NZUMDT-0MvEQWSD.js +9 -0
  39. package/dist/assets/eu-ES-A7QVB2H4-BRU3FvSm.js +11 -0
  40. package/dist/assets/extends-CF3RwP-h.js +1 -0
  41. package/dist/assets/fa-IR-HGAKTJCU-CnBKZHHO.js +8 -0
  42. package/dist/assets/fi-FI-Z5N7JZ37-BgG37SFV.js +6 -0
  43. package/dist/assets/file-open-002ab408-DIuFHtCF.js +1 -0
  44. package/dist/assets/file-open-7c801643-684qeFg4.js +1 -0
  45. package/dist/assets/file-save-3189631c-C1wFhQhH.js +1 -0
  46. package/dist/assets/file-save-745eba88-Bb9F9Kg7.js +1 -0
  47. package/dist/assets/flowDb-956e92f1-DLjSZLGX.js +10 -0
  48. package/dist/assets/flowDiagram-66a62f08-B3qKWVtA.js +4 -0
  49. package/dist/assets/flowDiagram-v2-96b9c2cf-DSrHZQLe.js +1 -0
  50. package/dist/assets/flowchart-elk-definition-4a651766-tTc1ftqg.js +139 -0
  51. package/dist/assets/fr-FR-RHASNOE6-Jl6lji35.js +9 -0
  52. package/dist/assets/ganttDiagram-c361ad54-lSzFV-3P.js +257 -0
  53. package/dist/assets/gitGraphDiagram-72cf32ee-7SNlQmFU.js +70 -0
  54. package/dist/assets/gl-ES-HMX3MZ6V-BFbSruD0.js +10 -0
  55. package/dist/assets/graph-BcpGRXRu.js +1 -0
  56. package/dist/assets/he-IL-6SHJWFNN-BNLVLLSm.js +10 -0
  57. package/dist/assets/hi-IN-IWLTKZ5I-Dsj3ZWPl.js +4 -0
  58. package/dist/assets/hu-HU-A5ZG7DT2-DCbDqW_4.js +7 -0
  59. package/dist/assets/id-ID-SAP4L64H-BmeTOocX.js +10 -0
  60. package/dist/assets/image-blob-reduce.esm-D6s-rqMO.js +7 -0
  61. package/dist/assets/index-3862675e-DIhdBfTo.js +1 -0
  62. package/dist/assets/index-BcAUh2Nm.css +1 -0
  63. package/dist/assets/index-DB9RcBPw.css +1 -0
  64. package/dist/assets/index-DE39xQEr.js +95 -0
  65. package/dist/assets/index-DcCNGeQt.js +1 -0
  66. package/dist/assets/index-qUAZT-v0.js +97 -0
  67. package/dist/assets/infoDiagram-f8f76790-GkJF5VfW.js +7 -0
  68. package/dist/assets/init-Gi6I4Gst.js +1 -0
  69. package/dist/assets/it-IT-JPQ66NNP-B7_H3wxP.js +11 -0
  70. package/dist/assets/ja-JP-DBVTYXUO-Kiyckl1D.js +8 -0
  71. package/dist/assets/journeyDiagram-49397b02-BgjYuH61.js +139 -0
  72. package/dist/assets/kaa-6HZHGXH3-C9JGzPVZ.js +1 -0
  73. package/dist/assets/kab-KAB-ZGHBKWFO-Byx6HzwL.js +8 -0
  74. package/dist/assets/katex-BlHpptmG.js +261 -0
  75. package/dist/assets/kk-KZ-P5N5QNE5-Cgk7qzOf.js +1 -0
  76. package/dist/assets/km-KH-HSX4SM5Z-Elll5unm.js +11 -0
  77. package/dist/assets/ko-KR-MTYHY66A-CsOtBxCZ.js +9 -0
  78. package/dist/assets/ku-TR-6OUDTVRD-CuTEXM4p.js +9 -0
  79. package/dist/assets/layout-CUWfUNS2.js +1 -0
  80. package/dist/assets/line-CVwrY-Vb.js +1 -0
  81. package/dist/assets/linear-BrME-eAl.js +1 -0
  82. package/dist/assets/lt-LT-XHIRWOB4-C8c0WkUW.js +3 -0
  83. package/dist/assets/lv-LV-5QDEKY6T-CEKRGmw8.js +7 -0
  84. package/dist/assets/{manifest-D0B-XRFy.js → manifest-BHamNdmX.js} +1 -1
  85. package/dist/assets/manifest-CjjXAwgC.js +10 -0
  86. package/dist/assets/mindmap-definition-fc14e90a-U0UAQ-Fw.js +425 -0
  87. package/dist/assets/mr-IN-CRQNXWMA-CTxbfZqj.js +13 -0
  88. package/dist/assets/my-MM-5M5IBNSE-DcDY6QTm.js +1 -0
  89. package/dist/assets/nb-NO-T6EIAALU-C6KHf4vB.js +10 -0
  90. package/dist/assets/nl-NL-IS3SIHDZ-BGmeQ17q.js +8 -0
  91. package/dist/assets/nn-NO-6E72VCQL-C-tmVDb4.js +8 -0
  92. package/dist/assets/oc-FR-POXYY2M6-C5fnow4b.js +8 -0
  93. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  94. package/dist/assets/pa-IN-N4M65BXN-D6t69GoD.js +4 -0
  95. package/dist/assets/path-CbwjOpE9.js +1 -0
  96. package/dist/assets/percentages-BXMCSKIN-CuqegRFs.js +215 -0
  97. package/dist/assets/pica-DICzK7iG.js +7 -0
  98. package/dist/assets/pieDiagram-8a3498a8-CbbXLjm7.js +35 -0
  99. package/dist/assets/pl-PL-T2D74RX3-DER2H0wo.js +9 -0
  100. package/dist/assets/{pneuma-mode-DPKBeHJ5.js → pneuma-mode-D2DgSpg6.js} +6 -6
  101. package/dist/assets/pneuma-mode-DlMa-HY8.js +3 -0
  102. package/dist/assets/{pneuma-mode-Ru5wiX6u.js → pneuma-mode-Ogulzc3b.js} +1 -1
  103. package/dist/assets/pt-BR-5N22H2LF-CJHfbbIQ.js +9 -0
  104. package/dist/assets/pt-PT-UZXXM6DQ-CkCsXMDT.js +9 -0
  105. package/dist/assets/quadrantDiagram-120e2f19-D_mc357R.js +7 -0
  106. package/dist/assets/requirementDiagram-deff3bca-zCTbZYaF.js +52 -0
  107. package/dist/assets/ro-RO-JPDTUUEW-BSHMxikS.js +11 -0
  108. package/dist/assets/roundRect-0PYZxl1G.js +1 -0
  109. package/dist/assets/ru-RU-B4JR7IUQ-DhnnXCsj.js +9 -0
  110. package/dist/assets/sankeyDiagram-04a897e0-BvDjTJE5.js +8 -0
  111. package/dist/assets/sequenceDiagram-704730f1-DyDtB4bR.js +122 -0
  112. package/dist/assets/si-LK-N5RQ5JYF-CBpcfcVd.js +1 -0
  113. package/dist/assets/sk-SK-C5VTKIMK-AQrsUpWY.js +6 -0
  114. package/dist/assets/sl-SI-NN7IZMDC-CT83Tcto.js +6 -0
  115. package/dist/assets/stateDiagram-587899a1-BLA3XkRF.js +1 -0
  116. package/dist/assets/stateDiagram-v2-d93cdb3a-DR7IYUqf.js +1 -0
  117. package/dist/assets/styles-6aaf32cf-D2GttATi.js +207 -0
  118. package/dist/assets/styles-9a916d00-BpiIjJGB.js +160 -0
  119. package/dist/assets/styles-c10674c1-DvdNSnC7.js +116 -0
  120. package/dist/assets/subset-shared.chunk-B4kMbE2H.js +84 -0
  121. package/dist/assets/subset-worker.chunk-CPiIs6nq.js +1 -0
  122. package/dist/assets/sv-SE-XGPEYMSR-BoK_Plbz.js +10 -0
  123. package/dist/assets/svgDrawCommon-08f97a94-3WJM28cv.js +1 -0
  124. package/dist/assets/ta-IN-2NMHFXQM-d4L55p0Z.js +9 -0
  125. package/dist/assets/th-TH-HPSO5L25-BM_rrii7.js +2 -0
  126. package/dist/assets/timeline-definition-85554ec2-CyUtcC-f.js +61 -0
  127. package/dist/assets/tr-TR-DEFEU3FU-HX-ieOrB.js +7 -0
  128. package/dist/assets/uk-UA-QMV73CPH-BLoCnHH_.js +6 -0
  129. package/dist/assets/vi-VN-M7AON7JQ-25ijC9l1.js +5 -0
  130. package/dist/assets/xychartDiagram-e933f94c-BnuVVpgF.js +7 -0
  131. package/dist/assets/zh-CN-LNUGB5OW-C40B56rX.js +10 -0
  132. package/dist/assets/zh-HK-E62DVLB3-NkoZbsR_.js +1 -0
  133. package/dist/assets/zh-TW-RAJ6MFWO-DVkT3BzC.js +9 -0
  134. package/dist/index.html +2 -2
  135. package/modes/doc/manifest.ts +1 -1
  136. package/modes/doc/pneuma-mode.ts +1 -1
  137. package/modes/doc/seed/README.md +76 -0
  138. package/modes/draw/manifest.ts +56 -0
  139. package/modes/draw/pneuma-mode.ts +44 -0
  140. package/modes/draw/seed/drawing.excalidraw +526 -0
  141. package/modes/draw/skill/SKILL.md +201 -0
  142. package/modes/draw/viewer/DrawPreview.tsx +472 -0
  143. package/modes/slide/pneuma-mode.ts +1 -1
  144. package/modes/slide/skill/SKILL.md +21 -6
  145. package/modes/slide/skill/scripts/generate_image.mjs +389 -0
  146. package/modes/slide/{components → viewer}/SlideIframePool.tsx +9 -25
  147. package/package.json +2 -1
  148. package/server/__tests__/file-watcher.test.ts +123 -0
  149. package/server/__tests__/skill-installer.test.ts +282 -0
  150. package/server/__tests__/ws-bridge-browser.test.ts +464 -0
  151. package/server/__tests__/ws-bridge-controls.test.ts +205 -0
  152. package/server/__tests__/ws-bridge-replay.test.ts +284 -0
  153. package/server/index.ts +174 -40
  154. package/dist/assets/index-DC1enq6_.css +0 -1
  155. package/dist/assets/index-zOZ-kT8a.js +0 -95
  156. package/modes/slide/skill/scripts/generate_image.py +0 -333
  157. /package/modes/doc/{components → viewer}/DocPreview.tsx +0 -0
  158. /package/modes/slide/{components → viewer}/SlidePreview.tsx +0 -0
package/README.md CHANGED
@@ -12,7 +12,7 @@ ModeManifest(skill + viewer + agent_config) × AgentBackend × RuntimeShell
12
12
 
13
13
  ## Demo
14
14
 
15
- Ships with **Doc Mode** (markdown editing) and **Slide Mode** (presentation editing). Here's Doc Mode — Claude Code edits `.md` files and you see the rendered result in real-time:
15
+ Ships with **Doc Mode** (markdown editing), **Slide Mode** (presentation editing), and **Draw Mode** (Excalidraw whiteboard). Here's Doc Mode — Claude Code edits `.md` files and you see the rendered result in real-time:
16
16
 
17
17
  ```
18
18
  ┌─────────────────────────────┬──────────────────────────┐
@@ -69,8 +69,12 @@ This will:
69
69
  pneuma-skills <mode> [options]
70
70
 
71
71
  Modes:
72
- doc Markdown document editing mode
73
- slide Presentation editing mode (HTML slides with iframe preview)
72
+ doc Markdown document editing mode
73
+ slide Presentation editing mode (HTML slides with iframe preview)
74
+ draw Excalidraw whiteboard drawing mode
75
+ /path/to/mode Load mode from a local directory
76
+ github:user/repo Load mode from a GitHub repository
77
+ github:user/repo#branch Load mode from a specific branch/tag
74
78
 
75
79
  Options:
76
80
  --workspace <path> Target workspace directory (default: current directory)
@@ -78,6 +82,29 @@ Options:
78
82
  --no-open Don't auto-open the browser
79
83
  ```
80
84
 
85
+ ### Remote / External Modes
86
+
87
+ Pneuma supports loading modes from outside the built-in `modes/` directory:
88
+
89
+ ```bash
90
+ # Load from a local directory (must contain manifest.ts and pneuma-mode.ts)
91
+ bunx pneuma-skills /path/to/my-custom-mode --workspace ~/project
92
+
93
+ # Load from a GitHub repository
94
+ bunx pneuma-skills github:pandazki/pneuma-mode-canvas --workspace ~/project
95
+
96
+ # Load from a specific branch or tag
97
+ bunx pneuma-skills github:pandazki/pneuma-mode-canvas#develop --workspace ~/project
98
+ ```
99
+
100
+ GitHub repositories are cloned to `~/.pneuma/modes/` and cached locally. Subsequent runs will fetch the latest changes.
101
+
102
+ A mode package must contain:
103
+ - `manifest.ts` — default export of `ModeManifest`
104
+ - `pneuma-mode.ts` — default export of `ModeDefinition`
105
+ - `viewer/` — React preview components
106
+ - `skill/` — Skill files (optional)
107
+
81
108
  ## Architecture
82
109
 
83
110
  Pneuma is organized in four layers, each with a clear contract boundary:
@@ -90,7 +117,7 @@ Pneuma is organized in four layers, each with a clear contract boundary:
90
117
  ├─────────────────────────────────────────────────────────┤
91
118
  │ Layer 3: Content Viewer │
92
119
  │ ViewerContract — "how to render, select, update" │
93
- │ modes/doc/components/DocPreview.tsx
120
+ │ modes/doc/viewer/DocPreview.tsx
94
121
  ├─────────────────────────────────────────────────────────┤
95
122
  │ Layer 2: Agent Bridge │
96
123
  │ AgentBackend — "how to launch, communicate, lifecycle" │
@@ -112,11 +139,11 @@ When Claude Code edits files, chokidar detects the changes and pushes updated co
112
139
 
113
140
  | Contract | Responsibility | Extend to... |
114
141
  |----------|---------------|-------------|
115
- | **ModeManifest** | Declares skill, viewer config, agent preferences, init seeds | Add new modes (slide, mindmap, canvas) |
142
+ | **ModeManifest** | Declares skill, viewer config, agent preferences, init seeds | Add new modes (mindmap, canvas, etc.) |
116
143
  | **ViewerContract** | Preview component, context extraction, update strategy | Custom renderers (iframe, D3, Monaco) |
117
144
  | **AgentBackend** | Launch, resume, kill, capability declaration | Other agents (Codex, Aider) |
118
145
 
119
- Contracts are defined in `core/types/` with 42 tests in `core/__tests__/`.
146
+ Contracts are defined in `core/types/` with 81 tests in `core/__tests__/`.
120
147
 
121
148
  ## Project Structure
122
149
 
@@ -130,19 +157,25 @@ pneuma-skills/
130
157
  │ │ ├── agent-backend.ts # AgentBackend, AgentCapabilities
131
158
  │ │ ├── mode-definition.ts # ModeDefinition (manifest + viewer)
132
159
  │ │ └── index.ts # Re-exports
133
- │ ├── mode-loader.ts # Dynamic mode discovery and loading
134
- └── __tests__/ # 42 contract tests
160
+ │ ├── mode-loader.ts # Dynamic mode discovery and loading (builtin + external)
161
+ ├── mode-resolver.ts # Mode source resolution (builtin/local/github)
162
+ │ └── __tests__/ # 81 tests
135
163
  ├── modes/
136
164
  │ ├── doc/
137
165
  │ │ ├── pneuma-mode.ts # Doc Mode definition (manifest + viewer)
138
166
  │ │ ├── skill/SKILL.md # Skill prompt for Claude Code
139
- │ │ └── components/
167
+ │ │ └── viewer/
140
168
  │ │ └── DocPreview.tsx # Markdown preview with select/edit modes
141
- └── slide/
142
- ├── pneuma-mode.ts # Slide Mode definition (manifest + viewer)
143
- ├── skill/ # Skill package (SKILL.md + design docs + scripts)
144
- └── components/
145
- └── SlidePreview.tsx # Slide carousel with iframe preview
169
+ ├── slide/
170
+ ├── pneuma-mode.ts # Slide Mode definition (manifest + viewer)
171
+ ├── skill/ # Skill package (SKILL.md + design docs + scripts)
172
+ └── viewer/
173
+ └── SlidePreview.tsx # Slide carousel with iframe preview
174
+ │ └── draw/
175
+ │ ├── pneuma-mode.ts # Draw Mode definition (manifest + viewer)
176
+ │ ├── skill/SKILL.md # Skill prompt for Claude Code
177
+ │ └── viewer/
178
+ │ └── DrawPreview.tsx # Excalidraw editor
146
179
  ├── backends/
147
180
  │ └── claude-code/
148
181
  │ ├── index.ts # ClaudeCodeBackend implements AgentBackend
@@ -167,6 +200,10 @@ pneuma-skills/
167
200
  │ ├── ToolBlock.tsx # Expandable tool call cards
168
201
  │ ├── PermissionBanner.tsx # Tool permission approval UI
169
202
  │ └── TopBar.tsx # Tabs (Chat/Context/Terminal) + status
203
+ ├── snapshot/ # Snapshot push/pull via Cloudflare R2
204
+ │ ├── push.ts # Pack and upload workspace
205
+ │ ├── pull.ts # Download and extract workspace
206
+ │ └── r2.ts # R2 storage client
170
207
  └── docs/
171
208
  ├── adr/ # Architecture Decision Records (1-11)
172
209
  ├── design/ # Active design documents
@@ -185,6 +222,7 @@ pneuma-skills/
185
222
  | Markdown | [react-markdown](https://github.com/remarkjs/react-markdown) + remark-gfm |
186
223
  | Terminal | [xterm.js](https://xtermjs.org) + Bun native PTY |
187
224
  | File Watching | [chokidar](https://github.com/paulmillr/chokidar) 4 |
225
+ | Drawing | [Excalidraw](https://excalidraw.com) 0.18 |
188
226
  | Agent | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) via `--sdk-url` |
189
227
 
190
228
  ## Features
@@ -205,11 +243,12 @@ pneuma-skills/
205
243
 
206
244
  - [x] Doc Mode — Markdown WYSIWYG editing
207
245
  - [x] Slide Mode — Presentation editing with iframe preview, drag-reorder, AI image generation
246
+ - [x] Draw Mode — Excalidraw whiteboard with `.excalidraw` file editing
208
247
  - [x] Element selection & inline editing
209
248
  - [x] Session persistence & resume
210
249
  - [x] Terminal, tasks, context panel
211
250
  - [x] v1.0 contract architecture (ModeManifest, ViewerContract, AgentBackend)
212
- - [ ] Remote mode loading — `pneuma --mode github:user/repo` (v1.x)
251
+ - [x] Remote mode loading — `pneuma github:user/repo` or local path (v1.x)
213
252
  - [ ] Additional agent backends — Codex CLI, custom agents (v1.x)
214
253
 
215
254
  ## Acknowledgements
package/bin/pneuma.ts CHANGED
@@ -15,9 +15,11 @@ import { startServer } from "../server/index.js";
15
15
  import { ClaudeCodeBackend } from "../backends/claude-code/index.js";
16
16
  import { installSkill } from "../server/skill-installer.js";
17
17
  import { startFileWatcher } from "../server/file-watcher.js";
18
- import { loadModeManifest, listModes } from "../core/mode-loader.js";
18
+ import { loadModeManifest, listBuiltinModes, registerExternalMode } from "../core/mode-loader.js";
19
19
  import type { ModeManifest } from "../core/types/mode-manifest.js";
20
20
  import { applyTemplateParams } from "../server/skill-installer.js";
21
+ import { resolveMode as resolveModeSource, isExternalMode } from "../core/mode-resolver.js";
22
+ import type { ResolvedMode } from "../core/mode-resolver.js";
21
23
 
22
24
  const PROJECT_ROOT = resolve(dirname(import.meta.path), "..");
23
25
 
@@ -179,20 +181,40 @@ async function main() {
179
181
 
180
182
  const { mode, workspace, port, noOpen } = parseArgs(process.argv);
181
183
 
182
- // Validate mode
183
- const availableModes = listModes();
184
- if (!mode || !availableModes.includes(mode)) {
185
- const modeList = availableModes.join(" | ");
186
- console.log(`Usage: pneuma <${modeList}> --workspace /path/to/project [--port 17996] [--no-open]`);
184
+ // Validate mode — support builtin names, local paths, and github: specifiers
185
+ if (!mode) {
186
+ const modeList = listBuiltinModes().join("|");
187
+ console.log(
188
+ `Usage: pneuma <${modeList}|/path/to/mode|github:user/repo> [--workspace <path>] [--port <number>] [--no-open]`,
189
+ );
190
+ process.exit(1);
191
+ }
192
+
193
+ // Resolve mode source (builtin, local path, or github clone)
194
+ let resolved: ResolvedMode;
195
+ try {
196
+ resolved = await resolveModeSource(mode, PROJECT_ROOT);
197
+ } catch (err) {
198
+ const msg = err instanceof Error ? err.message : String(err);
199
+ console.error(`[pneuma] Failed to resolve mode "${mode}": ${msg}`);
187
200
  process.exit(1);
188
201
  }
189
202
 
203
+ // For external modes, register them in the mode-loader before loading
204
+ if (resolved.type !== "builtin") {
205
+ registerExternalMode(resolved.name, resolved.path);
206
+ console.log(`[pneuma] External mode "${resolved.name}" loaded from ${resolved.path}`);
207
+ }
208
+
190
209
  // Load mode manifest (no React deps — backend safe)
210
+ // Use resolved.name for lookup since external modes are registered under that name
211
+ const modeName = resolved.name;
191
212
  let manifest: ModeManifest;
192
213
  try {
193
- manifest = await loadModeManifest(mode);
214
+ manifest = await loadModeManifest(modeName);
194
215
  } catch (err) {
195
- console.error(`[pneuma] Failed to load mode "${mode}":`, err);
216
+ const msg = err instanceof Error ? err.message : String(err);
217
+ console.error(`[pneuma] Failed to load mode "${modeName}": ${msg}`);
196
218
  process.exit(1);
197
219
  }
198
220
 
@@ -206,7 +228,7 @@ async function main() {
206
228
  console.log(`[pneuma] Created workspace: ${workspace}`);
207
229
  }
208
230
 
209
- console.log(`[pneuma] Mode: ${manifest.displayName} (${mode})`);
231
+ console.log(`[pneuma] Mode: ${manifest.displayName} (${modeName})`);
210
232
  console.log(`[pneuma] Workspace: ${workspace}`);
211
233
 
212
234
  // 0.5 Resolve init params (interactive on first run, then cached)
@@ -228,7 +250,8 @@ async function main() {
228
250
  }
229
251
 
230
252
  // 1. Install skill + inject CLAUDE.md (driven by manifest)
231
- const modeSourceDir = resolve(PROJECT_ROOT, "modes", mode);
253
+ // Use resolved path for external modes, PROJECT_ROOT/modes/{name} for builtin
254
+ const modeSourceDir = resolved.path;
232
255
  const skillTarget = join(workspace, ".claude", "skills", manifest.skill.installName);
233
256
  let skipSkillInstall = false;
234
257
 
@@ -263,8 +286,11 @@ async function main() {
263
286
 
264
287
  if (!hasContent && manifest.init.seedFiles) {
265
288
  const hasParams = Object.keys(resolvedParams).length > 0;
289
+ // For builtin modes, seed paths are relative to PROJECT_ROOT
290
+ // For external modes, seed paths are relative to the mode package directory
291
+ const seedBase = resolved.type === "builtin" ? PROJECT_ROOT : resolved.path;
266
292
  for (const [src, dst] of Object.entries(manifest.init.seedFiles)) {
267
- const srcPath = join(PROJECT_ROOT, src);
293
+ const srcPath = join(seedBase, src);
268
294
  if (existsSync(srcPath)) {
269
295
  const dstPath = join(workspace, dst);
270
296
  mkdirSync(dirname(dstPath), { recursive: true });
@@ -302,6 +328,10 @@ async function main() {
302
328
  watchPatterns: manifest.viewer.watchPatterns,
303
329
  ...(isDev ? {} : { distDir }),
304
330
  ...(Object.keys(resolvedParams).length > 0 ? { initParams: resolvedParams } : {}),
331
+ // Pass external mode info for the /api/mode-info endpoint
332
+ ...(resolved.type !== "builtin"
333
+ ? { externalMode: { name: resolved.name, path: resolved.path, type: resolved.type } }
334
+ : {}),
305
335
  });
306
336
 
307
337
  // 4. Launch Agent backend (driven by manifest)
@@ -343,7 +373,7 @@ async function main() {
343
373
  saveSession(workspace, {
344
374
  sessionId: session.sessionId,
345
375
  agentSessionId: existing?.agentSessionId,
346
- mode,
376
+ mode: modeName,
347
377
  createdAt: existing?.createdAt || Date.now(),
348
378
  });
349
379
 
@@ -413,12 +443,23 @@ async function main() {
413
443
  // Dev mode: start Vite dev server
414
444
  const VITE_PORT = 17996;
415
445
  console.log(`[pneuma] Starting Vite dev server on port ${VITE_PORT}...`);
446
+
447
+ // Pass external mode path to Vite via env var (for server.fs.allow)
448
+ const viteEnv: Record<string, string> = {
449
+ ...process.env as Record<string, string>,
450
+ };
451
+ if (resolved.type !== "builtin") {
452
+ viteEnv.PNEUMA_EXTERNAL_MODE_PATH = resolved.path;
453
+ viteEnv.PNEUMA_EXTERNAL_MODE_NAME = resolved.name;
454
+ }
455
+
416
456
  viteProc = Bun.spawn(
417
457
  ["bunx", "vite", "--port", String(VITE_PORT), "--strictPort"],
418
458
  {
419
459
  cwd: PROJECT_ROOT,
420
460
  stdout: "pipe",
421
461
  stderr: "pipe",
462
+ env: viteEnv,
422
463
  }
423
464
  );
424
465
 
@@ -443,7 +484,7 @@ async function main() {
443
484
 
444
485
  // 7. Open browser (include mode in URL for frontend)
445
486
  if (!noOpen) {
446
- const url = `http://localhost:${browserPort}?session=${session.sessionId}&mode=${mode}`;
487
+ const url = `http://localhost:${browserPort}?session=${session.sessionId}&mode=${modeName}`;
447
488
  console.log(`[pneuma] Opening browser: ${url}`);
448
489
  try {
449
490
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Test mode manifest — used by mode-resolver integration tests.
3
+ */
4
+ const testManifest = {
5
+ name: "test-mode",
6
+ version: "0.1.0",
7
+ displayName: "Test Mode",
8
+ description: "A minimal mode for testing",
9
+ skill: {
10
+ sourceDir: "skill",
11
+ installName: "pneuma-test",
12
+ claudeMdSection: "## Test Mode",
13
+ },
14
+ viewer: {
15
+ watchPatterns: ["**/*.txt"],
16
+ ignorePatterns: ["node_modules/**"],
17
+ },
18
+ };
19
+
20
+ export default testManifest;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Mode Loader — External mode registration tests
3
+ *
4
+ * 验证 registerExternalMode 和 loadModeManifest 对外部 mode 的支持。
5
+ */
6
+
7
+ import { describe, test, expect } from "bun:test";
8
+ import { resolve, dirname } from "node:path";
9
+ import { registerExternalMode, loadModeManifest, listModes, listBuiltinModes } from "../mode-loader.js";
10
+
11
+ const TEST_MODE_PATH = resolve(dirname(import.meta.path), "fixtures/test-mode");
12
+
13
+ describe("registerExternalMode", () => {
14
+ test("registered external mode appears in listModes()", () => {
15
+ registerExternalMode("test-mode", TEST_MODE_PATH);
16
+ const modes = listModes();
17
+ expect(modes).toContain("test-mode");
18
+ expect(modes).toContain("doc");
19
+ expect(modes).toContain("slide");
20
+ });
21
+
22
+ test("listBuiltinModes() does not include external modes", () => {
23
+ const builtins = listBuiltinModes();
24
+ expect(builtins).toContain("doc");
25
+ expect(builtins).toContain("slide");
26
+ expect(builtins).not.toContain("test-mode");
27
+ });
28
+
29
+ test("loadModeManifest() works for registered external mode", async () => {
30
+ registerExternalMode("test-mode", TEST_MODE_PATH);
31
+ const manifest = await loadModeManifest("test-mode");
32
+ expect(manifest.name).toBe("test-mode");
33
+ expect(manifest.version).toBe("0.1.0");
34
+ expect(manifest.displayName).toBe("Test Mode");
35
+ });
36
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Mode Resolver 测试
3
+ *
4
+ * 验证 mode 来源解析:
5
+ * - builtin mode 名称识别
6
+ * - local path 解析 (绝对路径、相对路径、~ 展开)
7
+ * - github 格式解析
8
+ * - 错误处理
9
+ */
10
+
11
+ import { describe, test, expect } from "bun:test";
12
+ import { parseModeSpecifier, isExternalMode, resolveMode } from "../mode-resolver.js";
13
+ import { homedir } from "node:os";
14
+ import { resolve, dirname, join } from "node:path";
15
+
16
+ describe("parseModeSpecifier", () => {
17
+ // ── Builtin modes ──────────────────────────────────────────────────
18
+
19
+ test("recognizes builtin mode: doc", () => {
20
+ const result = parseModeSpecifier("doc");
21
+ expect(result.type).toBe("builtin");
22
+ expect(result.name).toBe("doc");
23
+ });
24
+
25
+ test("recognizes builtin mode: slide", () => {
26
+ const result = parseModeSpecifier("slide");
27
+ expect(result.type).toBe("builtin");
28
+ expect(result.name).toBe("slide");
29
+ });
30
+
31
+ test("unknown plain name falls back to builtin (let mode-loader handle it)", () => {
32
+ const result = parseModeSpecifier("mindmap");
33
+ expect(result.type).toBe("builtin");
34
+ expect(result.name).toBe("mindmap");
35
+ });
36
+
37
+ // ── Local paths ────────────────────────────────────────────────────
38
+
39
+ test("parses absolute path as local mode", () => {
40
+ const result = parseModeSpecifier("/home/user/my-mode");
41
+ expect(result.type).toBe("local");
42
+ expect(result.name).toBe("my-mode");
43
+ expect(result.localPath).toBe("/home/user/my-mode");
44
+ });
45
+
46
+ test("parses relative path with ./ as local mode", () => {
47
+ const result = parseModeSpecifier("./modes/custom");
48
+ expect(result.type).toBe("local");
49
+ expect(result.name).toBe("custom");
50
+ expect(result.localPath).toBeTruthy();
51
+ });
52
+
53
+ test("parses relative path with ../ as local mode", () => {
54
+ const result = parseModeSpecifier("../other-project/my-mode");
55
+ expect(result.type).toBe("local");
56
+ expect(result.name).toBe("my-mode");
57
+ expect(result.localPath).toBeTruthy();
58
+ });
59
+
60
+ test("expands ~ to home directory", () => {
61
+ const result = parseModeSpecifier("~/my-modes/custom");
62
+ expect(result.type).toBe("local");
63
+ expect(result.name).toBe("custom");
64
+ expect(result.localPath).toStartWith(homedir());
65
+ });
66
+
67
+ // ── GitHub specifiers ──────────────────────────────────────────────
68
+
69
+ test("parses github:user/repo", () => {
70
+ const result = parseModeSpecifier("github:pandazki/pneuma-mode-canvas");
71
+ expect(result.type).toBe("github");
72
+ expect(result.name).toBe("pandazki-pneuma-mode-canvas");
73
+ expect(result.github).toEqual({
74
+ user: "pandazki",
75
+ repo: "pneuma-mode-canvas",
76
+ ref: "main",
77
+ });
78
+ });
79
+
80
+ test("parses github:user/repo#branch", () => {
81
+ const result = parseModeSpecifier("github:pandazki/my-mode#develop");
82
+ expect(result.type).toBe("github");
83
+ expect(result.name).toBe("pandazki-my-mode");
84
+ expect(result.github).toEqual({
85
+ user: "pandazki",
86
+ repo: "my-mode",
87
+ ref: "develop",
88
+ });
89
+ });
90
+
91
+ test("parses github:user/repo#tag", () => {
92
+ const result = parseModeSpecifier("github:user/repo#v1.0.0");
93
+ expect(result.type).toBe("github");
94
+ expect(result.github?.ref).toBe("v1.0.0");
95
+ });
96
+
97
+ test("throws for invalid github specifier (no repo)", () => {
98
+ expect(() => parseModeSpecifier("github:user")).toThrow("Invalid GitHub mode specifier");
99
+ });
100
+
101
+ test("throws for invalid github specifier (empty user)", () => {
102
+ expect(() => parseModeSpecifier("github:/repo")).toThrow("Invalid GitHub mode specifier");
103
+ });
104
+ });
105
+
106
+ // ── resolveMode integration tests ─────────────────────────────────────
107
+
108
+ const PROJECT_ROOT = resolve(dirname(import.meta.path), "../..");
109
+
110
+ describe("resolveMode", () => {
111
+ test("resolves builtin mode to modes/ directory", async () => {
112
+ const result = await resolveMode("doc", PROJECT_ROOT);
113
+ expect(result.type).toBe("builtin");
114
+ expect(result.name).toBe("doc");
115
+ expect(result.path).toBe(join(PROJECT_ROOT, "modes", "doc"));
116
+ });
117
+
118
+ test("resolves local path to absolute directory", async () => {
119
+ const testModePath = resolve(dirname(import.meta.path), "fixtures/test-mode");
120
+ const result = await resolveMode(testModePath, PROJECT_ROOT);
121
+ expect(result.type).toBe("local");
122
+ expect(result.name).toBe("test-mode");
123
+ expect(result.path).toBe(testModePath);
124
+ });
125
+
126
+ test("throws for non-existent local path", async () => {
127
+ await expect(resolveMode("/nonexistent/path/to/mode", PROJECT_ROOT)).rejects.toThrow(
128
+ "Local mode directory not found",
129
+ );
130
+ });
131
+
132
+ test("throws for local path without manifest", async () => {
133
+ // Use a directory that exists but has no manifest.ts
134
+ const tmpDir = resolve(dirname(import.meta.path), "fixtures");
135
+ await expect(resolveMode(tmpDir, PROJECT_ROOT)).rejects.toThrow(
136
+ "missing manifest.ts",
137
+ );
138
+ });
139
+ });
140
+
141
+ describe("isExternalMode", () => {
142
+ test("builtin modes are not external", () => {
143
+ expect(isExternalMode("doc")).toBe(false);
144
+ expect(isExternalMode("slide")).toBe(false);
145
+ });
146
+
147
+ test("local paths are external", () => {
148
+ expect(isExternalMode("/path/to/mode")).toBe(true);
149
+ expect(isExternalMode("./my-mode")).toBe(true);
150
+ });
151
+
152
+ test("github specifiers are external", () => {
153
+ expect(isExternalMode("github:user/repo")).toBe(true);
154
+ });
155
+ });
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Mode Loader — 解析、安装、加载 Mode。
3
3
  *
4
- * v1.0: 只支持 builtin mode,通过动态 import 加载。
5
- * v1.x: 支持 local path remote URL。
4
+ * 支持三种来源:
5
+ * - builtin: 内置 mode,从 modes/ 目录动态 import
6
+ * - local: 本地文件系统路径
7
+ * - github: GitHub 仓库 (通过 mode-resolver 克隆到本地缓存)
6
8
  *
7
9
  * 核心流程: resolveMode → ensureInstalled → loadFromSource
8
10
  */
@@ -13,15 +15,21 @@ import type { ModeDefinition } from "./types/mode-definition.js";
13
15
  /**
14
16
  * Mode 来源类型:
15
17
  * - "builtin" — 内置 mode,从 modes/ 目录动态 import
18
+ * - "external" — 外部 mode,从绝对路径动态 import (local path 或 github clone)
16
19
  */
17
- type ModeSource = {
18
- type: "builtin";
19
- manifestLoader: () => Promise<ModeManifest>;
20
- definitionLoader: () => Promise<ModeDefinition>;
21
- };
22
- // v1.x:
23
- // | { type: "local"; path: string }
24
- // | { type: "remote"; url: string }
20
+ type ModeSource =
21
+ | {
22
+ type: "builtin";
23
+ manifestLoader: () => Promise<ModeManifest>;
24
+ definitionLoader: () => Promise<ModeDefinition>;
25
+ }
26
+ | {
27
+ type: "external";
28
+ name: string;
29
+ path: string;
30
+ manifestLoader: () => Promise<ModeManifest>;
31
+ definitionLoader: () => Promise<ModeDefinition>;
32
+ };
25
33
 
26
34
  /** 内置 mode 注册表 — 全部使用动态 import */
27
35
  const builtinModes: Record<string, ModeSource> = {
@@ -39,8 +47,18 @@ const builtinModes: Record<string, ModeSource> = {
39
47
  definitionLoader: () =>
40
48
  import("../modes/slide/pneuma-mode.js").then((m) => m.default),
41
49
  },
50
+ draw: {
51
+ type: "builtin",
52
+ manifestLoader: () =>
53
+ import("../modes/draw/manifest.js").then((m) => m.default),
54
+ definitionLoader: () =>
55
+ import("../modes/draw/pneuma-mode.js").then((m) => m.default),
56
+ },
42
57
  };
43
58
 
59
+ /** 外部 mode 注册表 — 由 CLI 在启动时通过 registerExternalMode 注册 */
60
+ const externalModes: Record<string, ModeSource> = {};
61
+
44
62
  // ── Public API ───────────────────────────────────────────────────────────────
45
63
 
46
64
  /**
@@ -64,35 +82,84 @@ export async function loadModeManifest(name: string): Promise<ModeManifest> {
64
82
  }
65
83
 
66
84
  /**
67
- * 列出所有已注册的 mode 名称。
85
+ * 列出所有已注册的 mode 名称 (包括 builtin 和已注册的 external)。
68
86
  */
69
87
  export function listModes(): string[] {
88
+ return [...Object.keys(builtinModes), ...Object.keys(externalModes)];
89
+ }
90
+
91
+ /**
92
+ * 列出内置 mode 名称。
93
+ */
94
+ export function listBuiltinModes(): string[] {
70
95
  return Object.keys(builtinModes);
71
96
  }
72
97
 
98
+ /**
99
+ * 注册外部 mode (由 CLI 在启动时调用)。
100
+ *
101
+ * Backend context (Bun): 直接用 import() 加载绝对路径。
102
+ * Frontend context (browser/Vite): 用 /@fs/ URL 加载。
103
+ *
104
+ * @param name — Mode 名称 (用于注册和查找)
105
+ * @param absPath — Mode 包的绝对路径
106
+ */
107
+ export function registerExternalMode(name: string, absPath: string): void {
108
+ const isBrowser = typeof window !== "undefined";
109
+
110
+ if (isBrowser) {
111
+ // Frontend: use Vite's /@fs/ URL scheme for dev mode
112
+ externalModes[name] = {
113
+ type: "external",
114
+ name,
115
+ path: absPath,
116
+ manifestLoader: () =>
117
+ import(/* @vite-ignore */ `/@fs${absPath}/manifest.ts`).then(
118
+ (m) => m.default,
119
+ ),
120
+ definitionLoader: () =>
121
+ import(/* @vite-ignore */ `/@fs${absPath}/pneuma-mode.ts`).then(
122
+ (m) => m.default,
123
+ ),
124
+ };
125
+ } else {
126
+ // Backend (Bun): use direct absolute path import
127
+ externalModes[name] = {
128
+ type: "external",
129
+ name,
130
+ path: absPath,
131
+ manifestLoader: () =>
132
+ import(/* @vite-ignore */ absPath + "/manifest.ts").then((m) => m.default),
133
+ definitionLoader: () =>
134
+ import(/* @vite-ignore */ absPath + "/pneuma-mode.ts").then((m) => m.default),
135
+ };
136
+ }
137
+ }
138
+
73
139
  // ── Internal ─────────────────────────────────────────────────────────────────
74
140
 
75
- /** 解析 mode 来源 (v1.0: 只查 builtin 注册表) */
141
+ /** 解析 mode 来源 ( builtin 和 external 注册表) */
76
142
  function resolveMode(name: string): ModeSource {
77
- const source = builtinModes[name];
78
- if (!source) {
79
- const available = Object.keys(builtinModes).join(", ");
80
- throw new Error(`Unknown mode: "${name}". Available: ${available}`);
81
- }
82
- return source;
143
+ // Check external modes first (allows overriding builtin names)
144
+ const external = externalModes[name];
145
+ if (external) return external;
146
+
147
+ const builtin = builtinModes[name];
148
+ if (builtin) return builtin;
149
+
150
+ const available = listModes();
151
+ throw new Error(
152
+ `Unknown mode: "${name}". Available: ${available.join(", ")}`,
153
+ );
83
154
  }
84
155
 
85
- /** 确保 mode 已安装 (v1.0: builtin 直接跳过) */
156
+ /** 确保 mode 已安装 (builtin 直接跳过,external 已由 mode-resolver 处理) */
86
157
  async function ensureInstalled(_source: ModeSource): Promise<void> {
87
- if (_source.type === "builtin") return;
88
- // v1.x: 检查 .pneuma/modes/{name}/ 是否存在
89
- // 不存在 → 从 source.url 拉取/安装
90
- // 非信任来源 → 用户确认
158
+ // Both builtin and external modes are already resolved to local paths
159
+ return;
91
160
  }
92
161
 
93
162
  /** 从已安装的 source 加载完整 ModeDefinition */
94
163
  async function loadDefinition(source: ModeSource): Promise<ModeDefinition> {
95
- if (source.type === "builtin") return source.definitionLoader();
96
- // v1.x: dynamic import from .pneuma/modes/{name}/pneuma-mode.js
97
- throw new Error("Non-builtin mode loading not yet implemented");
164
+ return source.definitionLoader();
98
165
  }