@hienlh/ppm 0.1.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/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents a discovered Claude CLI installation.
|
|
6
|
+
* Mirrors opcode's ClaudeInstallation struct.
|
|
7
|
+
*/
|
|
8
|
+
export interface ClaudeInstallation {
|
|
9
|
+
path: string;
|
|
10
|
+
version: string | null;
|
|
11
|
+
source: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Source preference score (lower = better).
|
|
16
|
+
* Mirrors opcode's source_preference() function.
|
|
17
|
+
*/
|
|
18
|
+
function sourcePreference(source: string): number {
|
|
19
|
+
const order: Record<string, number> = {
|
|
20
|
+
"which": 1,
|
|
21
|
+
"homebrew": 2,
|
|
22
|
+
"system": 3,
|
|
23
|
+
"nvm-active": 4,
|
|
24
|
+
"local-bin": 6,
|
|
25
|
+
"claude-local": 7,
|
|
26
|
+
"npm-global": 8,
|
|
27
|
+
"yarn": 9,
|
|
28
|
+
"bun": 10,
|
|
29
|
+
"node-modules": 11,
|
|
30
|
+
"home-bin": 12,
|
|
31
|
+
"PATH": 13,
|
|
32
|
+
};
|
|
33
|
+
if (source.startsWith("nvm")) return 5;
|
|
34
|
+
return order[source] ?? 14;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Try `which claude` (Unix) to find the binary.
|
|
39
|
+
* Handles aliased output: "claude: aliased to /path/to/claude".
|
|
40
|
+
*/
|
|
41
|
+
function tryWhichCommand(): ClaudeInstallation | null {
|
|
42
|
+
try {
|
|
43
|
+
const result = Bun.spawnSync(["which", "claude"], { stdout: "pipe", stderr: "pipe" });
|
|
44
|
+
if (result.exitCode !== 0) return null;
|
|
45
|
+
|
|
46
|
+
let output = result.stdout.toString().trim();
|
|
47
|
+
if (!output) return null;
|
|
48
|
+
|
|
49
|
+
// Parse aliased output
|
|
50
|
+
if (output.startsWith("claude:") && output.includes("aliased to")) {
|
|
51
|
+
output = output.split("aliased to")[1]?.trim() ?? "";
|
|
52
|
+
}
|
|
53
|
+
if (!output || !existsSync(output)) return null;
|
|
54
|
+
|
|
55
|
+
return { path: output, version: getClaudeVersion(output), source: "which" };
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find Claude installations in NVM directories.
|
|
63
|
+
* Checks NVM_BIN env var and all NVM node version bin directories.
|
|
64
|
+
*/
|
|
65
|
+
function findNvmInstallations(): ClaudeInstallation[] {
|
|
66
|
+
const installations: ClaudeInstallation[] = [];
|
|
67
|
+
const home = process.env.HOME;
|
|
68
|
+
if (!home) return installations;
|
|
69
|
+
|
|
70
|
+
// Check NVM_BIN (current active NVM)
|
|
71
|
+
const nvmBin = process.env.NVM_BIN;
|
|
72
|
+
if (nvmBin) {
|
|
73
|
+
const claudePath = join(nvmBin, "claude");
|
|
74
|
+
if (existsSync(claudePath)) {
|
|
75
|
+
installations.push({
|
|
76
|
+
path: claudePath,
|
|
77
|
+
version: getClaudeVersion(claudePath),
|
|
78
|
+
source: "nvm-active",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Scan all NVM node versions
|
|
84
|
+
const nvmDir = join(home, ".nvm", "versions", "node");
|
|
85
|
+
try {
|
|
86
|
+
for (const entry of readdirSync(nvmDir) as string[]) {
|
|
87
|
+
const claudePath = join(nvmDir, entry, "bin", "claude");
|
|
88
|
+
if (existsSync(claudePath) && statSync(claudePath).isFile()) {
|
|
89
|
+
installations.push({
|
|
90
|
+
path: claudePath,
|
|
91
|
+
version: getClaudeVersion(claudePath),
|
|
92
|
+
source: `nvm (${entry})`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// NVM dir doesn't exist — skip
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return installations;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check standard installation paths.
|
|
105
|
+
* Mirrors opcode's find_standard_installations().
|
|
106
|
+
*/
|
|
107
|
+
function findStandardInstallations(): ClaudeInstallation[] {
|
|
108
|
+
const installations: ClaudeInstallation[] = [];
|
|
109
|
+
const home = process.env.HOME ?? "";
|
|
110
|
+
|
|
111
|
+
const pathsToCheck: [string, string][] = [
|
|
112
|
+
["/usr/local/bin/claude", "system"],
|
|
113
|
+
["/opt/homebrew/bin/claude", "homebrew"],
|
|
114
|
+
["/usr/bin/claude", "system"],
|
|
115
|
+
[join(home, ".claude/local/claude"), "claude-local"],
|
|
116
|
+
[join(home, ".local/bin/claude"), "local-bin"],
|
|
117
|
+
[join(home, ".npm-global/bin/claude"), "npm-global"],
|
|
118
|
+
[join(home, ".yarn/bin/claude"), "yarn"],
|
|
119
|
+
[join(home, ".bun/bin/claude"), "bun"],
|
|
120
|
+
[join(home, "bin/claude"), "home-bin"],
|
|
121
|
+
[join(home, "node_modules/.bin/claude"), "node-modules"],
|
|
122
|
+
[join(home, ".config/yarn/global/node_modules/.bin/claude"), "yarn-global"],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
for (const [path, source] of pathsToCheck) {
|
|
126
|
+
if (!path || !existsSync(path)) continue;
|
|
127
|
+
installations.push({
|
|
128
|
+
path,
|
|
129
|
+
version: getClaudeVersion(path),
|
|
130
|
+
source,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return installations;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get Claude version by running `<path> --version`.
|
|
139
|
+
* Extracts semver pattern from output.
|
|
140
|
+
*/
|
|
141
|
+
function getClaudeVersion(binaryPath: string): string | null {
|
|
142
|
+
try {
|
|
143
|
+
const result = Bun.spawnSync([binaryPath, "--version"], {
|
|
144
|
+
stdout: "pipe",
|
|
145
|
+
stderr: "pipe",
|
|
146
|
+
timeout: 5000,
|
|
147
|
+
});
|
|
148
|
+
if (result.exitCode !== 0) return null;
|
|
149
|
+
const output = result.stdout.toString();
|
|
150
|
+
const match = output.match(/(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)/);
|
|
151
|
+
return match?.[1] ?? null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Compare two semver strings. Returns -1, 0, or 1.
|
|
159
|
+
*/
|
|
160
|
+
function compareVersions(a: string, b: string): number {
|
|
161
|
+
const pa = a.split(".").map((s) => parseInt(s) || 0);
|
|
162
|
+
const pb = b.split(".").map((s) => parseInt(s) || 0);
|
|
163
|
+
const len = Math.max(pa.length, pb.length);
|
|
164
|
+
for (let i = 0; i < len; i++) {
|
|
165
|
+
const va = pa[i] ?? 0;
|
|
166
|
+
const vb = pb[i] ?? 0;
|
|
167
|
+
if (va > vb) return 1;
|
|
168
|
+
if (va < vb) return -1;
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Cached binary path — only discovered once per process */
|
|
174
|
+
let cachedBinaryPath: string | null = null;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Find the best Claude CLI binary on the system.
|
|
178
|
+
* Discovery chain (mirrors opcode):
|
|
179
|
+
* 1. which claude
|
|
180
|
+
* 2. NVM paths (active + all versions)
|
|
181
|
+
* 3. Standard paths (homebrew, system, npm, yarn, bun, etc.)
|
|
182
|
+
* Selects the installation with the highest version.
|
|
183
|
+
*/
|
|
184
|
+
export function findClaudeBinary(): string {
|
|
185
|
+
if (cachedBinaryPath) return cachedBinaryPath;
|
|
186
|
+
|
|
187
|
+
const installations: ClaudeInstallation[] = [];
|
|
188
|
+
|
|
189
|
+
// 1. Try `which` command
|
|
190
|
+
const whichResult = tryWhichCommand();
|
|
191
|
+
if (whichResult) installations.push(whichResult);
|
|
192
|
+
|
|
193
|
+
// 2. Check NVM paths
|
|
194
|
+
installations.push(...findNvmInstallations());
|
|
195
|
+
|
|
196
|
+
// 3. Check standard paths
|
|
197
|
+
installations.push(...findStandardInstallations());
|
|
198
|
+
|
|
199
|
+
// Deduplicate by path
|
|
200
|
+
const seen = new Set<string>();
|
|
201
|
+
const unique = installations.filter((i) => {
|
|
202
|
+
if (seen.has(i.path)) return false;
|
|
203
|
+
seen.add(i.path);
|
|
204
|
+
return true;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (unique.length === 0) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
"Claude Code CLI not found. Install via: npm install -g @anthropic-ai/claude-code",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Select best: highest version, then source preference
|
|
214
|
+
const best = unique.reduce((a, b) => {
|
|
215
|
+
if (a.version && b.version) {
|
|
216
|
+
const cmp = compareVersions(b.version, a.version);
|
|
217
|
+
if (cmp !== 0) return cmp > 0 ? b : a;
|
|
218
|
+
} else if (a.version && !b.version) return a;
|
|
219
|
+
else if (!a.version && b.version) return b;
|
|
220
|
+
return sourcePreference(a.source) <= sourcePreference(b.source) ? a : b;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
cachedBinaryPath = best.path;
|
|
224
|
+
return best.path;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Reset cached binary path (for testing) */
|
|
228
|
+
export function resetBinaryCache(): void {
|
|
229
|
+
cachedBinaryPath = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Discover all installations (for UI display) */
|
|
233
|
+
export function discoverAllInstallations(): ClaudeInstallation[] {
|
|
234
|
+
const installations: ClaudeInstallation[] = [];
|
|
235
|
+
const whichResult = tryWhichCommand();
|
|
236
|
+
if (whichResult) installations.push(whichResult);
|
|
237
|
+
installations.push(...findNvmInstallations());
|
|
238
|
+
installations.push(...findStandardInstallations());
|
|
239
|
+
|
|
240
|
+
// Deduplicate and sort by version desc, then source preference
|
|
241
|
+
const seen = new Set<string>();
|
|
242
|
+
return installations
|
|
243
|
+
.filter((i) => {
|
|
244
|
+
if (seen.has(i.path)) return false;
|
|
245
|
+
seen.add(i.path);
|
|
246
|
+
return true;
|
|
247
|
+
})
|
|
248
|
+
.sort((a, b) => {
|
|
249
|
+
if (a.version && b.version) {
|
|
250
|
+
const cmp = compareVersions(b.version, a.version);
|
|
251
|
+
if (cmp !== 0) return cmp;
|
|
252
|
+
} else if (a.version && !b.version) return -1;
|
|
253
|
+
else if (!a.version && b.version) return 1;
|
|
254
|
+
return sourcePreference(a.source) - sourcePreference(b.source);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AIProvider,
|
|
3
|
+
Session,
|
|
4
|
+
SessionConfig,
|
|
5
|
+
SessionInfo,
|
|
6
|
+
ChatEvent,
|
|
7
|
+
ChatMessage,
|
|
8
|
+
} from "./provider.interface.ts";
|
|
9
|
+
import { findClaudeBinary } from "./claude-binary-finder.ts";
|
|
10
|
+
import { processRegistry } from "./claude-process-registry.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Stream-JSON event types from Claude CLI.
|
|
14
|
+
* Each line of stdout is one complete JSON object.
|
|
15
|
+
* Mirrors opcode's line-by-line parsing approach.
|
|
16
|
+
*/
|
|
17
|
+
interface CliSystemEvent {
|
|
18
|
+
type: "system";
|
|
19
|
+
subtype: "init" | string;
|
|
20
|
+
session_id?: string;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CliAssistantEvent {
|
|
25
|
+
type: "assistant";
|
|
26
|
+
message: {
|
|
27
|
+
content: Array<
|
|
28
|
+
| { type: "text"; text: string }
|
|
29
|
+
| { type: "tool_use"; name: string; input: unknown; id?: string }
|
|
30
|
+
>;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CliResultEvent {
|
|
35
|
+
type: "result";
|
|
36
|
+
result?: string;
|
|
37
|
+
session_id?: string;
|
|
38
|
+
is_error?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CliToolResultEvent {
|
|
42
|
+
type: "tool_result";
|
|
43
|
+
output?: unknown;
|
|
44
|
+
content?: unknown;
|
|
45
|
+
is_error?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CliErrorEvent {
|
|
49
|
+
type: "error";
|
|
50
|
+
error?: string;
|
|
51
|
+
message?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type CliEvent =
|
|
55
|
+
| CliSystemEvent
|
|
56
|
+
| CliAssistantEvent
|
|
57
|
+
| CliResultEvent
|
|
58
|
+
| CliToolResultEvent
|
|
59
|
+
| CliErrorEvent
|
|
60
|
+
| { type: string; [key: string]: unknown };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* AI provider that spawns the `claude` CLI as a subprocess.
|
|
64
|
+
* Architecture mirrors opcode's Rust implementation:
|
|
65
|
+
* - Binary discovery chain (claude-binary-finder.ts)
|
|
66
|
+
* - Process registry for cancel/kill (claude-process-registry.ts)
|
|
67
|
+
* - Line-by-line stream-json parsing (no chunk accumulation)
|
|
68
|
+
* - Session ID extraction from init message
|
|
69
|
+
* - Continue/resume support via -c and --resume flags
|
|
70
|
+
*/
|
|
71
|
+
export class ClaudeCodeCliProvider implements AIProvider {
|
|
72
|
+
id = "claude";
|
|
73
|
+
name = "Claude Code";
|
|
74
|
+
|
|
75
|
+
private sessions = new Map<string, Session>();
|
|
76
|
+
private messageHistory = new Map<string, ChatMessage[]>();
|
|
77
|
+
/** Maps our session ID → Claude CLI's real session ID (from init message) */
|
|
78
|
+
private cliSessionIds = new Map<string, string>();
|
|
79
|
+
|
|
80
|
+
async createSession(config: SessionConfig): Promise<Session> {
|
|
81
|
+
const id = crypto.randomUUID();
|
|
82
|
+
const session: Session = {
|
|
83
|
+
id,
|
|
84
|
+
providerId: this.id,
|
|
85
|
+
title: config.title ?? "New Chat",
|
|
86
|
+
projectName: config.projectName,
|
|
87
|
+
createdAt: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
this.sessions.set(id, session);
|
|
90
|
+
this.messageHistory.set(id, []);
|
|
91
|
+
return session;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async resumeSession(sessionId: string): Promise<Session> {
|
|
95
|
+
const session = this.sessions.get(sessionId);
|
|
96
|
+
if (!session) throw new Error(`Session ${sessionId} not found`);
|
|
97
|
+
return session;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async listSessions(): Promise<SessionInfo[]> {
|
|
101
|
+
return Array.from(this.sessions.values()).map((s) => ({
|
|
102
|
+
id: s.id,
|
|
103
|
+
providerId: s.providerId,
|
|
104
|
+
title: s.title,
|
|
105
|
+
projectName: s.projectName,
|
|
106
|
+
createdAt: s.createdAt,
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
111
|
+
// Kill running process if any
|
|
112
|
+
if (processRegistry.isRunning(sessionId)) {
|
|
113
|
+
await processRegistry.kill(sessionId);
|
|
114
|
+
}
|
|
115
|
+
this.sessions.delete(sessionId);
|
|
116
|
+
this.messageHistory.delete(sessionId);
|
|
117
|
+
this.cliSessionIds.delete(sessionId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async *sendMessage(
|
|
121
|
+
sessionId: string,
|
|
122
|
+
message: string,
|
|
123
|
+
): AsyncIterable<ChatEvent> {
|
|
124
|
+
const session = this.sessions.get(sessionId);
|
|
125
|
+
if (!session) {
|
|
126
|
+
yield { type: "error", message: "Session not found" };
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Update title from first message
|
|
131
|
+
if (session.title === "New Chat") {
|
|
132
|
+
session.title = message.slice(0, 50) + (message.length > 50 ? "..." : "");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Store user message
|
|
136
|
+
const history = this.messageHistory.get(sessionId) ?? [];
|
|
137
|
+
history.push({
|
|
138
|
+
id: crypto.randomUUID(),
|
|
139
|
+
role: "user",
|
|
140
|
+
content: message,
|
|
141
|
+
timestamp: new Date().toISOString(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Build CLI arguments (mirrors opcode's execute/continue/resume pattern)
|
|
145
|
+
const args = this.buildCliArgs(sessionId, message);
|
|
146
|
+
|
|
147
|
+
// Clean env — remove CLAUDECODE to avoid "nested session" error
|
|
148
|
+
const env = { ...process.env };
|
|
149
|
+
delete env.CLAUDECODE;
|
|
150
|
+
|
|
151
|
+
// Find binary via discovery chain
|
|
152
|
+
let binaryPath: string;
|
|
153
|
+
try {
|
|
154
|
+
binaryPath = findClaudeBinary();
|
|
155
|
+
} catch (e) {
|
|
156
|
+
yield { type: "error", message: (e as Error).message };
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Spawn process
|
|
161
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
162
|
+
try {
|
|
163
|
+
proc = Bun.spawn([binaryPath, ...args], {
|
|
164
|
+
stdout: "pipe",
|
|
165
|
+
stderr: "pipe",
|
|
166
|
+
env,
|
|
167
|
+
});
|
|
168
|
+
} catch (e) {
|
|
169
|
+
yield { type: "error", message: `Failed to spawn claude CLI: ${(e as Error).message}` };
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Register in process registry (for cancel support)
|
|
174
|
+
processRegistry.register(sessionId, proc, { prompt: message });
|
|
175
|
+
|
|
176
|
+
let assistantContent = "";
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
// Read stdout line-by-line (mirrors opcode's BufReader::lines())
|
|
180
|
+
yield* this.readStreamJsonLines(proc, sessionId, (text) => {
|
|
181
|
+
assistantContent += text;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Check stderr for errors after stdout is done
|
|
185
|
+
yield* this.readStderrErrors(proc);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
yield { type: "error", message: `Stream error: ${(e as Error).message}` };
|
|
188
|
+
} finally {
|
|
189
|
+
// Unregister from process registry
|
|
190
|
+
processRegistry.unregister(sessionId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Store assistant message
|
|
194
|
+
history.push({
|
|
195
|
+
id: crypto.randomUUID(),
|
|
196
|
+
role: "assistant",
|
|
197
|
+
content: assistantContent,
|
|
198
|
+
timestamp: new Date().toISOString(),
|
|
199
|
+
});
|
|
200
|
+
this.messageHistory.set(sessionId, history);
|
|
201
|
+
|
|
202
|
+
yield { type: "done", sessionId };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Cancel a running session */
|
|
206
|
+
async cancelSession(sessionId: string): Promise<boolean> {
|
|
207
|
+
return processRegistry.kill(sessionId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getMessages(sessionId: string): ChatMessage[] {
|
|
211
|
+
return this.messageHistory.get(sessionId) ?? [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Build CLI arguments based on session state.
|
|
216
|
+
* Mirrors opcode's execute_claude_code / continue_claude_code / resume_claude_code.
|
|
217
|
+
*/
|
|
218
|
+
private buildCliArgs(sessionId: string, message: string): string[] {
|
|
219
|
+
const cliSessionId = this.cliSessionIds.get(sessionId);
|
|
220
|
+
const history = this.messageHistory.get(sessionId) ?? [];
|
|
221
|
+
const userMessageCount = history.filter((m) => m.role === "user").length;
|
|
222
|
+
const isFirstMessage = userMessageCount <= 1; // Current message already pushed
|
|
223
|
+
|
|
224
|
+
const args: string[] = [];
|
|
225
|
+
|
|
226
|
+
if (!isFirstMessage && cliSessionId) {
|
|
227
|
+
// Resume existing CLI session (like opcode's resume_claude_code)
|
|
228
|
+
args.push("--resume", cliSessionId);
|
|
229
|
+
} else if (!isFirstMessage) {
|
|
230
|
+
// Continue most recent session (like opcode's continue_claude_code)
|
|
231
|
+
args.push("-c");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
args.push(
|
|
235
|
+
"-p", message,
|
|
236
|
+
"--output-format", "stream-json",
|
|
237
|
+
"--verbose",
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
return args;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Read stdout line-by-line and yield ChatEvents.
|
|
245
|
+
* Each line is a complete JSON object — no chunk accumulation needed.
|
|
246
|
+
* Mirrors opcode's spawn_claude_process stdout_task.
|
|
247
|
+
*/
|
|
248
|
+
private async *readStreamJsonLines(
|
|
249
|
+
proc: ReturnType<typeof Bun.spawn>,
|
|
250
|
+
sessionId: string,
|
|
251
|
+
onText: (text: string) => void,
|
|
252
|
+
): AsyncGenerator<ChatEvent> {
|
|
253
|
+
const stdout = proc.stdout as ReadableStream<Uint8Array> | undefined;
|
|
254
|
+
if (!stdout) {
|
|
255
|
+
yield { type: "error", message: "Failed to get stdout from claude CLI" };
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const reader = stdout.getReader();
|
|
260
|
+
const decoder = new TextDecoder();
|
|
261
|
+
let buffer = "";
|
|
262
|
+
|
|
263
|
+
while (true) {
|
|
264
|
+
const { done, value } = await reader.read();
|
|
265
|
+
if (done) break;
|
|
266
|
+
|
|
267
|
+
buffer += decoder.decode(value, { stream: true });
|
|
268
|
+
|
|
269
|
+
// Split into complete lines
|
|
270
|
+
const lines = buffer.split("\n");
|
|
271
|
+
// Keep last partial line in buffer
|
|
272
|
+
buffer = lines.pop() ?? "";
|
|
273
|
+
|
|
274
|
+
for (const line of lines) {
|
|
275
|
+
const trimmed = line.trim();
|
|
276
|
+
if (!trimmed) continue;
|
|
277
|
+
|
|
278
|
+
// Parse JSON — each line is one complete event
|
|
279
|
+
let event: CliEvent;
|
|
280
|
+
try {
|
|
281
|
+
event = JSON.parse(trimmed) as CliEvent;
|
|
282
|
+
} catch {
|
|
283
|
+
continue; // Skip non-JSON lines (e.g. progress indicators)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Process the event — may yield multiple ChatEvents per CLI event
|
|
287
|
+
for (const chatEvent of this.mapCliEvent(event, sessionId, onText)) {
|
|
288
|
+
yield chatEvent;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Process remaining buffer (last line without trailing newline)
|
|
294
|
+
if (buffer.trim()) {
|
|
295
|
+
try {
|
|
296
|
+
const event = JSON.parse(buffer.trim()) as CliEvent;
|
|
297
|
+
for (const chatEvent of this.mapCliEvent(event, sessionId, onText)) {
|
|
298
|
+
yield chatEvent;
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
// Ignore incomplete trailing data
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Read stderr and yield error events */
|
|
307
|
+
private async *readStderrErrors(
|
|
308
|
+
proc: ReturnType<typeof Bun.spawn>,
|
|
309
|
+
): AsyncGenerator<ChatEvent> {
|
|
310
|
+
const stderr = proc.stderr as ReadableStream<Uint8Array> | undefined;
|
|
311
|
+
if (!stderr) return;
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const reader = stderr.getReader();
|
|
315
|
+
const decoder = new TextDecoder();
|
|
316
|
+
let stderrContent = "";
|
|
317
|
+
|
|
318
|
+
while (true) {
|
|
319
|
+
const { done, value } = await reader.read();
|
|
320
|
+
if (done) break;
|
|
321
|
+
stderrContent += decoder.decode(value, { stream: true });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Only yield if there's meaningful stderr content
|
|
325
|
+
const trimmed = stderrContent.trim();
|
|
326
|
+
if (trimmed && !trimmed.includes("ExperimentalWarning")) {
|
|
327
|
+
yield { type: "error", message: trimmed };
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// Stderr read failure is non-fatal
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Map a single CLI stream-json event to a ChatEvent.
|
|
336
|
+
* Mirrors opcode's event handling in stdout_task.
|
|
337
|
+
*
|
|
338
|
+
* CLI output format (one JSON per line):
|
|
339
|
+
* {type:"system", subtype:"init", session_id:"..."} — extract session ID
|
|
340
|
+
* {type:"assistant", message:{content:[...]}} — text and tool_use blocks
|
|
341
|
+
* {type:"result", result:"...", session_id:"..."} — final result (ignored to avoid duplication)
|
|
342
|
+
* {type:"tool_result", output:"..."} — tool execution result
|
|
343
|
+
* {type:"error", error:"..."} — error event
|
|
344
|
+
* {type:"rate_limit_event"} — ignored
|
|
345
|
+
*/
|
|
346
|
+
/**
|
|
347
|
+
* Map a single CLI stream-json event to ChatEvent(s).
|
|
348
|
+
* Returns an array because one `assistant` event can contain
|
|
349
|
+
* multiple content blocks (text + tool_use interleaved).
|
|
350
|
+
*/
|
|
351
|
+
private mapCliEvent(
|
|
352
|
+
event: CliEvent,
|
|
353
|
+
sessionId: string,
|
|
354
|
+
onText: (text: string) => void,
|
|
355
|
+
): ChatEvent[] {
|
|
356
|
+
switch (event.type) {
|
|
357
|
+
case "system": {
|
|
358
|
+
const sysEvent = event as CliSystemEvent;
|
|
359
|
+
if (sysEvent.subtype === "init" && sysEvent.session_id) {
|
|
360
|
+
this.cliSessionIds.set(sessionId, sysEvent.session_id);
|
|
361
|
+
}
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
case "assistant": {
|
|
366
|
+
const assistantEvent = event as CliAssistantEvent;
|
|
367
|
+
const content = assistantEvent.message?.content;
|
|
368
|
+
if (!Array.isArray(content)) return [];
|
|
369
|
+
|
|
370
|
+
// Yield ALL blocks in order — text and tool_use interleaved
|
|
371
|
+
const events: ChatEvent[] = [];
|
|
372
|
+
for (const block of content) {
|
|
373
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
374
|
+
onText(block.text);
|
|
375
|
+
events.push({ type: "text", content: block.text });
|
|
376
|
+
} else if (block.type === "tool_use") {
|
|
377
|
+
events.push({
|
|
378
|
+
type: "tool_use",
|
|
379
|
+
tool: block.name ?? "unknown",
|
|
380
|
+
input: block.input ?? {},
|
|
381
|
+
toolUseId: block.id as string | undefined,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return events;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
case "result":
|
|
389
|
+
return [];
|
|
390
|
+
|
|
391
|
+
case "tool_result": {
|
|
392
|
+
const trEvent = event as CliToolResultEvent;
|
|
393
|
+
const output = trEvent.output ?? trEvent.content ?? "";
|
|
394
|
+
return [{
|
|
395
|
+
type: "tool_result",
|
|
396
|
+
output: typeof output === "string" ? output : JSON.stringify(output),
|
|
397
|
+
isError: !!trEvent.is_error,
|
|
398
|
+
}];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
case "error": {
|
|
402
|
+
const errEvent = event as CliErrorEvent;
|
|
403
|
+
return [{
|
|
404
|
+
type: "error",
|
|
405
|
+
message: errEvent.error ?? errEvent.message ?? "Unknown CLI error",
|
|
406
|
+
}];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
default:
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|