pneuma-skills 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pandazki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # Pneuma Skills
2
+
3
+ **Let Code Agents do WYSIWYG editing on HTML-based content.**
4
+
5
+ > **"pneuma"** — Greek *pneuma*, meaning soul, breath, life force.
6
+
7
+ Pneuma Skills is a framework that connects a Code Agent (like Claude Code) to a browser-based editor, giving users a real-time WYSIWYG experience for AI-assisted content editing. The agent edits files on disk; Pneuma watches for changes and streams a live preview to the browser alongside a chat interface.
8
+
9
+ ```
10
+ Pneuma Skills = Content Mode x Code Agent Backend x Editor Shell
11
+ ```
12
+
13
+ ## Demo
14
+
15
+ Currently ships with **Doc Mode** — a markdown editing environment where Claude Code edits `.md` files and you see the rendered result in real-time.
16
+
17
+ ```
18
+ ┌─────────────────────────────┬──────────────────────────┐
19
+ │ │ Chat with Claude Code │
20
+ │ Live Markdown Preview │ │
21
+ │ │ > Add a features section│
22
+ │ # My Document │ │
23
+ │ ## Features │ [Thinking... 3s] │
24
+ │ - Real-time preview │ │
25
+ │ - GFM support │ ✎ Edit README.md │
26
+ │ - Image rendering │ ✎ Write hero.png │
27
+ │ │ │
28
+ ├─────────────────────────────┴──────────────────────────┤
29
+ │ ● Connected session:abc123 $0.02 3 turns │
30
+ └────────────────────────────────────────────────────────┘
31
+ ```
32
+
33
+ ## Prerequisites
34
+
35
+ - [Bun](https://bun.sh) >= 1.0
36
+ - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated (`claude` command available in PATH)
37
+
38
+ ## Quick Start
39
+
40
+ ```bash
41
+ # Clone and install
42
+ git clone https://github.com/pandazki/pneuma-skills.git
43
+ cd pneuma-skills
44
+ bun install
45
+
46
+ # Start Doc Mode on a workspace directory
47
+ bun bin/pneuma.ts doc --workspace ~/my-notes
48
+
49
+ # Or use the current directory
50
+ bun bin/pneuma.ts doc
51
+ ```
52
+
53
+ This will:
54
+
55
+ 1. Install a skill prompt into `<workspace>/.claude/skills/`
56
+ 2. Start the Pneuma server (default port 17996)
57
+ 3. Spawn a Claude Code CLI session connected via WebSocket
58
+ 4. Open your browser with the editor UI
59
+
60
+ ## CLI Usage
61
+
62
+ ```
63
+ pneuma <mode> [options]
64
+
65
+ Modes:
66
+ doc Markdown document editing mode
67
+
68
+ Options:
69
+ --workspace <path> Target workspace directory (default: current directory)
70
+ --port <number> Server port (default: 17996)
71
+ --no-open Don't auto-open the browser
72
+ ```
73
+
74
+ ## Architecture
75
+
76
+ ```
77
+ Browser (React) Pneuma Server (Bun + Hono) Claude Code CLI
78
+ ┌──────────────┐ JSON/WS ┌─────────────────────┐ NDJSON/WS ┌──────────────┐
79
+ │ Chat Panel │◄──────────────►│ │◄───────────────►│ │
80
+ │ Live Preview │ │ WebSocket Bridge │ │ claude │
81
+ │ Permissions │ │ File Watcher │ │ --sdk-url │
82
+ │ Status Bar │ │ Content Server │ │ │
83
+ └──────────────┘ └─────────────────────┘ └──────────────┘
84
+
85
+ watches disk
86
+
87
+ ┌─────────────┐
88
+ │ Workspace │
89
+ │ *.md files │
90
+ └─────────────┘
91
+ ```
92
+
93
+ The server maintains dual WebSocket channels:
94
+ - **Browser channel** (`/ws/browser/:sessionId`) — JSON messages for the React UI
95
+ - **CLI channel** (`/ws/cli/:sessionId`) — NDJSON messages for Claude Code's `--sdk-url` protocol
96
+
97
+ When Claude Code edits files, chokidar detects the changes and pushes updated content to the browser for live preview.
98
+
99
+ ## Project Structure
100
+
101
+ ```
102
+ pneuma-skills/
103
+ ├── bin/pneuma.ts # CLI entry point
104
+ ├── server/
105
+ │ ├── index.ts # Hono HTTP server + content API
106
+ │ ├── ws-bridge.ts # Dual WebSocket bridge (browser <-> CLI)
107
+ │ ├── cli-launcher.ts # Claude Code process spawner
108
+ │ ├── file-watcher.ts # chokidar-based file watcher
109
+ │ └── skill-installer.ts # Copies skill prompts to workspace
110
+ ├── src/
111
+ │ ├── App.tsx # Root layout (resizable panels)
112
+ │ ├── main.tsx # React entry
113
+ │ ├── store.ts # Zustand state management
114
+ │ ├── ws.ts # Browser WebSocket client
115
+ │ ├── types.ts # Shared TypeScript types
116
+ │ └── components/
117
+ │ ├── ChatPanel.tsx # Chat message feed
118
+ │ ├── ChatInput.tsx # Message input box
119
+ │ ├── MarkdownPreview.tsx # Live markdown renderer
120
+ │ ├── MessageBubble.tsx # Rich message rendering (text, tools, thinking)
121
+ │ ├── ToolBlock.tsx # Expandable tool call cards
122
+ │ ├── StreamingText.tsx # Streaming response display
123
+ │ ├── ActivityIndicator.tsx # Thinking/tool progress indicator
124
+ │ ├── PermissionBanner.tsx # Tool permission approval UI
125
+ │ └── StatusBar.tsx # Connection status + session info
126
+ ├── skill/
127
+ │ └── doc/SKILL.md # Doc Mode skill prompt for Claude Code
128
+ ├── docs/adr/ # Architecture Decision Records
129
+ └── draft.md # Full requirements document (Chinese)
130
+ ```
131
+
132
+ ## Tech Stack
133
+
134
+ | Layer | Technology |
135
+ |-------|-----------|
136
+ | Runtime | [Bun](https://bun.sh) |
137
+ | Server | [Hono](https://hono.dev) |
138
+ | Frontend | React 19 + [Vite](https://vite.dev) 6 |
139
+ | Styling | [Tailwind CSS](https://tailwindcss.com) 4 + Typography plugin |
140
+ | State | [Zustand](https://zustand.docs.pmnd.rs) 5 |
141
+ | Markdown | [react-markdown](https://github.com/remarkjs/react-markdown) + remark-gfm |
142
+ | File Watching | [chokidar](https://github.com/paulmillr/chokidar) 4 |
143
+ | Agent | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) via `--sdk-url` |
144
+
145
+ ## How It Works
146
+
147
+ 1. **Skill Installation** — Pneuma copies a mode-specific skill prompt (e.g. `skill/doc/SKILL.md`) into the workspace's `.claude/skills/` directory. Claude Code natively discovers and loads skills from this location.
148
+
149
+ 2. **Agent Spawning** — The CLI launcher spawns `claude --sdk-url ws://localhost:17007/ws/cli/<sessionId>` which connects Claude Code's streaming output to the Pneuma server.
150
+
151
+ 3. **Message Bridge** — The WebSocket bridge translates between the browser's JSON protocol and Claude Code's NDJSON protocol, handling message routing, permission flows, and event replay.
152
+
153
+ 4. **Live Preview** — When Claude Code writes or edits files, chokidar detects changes and pushes updated content to all connected browsers via WebSocket.
154
+
155
+ 5. **Rich Chat UI** — The browser renders Claude Code's full output: streaming text with markdown, expandable tool call cards, collapsible thinking blocks, and an activity indicator.
156
+
157
+ ## Content Mode System
158
+
159
+ Pneuma is designed to be extensible with different content modes. Each mode defines:
160
+
161
+ - **Renderer** — How to render the content in the browser
162
+ - **Skill** — Domain-specific prompt for the Code Agent
163
+ - **File Convention** — How content is organized on disk
164
+ - **Navigator** — Structural navigation (outline, page list, etc.)
165
+
166
+ Currently implemented: **Doc Mode** (markdown). Future modes could include slides, mindmaps, canvas, and more.
167
+
168
+ ## Roadmap
169
+
170
+ - [x] Doc Mode MVP — Markdown WYSIWYG editing
171
+ - [x] Element selection — Click to select and instruct edits on specific elements
172
+ - [ ] Slide Mode — Presentation editing with page navigation
173
+ - [ ] Session persistence — Resume previous editing sessions
174
+ - [ ] Multiple agent backends — Codex CLI, custom agents
175
+ - [ ] Production build — Single binary distribution
176
+
177
+ ## Acknowledgements
178
+
179
+ This project's WebSocket bridge, NDJSON protocol handling, and chat UI rendering are heavily ~~inspired by~~ copied from [Companion](https://github.com/The-Vibe-Company/companion) by The Vibe Company. To be honest, the entire technical approach was basically Claude Code reading Companion's source code and reproducing it here. We stand on the shoulders of giants — or more accurately, we asked an AI to stand on their shoulders for us.
180
+
181
+ Thank you Companion for figuring out the undocumented `--sdk-url` protocol so we didn't have to.
182
+
183
+ ## License
184
+
185
+ [MIT](LICENSE)
package/bin/pneuma.ts ADDED
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Pneuma Skills CLI entry point.
4
+ *
5
+ * Usage:
6
+ * pneuma doc --workspace /path/to/project [--port 17996] [--no-open]
7
+ */
8
+
9
+ import { resolve, dirname, join } from "node:path";
10
+ import { existsSync, copyFileSync, mkdirSync, readFileSync } from "node:fs";
11
+ import * as readline from "node:readline";
12
+ import { startServer } from "../server/index.js";
13
+ import { CliLauncher } from "../server/cli-launcher.js";
14
+ import { installSkill } from "../server/skill-installer.js";
15
+ import { startFileWatcher } from "../server/file-watcher.js";
16
+
17
+ const PROJECT_ROOT = resolve(dirname(import.meta.path), "..");
18
+
19
+ function parseArgs(argv: string[]) {
20
+ const args = argv.slice(2); // skip bun + script path
21
+ let mode = "";
22
+ let workspace = process.cwd();
23
+ let port = 0; // 0 = auto-detect based on mode
24
+ let noOpen = false;
25
+
26
+ for (let i = 0; i < args.length; i++) {
27
+ const arg = args[i];
28
+ if (arg === "--workspace" && i + 1 < args.length) {
29
+ workspace = args[++i];
30
+ } else if (arg === "--port" && i + 1 < args.length) {
31
+ port = Number(args[++i]);
32
+ } else if (arg === "--no-open") {
33
+ noOpen = true;
34
+ } else if (!arg.startsWith("--")) {
35
+ mode = arg;
36
+ }
37
+ }
38
+
39
+ return { mode, workspace: resolve(workspace), port, noOpen };
40
+ }
41
+
42
+ function ask(question: string): Promise<string> {
43
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
44
+ return new Promise((resolve) => {
45
+ rl.question(question, (answer) => {
46
+ rl.close();
47
+ resolve(answer.trim());
48
+ });
49
+ });
50
+ }
51
+
52
+ async function main() {
53
+ const { mode, workspace, port, noOpen } = parseArgs(process.argv);
54
+
55
+ if (!mode || mode !== "doc") {
56
+ console.log("Usage: pneuma doc --workspace /path/to/project [--port 17996] [--no-open]");
57
+ process.exit(1);
58
+ }
59
+
60
+ if (!existsSync(workspace)) {
61
+ const answer = await ask(`Workspace does not exist: ${workspace}\nCreate it? [Y/n] `);
62
+ if (answer.toLowerCase() === "n") {
63
+ console.log("[pneuma] Aborted.");
64
+ process.exit(0);
65
+ }
66
+ mkdirSync(workspace, { recursive: true });
67
+ console.log(`[pneuma] Created workspace: ${workspace}`);
68
+ }
69
+
70
+ console.log(`[pneuma] Mode: ${mode}`);
71
+ console.log(`[pneuma] Workspace: ${workspace}`);
72
+
73
+ // 1. Install skill + inject CLAUDE.md
74
+ console.log("[pneuma] Installing skill and preparing environment...");
75
+ installSkill(workspace);
76
+
77
+ // 1.5 Seed default content if workspace has no meaningful markdown files
78
+ const contentFiles = Array.from(
79
+ new Bun.Glob("**/*.md").scanSync({ cwd: workspace, absolute: false })
80
+ ).filter((f) => f !== "CLAUDE.md" && !f.startsWith(".claude/"));
81
+
82
+ const hasContent = contentFiles.some((f) => {
83
+ try {
84
+ return readFileSync(join(workspace, f), "utf-8").trim().length > 0;
85
+ } catch { return false; }
86
+ });
87
+
88
+ if (!hasContent) {
89
+ const readmeSrc = join(PROJECT_ROOT, "README.md");
90
+ if (existsSync(readmeSrc)) {
91
+ copyFileSync(readmeSrc, join(workspace, "README.md"));
92
+ console.log("[pneuma] Seeded workspace with default README.md");
93
+ }
94
+ }
95
+
96
+ // 2. Detect dev vs production mode
97
+ const distDir = resolve(PROJECT_ROOT, "dist");
98
+ const isDev = !existsSync(join(distDir, "index.html"));
99
+
100
+ if (isDev) {
101
+ console.log("[pneuma] Development mode (serving via Vite)");
102
+ } else {
103
+ console.log("[pneuma] Production mode (serving built assets)");
104
+ }
105
+
106
+ // 3. Start server
107
+ // Dev mode: backend on 17007, Vite on 17996 (user-facing)
108
+ // Prod mode: backend on 17996 (serves everything)
109
+ const serverPort = port || (isDev ? 17007 : 17996);
110
+ const { server, wsBridge, port: actualPort } = startServer({
111
+ port: serverPort,
112
+ workspace,
113
+ ...(isDev ? {} : { distDir }),
114
+ });
115
+
116
+ // 4. Launch CLI
117
+ const launcher = new CliLauncher(actualPort);
118
+
119
+ // When the CLI reports its internal session_id, store it
120
+ wsBridge.onCLISessionIdReceived((sessionId, cliSessionId) => {
121
+ launcher.setCLISessionId(sessionId, cliSessionId);
122
+ });
123
+
124
+ const session = launcher.launch({
125
+ cwd: workspace,
126
+ permissionMode: "bypassPermissions",
127
+ });
128
+
129
+ console.log(`[pneuma] CLI session started: ${session.sessionId}`);
130
+
131
+ // 5. Start file watcher
132
+ startFileWatcher(workspace, (files) => {
133
+ wsBridge.broadcastToSession(session.sessionId, {
134
+ type: "content_update",
135
+ files,
136
+ });
137
+ });
138
+
139
+ // 6. Frontend serving
140
+ let viteProc: ReturnType<typeof Bun.spawn> | null = null;
141
+ let browserPort = actualPort;
142
+
143
+ if (isDev) {
144
+ // Dev mode: start Vite dev server
145
+ const VITE_PORT = 17996;
146
+ console.log(`[pneuma] Starting Vite dev server on port ${VITE_PORT}...`);
147
+ viteProc = Bun.spawn(
148
+ ["bunx", "vite", "--port", String(VITE_PORT), "--strictPort"],
149
+ {
150
+ cwd: PROJECT_ROOT,
151
+ stdout: "pipe",
152
+ stderr: "pipe",
153
+ }
154
+ );
155
+
156
+ const pipeViteOutput = async (stream: ReadableStream<Uint8Array>) => {
157
+ const reader = stream.getReader();
158
+ const decoder = new TextDecoder();
159
+ while (true) {
160
+ const { done, value } = await reader.read();
161
+ if (done) break;
162
+ const text = decoder.decode(value, { stream: true });
163
+ for (const line of text.split("\n")) {
164
+ if (line.trim()) console.log(`[vite] ${line}`);
165
+ }
166
+ }
167
+ };
168
+ if (viteProc.stdout && typeof viteProc.stdout !== "number") pipeViteOutput(viteProc.stdout);
169
+ if (viteProc.stderr && typeof viteProc.stderr !== "number") pipeViteOutput(viteProc.stderr);
170
+ browserPort = VITE_PORT;
171
+ // Wait for Vite to start
172
+ await new Promise((r) => setTimeout(r, 2000));
173
+ }
174
+
175
+ // 7. Open browser
176
+ if (!noOpen) {
177
+ const url = `http://localhost:${browserPort}?session=${session.sessionId}`;
178
+ console.log(`[pneuma] Opening browser: ${url}`);
179
+ try {
180
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
181
+ Bun.spawn([opener, url], { stdout: "ignore", stderr: "ignore" });
182
+ } catch {
183
+ console.log(`[pneuma] Could not open browser. Visit: ${url}`);
184
+ }
185
+ }
186
+
187
+ // Graceful shutdown
188
+ const shutdown = async () => {
189
+ console.log("\n[pneuma] Shutting down...");
190
+ viteProc?.kill();
191
+ await launcher.killAll();
192
+ server.stop(true);
193
+ process.exit(0);
194
+ };
195
+ process.on("SIGTERM", shutdown);
196
+ process.on("SIGINT", shutdown);
197
+ }
198
+
199
+ main().catch((err) => {
200
+ console.error("[pneuma] Fatal error:", err);
201
+ process.exit(1);
202
+ });