kanna-code 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/README.md +137 -0
- package/bin/kanna +2 -0
- package/dist/client/assets/index-ClV0uXCn.css +1 -0
- package/dist/client/assets/index-DSpGrX6x.js +408 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +14 -0
- package/package.json +56 -0
- package/src/server/agent.ts +379 -0
- package/src/server/cli.ts +145 -0
- package/src/server/discovery.ts +65 -0
- package/src/server/event-store.ts +478 -0
- package/src/server/events.ts +134 -0
- package/src/server/external-open.ts +105 -0
- package/src/server/generate-title.ts +42 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths.ts +27 -0
- package/src/server/read-models.ts +120 -0
- package/src/server/server.ts +132 -0
- package/src/server/ws-router.ts +208 -0
- package/src/shared/branding.ts +23 -0
- package/src/shared/ports.ts +3 -0
- package/src/shared/protocol.ts +40 -0
- package/src/shared/types.ts +87 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
7
|
+
<title>Kanna</title>
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-DSpGrX6x.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-ClV0uXCn.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kanna-code",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Local-only project chat UI",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kanna": "./bin/kanna"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/server/",
|
|
12
|
+
"src/shared/",
|
|
13
|
+
"dist/client/"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.0.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "vite build",
|
|
20
|
+
"check": "tsc --noEmit && vite build",
|
|
21
|
+
"dev": "bun run ./scripts/dev.ts",
|
|
22
|
+
"dev:client": "vite --host 0.0.0.0 --port 5174",
|
|
23
|
+
"dev:server": "bun run ./src/server/cli.ts --no-open --port 3211",
|
|
24
|
+
"start": "bun run ./src/server/cli.ts",
|
|
25
|
+
"prepublishOnly": "vite build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.39"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
32
|
+
"@radix-ui/react-popover": "^1.1.15",
|
|
33
|
+
"@radix-ui/react-tooltip": "^1.2.8",
|
|
34
|
+
"@tailwindcss/postcss": "^4.1.18",
|
|
35
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
36
|
+
"@types/bun": "latest",
|
|
37
|
+
"@types/node": "^24.10.1",
|
|
38
|
+
"@types/react": "19.2.7",
|
|
39
|
+
"@types/react-dom": "19.2.3",
|
|
40
|
+
"@vitejs/plugin-react": "5.1.1",
|
|
41
|
+
"autoprefixer": "^10.4.23",
|
|
42
|
+
"class-variance-authority": "^0.7.1",
|
|
43
|
+
"clsx": "^2.1.1",
|
|
44
|
+
"lucide-react": "^0.562.0",
|
|
45
|
+
"react": "19.2.1",
|
|
46
|
+
"react-dom": "19.2.1",
|
|
47
|
+
"react-markdown": "^10.1.0",
|
|
48
|
+
"react-router-dom": "^7.12.0",
|
|
49
|
+
"remark-gfm": "^4.0.1",
|
|
50
|
+
"tailwind-merge": "^3.4.0",
|
|
51
|
+
"tailwindcss": "^4.1.18",
|
|
52
|
+
"typescript": "5.8.3",
|
|
53
|
+
"vite": "^6.0.0",
|
|
54
|
+
"zustand": "^5.0.10"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
|
|
2
|
+
import type { ClientCommand } from "../shared/protocol"
|
|
3
|
+
import { SDK_CLIENT_APP } from "../shared/branding"
|
|
4
|
+
import type { KannaStatus, PendingToolSnapshot } from "../shared/types"
|
|
5
|
+
import { EventStore } from "./event-store"
|
|
6
|
+
import { generateTitleForChat } from "./generate-title"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MODEL = "opus"
|
|
9
|
+
|
|
10
|
+
const TOOLSET = [
|
|
11
|
+
"Skill",
|
|
12
|
+
"WebFetch",
|
|
13
|
+
"WebSearch",
|
|
14
|
+
"Task",
|
|
15
|
+
"TaskOutput",
|
|
16
|
+
"Bash",
|
|
17
|
+
"Glob",
|
|
18
|
+
"Grep",
|
|
19
|
+
"Read",
|
|
20
|
+
"Edit",
|
|
21
|
+
"Write",
|
|
22
|
+
"TodoWrite",
|
|
23
|
+
"KillShell",
|
|
24
|
+
"AskUserQuestion",
|
|
25
|
+
"EnterPlanMode",
|
|
26
|
+
"ExitPlanMode",
|
|
27
|
+
] as const
|
|
28
|
+
|
|
29
|
+
interface PendingToolRequest {
|
|
30
|
+
toolUseId: string
|
|
31
|
+
toolName: "AskUserQuestion" | "ExitPlanMode"
|
|
32
|
+
input: Record<string, unknown>
|
|
33
|
+
resolve: (result: PermissionResult) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ActiveTurn {
|
|
37
|
+
chatId: string
|
|
38
|
+
query: Query
|
|
39
|
+
status: KannaStatus
|
|
40
|
+
pendingTool: PendingToolRequest | null
|
|
41
|
+
hasFinalResult: boolean
|
|
42
|
+
cancelRequested: boolean
|
|
43
|
+
cancelRecorded: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface AgentCoordinatorArgs {
|
|
47
|
+
store: EventStore
|
|
48
|
+
onStateChange: () => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildUserPromptPayload(content: string) {
|
|
52
|
+
return JSON.stringify({ type: "user_prompt", content })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildToolResultPayload(toolUseId: string, body: unknown) {
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
type: "user",
|
|
58
|
+
message: {
|
|
59
|
+
role: "user",
|
|
60
|
+
content: [
|
|
61
|
+
{
|
|
62
|
+
type: "tool_result",
|
|
63
|
+
tool_use_id: toolUseId,
|
|
64
|
+
content: JSON.stringify(body),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildCancelledPayload() {
|
|
72
|
+
return JSON.stringify({
|
|
73
|
+
type: "result",
|
|
74
|
+
subtype: "cancelled",
|
|
75
|
+
is_error: false,
|
|
76
|
+
duration_ms: 0,
|
|
77
|
+
result: "Interrupted by user",
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildErrorPayload(message: string) {
|
|
82
|
+
return JSON.stringify({
|
|
83
|
+
type: "result",
|
|
84
|
+
subtype: "error",
|
|
85
|
+
is_error: true,
|
|
86
|
+
duration_ms: 0,
|
|
87
|
+
result: message,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function deriveChatTitle(content: string) {
|
|
92
|
+
const singleLine = content.replace(/\s+/g, " ").trim()
|
|
93
|
+
return singleLine.slice(0, 60) || "New Chat"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toTranscriptEntry(message: string, messageId?: string) {
|
|
97
|
+
return {
|
|
98
|
+
_id: crypto.randomUUID(),
|
|
99
|
+
messageId,
|
|
100
|
+
message,
|
|
101
|
+
createdAt: Date.now(),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class AgentCoordinator {
|
|
106
|
+
private readonly store: EventStore
|
|
107
|
+
private readonly onStateChange: () => void
|
|
108
|
+
readonly activeTurns = new Map<string, ActiveTurn>()
|
|
109
|
+
|
|
110
|
+
constructor(args: AgentCoordinatorArgs) {
|
|
111
|
+
this.store = args.store
|
|
112
|
+
this.onStateChange = args.onStateChange
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getActiveStatuses() {
|
|
116
|
+
const statuses = new Map<string, KannaStatus>()
|
|
117
|
+
for (const [chatId, turn] of this.activeTurns.entries()) {
|
|
118
|
+
statuses.set(chatId, turn.status)
|
|
119
|
+
}
|
|
120
|
+
return statuses
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getPendingTool(chatId: string): PendingToolSnapshot | null {
|
|
124
|
+
const pending = this.activeTurns.get(chatId)?.pendingTool
|
|
125
|
+
if (!pending) return null
|
|
126
|
+
return { toolUseId: pending.toolUseId, toolName: pending.toolName }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
|
|
130
|
+
let chatId = command.chatId
|
|
131
|
+
|
|
132
|
+
if (!chatId) {
|
|
133
|
+
if (!command.projectId) {
|
|
134
|
+
throw new Error("Missing projectId for new chat")
|
|
135
|
+
}
|
|
136
|
+
const created = await this.store.createChat(command.projectId)
|
|
137
|
+
chatId = created.id
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const chat = this.store.requireChat(chatId)
|
|
141
|
+
if (this.activeTurns.has(chatId)) {
|
|
142
|
+
throw new Error("Chat is already running")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const existingMessages = this.store.getMessages(chatId)
|
|
146
|
+
if (chat.title === "New Chat" && existingMessages.length === 0) {
|
|
147
|
+
// Immediate placeholder: truncated first message
|
|
148
|
+
await this.store.renameChat(chatId, deriveChatTitle(command.content))
|
|
149
|
+
|
|
150
|
+
// Fire-and-forget: generate a better title with Haiku in parallel
|
|
151
|
+
void generateTitleForChat(command.content)
|
|
152
|
+
.then(async (title) => {
|
|
153
|
+
if (title) {
|
|
154
|
+
await this.store.renameChat(chatId!, title)
|
|
155
|
+
this.onStateChange()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
.catch(() => undefined)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await this.store.appendMessage(chatId, toTranscriptEntry(buildUserPromptPayload(command.content), crypto.randomUUID()))
|
|
162
|
+
await this.store.recordTurnStarted(chatId)
|
|
163
|
+
|
|
164
|
+
const canUseTool: CanUseTool = async (toolName, input, options) => {
|
|
165
|
+
if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
|
|
166
|
+
return {
|
|
167
|
+
behavior: "allow",
|
|
168
|
+
updatedInput: input,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const active = this.activeTurns.get(chatId!)
|
|
173
|
+
if (!active) {
|
|
174
|
+
return {
|
|
175
|
+
behavior: "deny",
|
|
176
|
+
message: "Chat turn ended unexpectedly",
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
active.status = "waiting_for_user"
|
|
181
|
+
this.onStateChange()
|
|
182
|
+
|
|
183
|
+
return await new Promise<PermissionResult>((resolve) => {
|
|
184
|
+
active.pendingTool = {
|
|
185
|
+
toolUseId: options.toolUseID,
|
|
186
|
+
toolName,
|
|
187
|
+
input,
|
|
188
|
+
resolve,
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const project = this.store.getProject(chat.projectId)
|
|
194
|
+
if (!project) {
|
|
195
|
+
throw new Error("Project not found")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const q = query({
|
|
199
|
+
prompt: command.content,
|
|
200
|
+
options: {
|
|
201
|
+
cwd: project.localPath,
|
|
202
|
+
model: DEFAULT_MODEL,
|
|
203
|
+
resume: chat.resumeSessionId ?? undefined,
|
|
204
|
+
permissionMode: chat.planMode ? "plan" : "acceptEdits",
|
|
205
|
+
canUseTool,
|
|
206
|
+
tools: [...TOOLSET],
|
|
207
|
+
settingSources: ["user", "project", "local"],
|
|
208
|
+
env: {
|
|
209
|
+
...process.env,
|
|
210
|
+
// CLAUDE_AGENT_SDK_CLIENT_APP: SDK_CLIENT_APP,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const active: ActiveTurn = {
|
|
216
|
+
chatId,
|
|
217
|
+
query: q,
|
|
218
|
+
status: "starting",
|
|
219
|
+
pendingTool: null,
|
|
220
|
+
hasFinalResult: false,
|
|
221
|
+
cancelRequested: false,
|
|
222
|
+
cancelRecorded: false,
|
|
223
|
+
}
|
|
224
|
+
this.activeTurns.set(chatId, active)
|
|
225
|
+
this.onStateChange()
|
|
226
|
+
|
|
227
|
+
void q.accountInfo()
|
|
228
|
+
.then(async (accountInfo) => {
|
|
229
|
+
await this.store.appendMessage(
|
|
230
|
+
chatId!,
|
|
231
|
+
toTranscriptEntry(JSON.stringify({ type: "system", subtype: "account_info", accountInfo }))
|
|
232
|
+
)
|
|
233
|
+
this.onStateChange()
|
|
234
|
+
})
|
|
235
|
+
.catch(() => undefined)
|
|
236
|
+
|
|
237
|
+
void this.runTurn(active)
|
|
238
|
+
|
|
239
|
+
return { chatId }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async runTurn(active: ActiveTurn) {
|
|
243
|
+
try {
|
|
244
|
+
for await (const sdkMessage of active.query) {
|
|
245
|
+
const raw = JSON.stringify(sdkMessage)
|
|
246
|
+
const maybeMessageId = "uuid" in sdkMessage && sdkMessage.uuid ? String(sdkMessage.uuid) : crypto.randomUUID()
|
|
247
|
+
|
|
248
|
+
await this.store.appendMessage(active.chatId, toTranscriptEntry(raw, maybeMessageId))
|
|
249
|
+
|
|
250
|
+
const sessionId = "session_id" in sdkMessage && typeof sdkMessage.session_id === "string"
|
|
251
|
+
? sdkMessage.session_id
|
|
252
|
+
: null
|
|
253
|
+
if (sessionId) {
|
|
254
|
+
await this.store.setResumeSession(active.chatId, sessionId)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (sdkMessage.type === "system" && sdkMessage.subtype === "init") {
|
|
258
|
+
active.status = "running"
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (sdkMessage.type === "result") {
|
|
262
|
+
active.hasFinalResult = true
|
|
263
|
+
if (sdkMessage.is_error) {
|
|
264
|
+
const errorText = "errors" in sdkMessage && Array.isArray(sdkMessage.errors)
|
|
265
|
+
? sdkMessage.errors.join("\n")
|
|
266
|
+
: "Turn failed"
|
|
267
|
+
await this.store.recordTurnFailed(active.chatId, errorText)
|
|
268
|
+
} else if (!active.cancelRequested) {
|
|
269
|
+
await this.store.recordTurnFinished(active.chatId)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
this.onStateChange()
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!active.cancelRequested) {
|
|
277
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
278
|
+
await this.store.appendMessage(active.chatId, toTranscriptEntry(buildErrorPayload(message)))
|
|
279
|
+
await this.store.recordTurnFailed(active.chatId, message)
|
|
280
|
+
}
|
|
281
|
+
} finally {
|
|
282
|
+
if (active.cancelRequested && !active.cancelRecorded) {
|
|
283
|
+
await this.store.recordTurnCancelled(active.chatId)
|
|
284
|
+
}
|
|
285
|
+
active.query.close()
|
|
286
|
+
this.activeTurns.delete(active.chatId)
|
|
287
|
+
this.onStateChange()
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async cancel(chatId: string) {
|
|
292
|
+
const active = this.activeTurns.get(chatId)
|
|
293
|
+
if (!active) return
|
|
294
|
+
|
|
295
|
+
active.cancelRequested = true
|
|
296
|
+
active.pendingTool = null
|
|
297
|
+
|
|
298
|
+
await this.store.appendMessage(chatId, toTranscriptEntry(buildCancelledPayload(), crypto.randomUUID()))
|
|
299
|
+
await this.store.recordTurnCancelled(chatId)
|
|
300
|
+
active.cancelRecorded = true
|
|
301
|
+
active.hasFinalResult = true
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await active.query.interrupt()
|
|
305
|
+
} catch {
|
|
306
|
+
active.query.close()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
this.activeTurns.delete(chatId)
|
|
310
|
+
this.onStateChange()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async respondTool(command: Extract<ClientCommand, { type: "chat.respondTool" }>) {
|
|
314
|
+
const active = this.activeTurns.get(command.chatId)
|
|
315
|
+
if (!active || !active.pendingTool) {
|
|
316
|
+
throw new Error("No pending tool request")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const pending = active.pendingTool
|
|
320
|
+
if (pending.toolUseId !== command.toolUseId) {
|
|
321
|
+
throw new Error("Tool response does not match active request")
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await this.store.appendMessage(
|
|
325
|
+
command.chatId,
|
|
326
|
+
toTranscriptEntry(buildToolResultPayload(command.toolUseId, command.result), crypto.randomUUID())
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
active.pendingTool = null
|
|
330
|
+
active.status = "running"
|
|
331
|
+
|
|
332
|
+
if (pending.toolName === "AskUserQuestion") {
|
|
333
|
+
const result = command.result as { questions?: unknown; answers?: unknown }
|
|
334
|
+
pending.resolve({
|
|
335
|
+
behavior: "allow",
|
|
336
|
+
updatedInput: {
|
|
337
|
+
...(pending.input ?? {}),
|
|
338
|
+
questions: result.questions ?? (pending.input.questions as unknown),
|
|
339
|
+
answers: result.answers ?? result,
|
|
340
|
+
},
|
|
341
|
+
})
|
|
342
|
+
this.onStateChange()
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const result = (command.result ?? {}) as {
|
|
347
|
+
confirmed?: boolean
|
|
348
|
+
clearContext?: boolean
|
|
349
|
+
message?: string
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (result.confirmed) {
|
|
353
|
+
await this.store.setPlanMode(command.chatId, false)
|
|
354
|
+
if (result.clearContext) {
|
|
355
|
+
await this.store.setResumeSession(command.chatId, null)
|
|
356
|
+
await this.store.appendMessage(
|
|
357
|
+
command.chatId,
|
|
358
|
+
toTranscriptEntry(JSON.stringify({ type: "system", subtype: "context_cleared" }), crypto.randomUUID())
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
pending.resolve({
|
|
362
|
+
behavior: "allow",
|
|
363
|
+
updatedInput: {
|
|
364
|
+
...(pending.input ?? {}),
|
|
365
|
+
...result,
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
} else {
|
|
369
|
+
pending.resolve({
|
|
370
|
+
behavior: "deny",
|
|
371
|
+
message: result.message
|
|
372
|
+
? `User wants to suggest edits to the plan: ${result.message}`
|
|
373
|
+
: "User wants to suggest edits to the plan before approving.",
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.onStateChange()
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import process from "node:process"
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
3
|
+
import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, SDK_CLIENT_APP } from "../shared/branding"
|
|
4
|
+
import { PROD_SERVER_PORT } from "../shared/ports"
|
|
5
|
+
import { startKannaServer } from "./server"
|
|
6
|
+
|
|
7
|
+
const VERSION = SDK_CLIENT_APP.split("/")[1] ?? "0.0.0"
|
|
8
|
+
|
|
9
|
+
interface CliOptions {
|
|
10
|
+
port: number
|
|
11
|
+
openBrowser: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function printHelp() {
|
|
15
|
+
console.log(`${APP_NAME} — local-only project chat UI
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
${CLI_COMMAND} [options]
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--port <number> Port to listen on (default: ${PROD_SERVER_PORT})
|
|
22
|
+
--no-open Don't open browser automatically
|
|
23
|
+
--version Print version and exit
|
|
24
|
+
--help Show this help message`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv: string[]): CliOptions {
|
|
28
|
+
let port = PROD_SERVER_PORT
|
|
29
|
+
let openBrowser = true
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
32
|
+
const arg = argv[index]
|
|
33
|
+
if (arg === "--version" || arg === "-v") {
|
|
34
|
+
console.log(VERSION)
|
|
35
|
+
process.exit(0)
|
|
36
|
+
}
|
|
37
|
+
if (arg === "--help" || arg === "-h") {
|
|
38
|
+
printHelp()
|
|
39
|
+
process.exit(0)
|
|
40
|
+
}
|
|
41
|
+
if (arg === "--port") {
|
|
42
|
+
const next = argv[index + 1]
|
|
43
|
+
if (!next) throw new Error("Missing value for --port")
|
|
44
|
+
port = Number(next)
|
|
45
|
+
index += 1
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (arg === "--no-open") {
|
|
49
|
+
openBrowser = false
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (!arg.startsWith("-")) throw new Error(`Unexpected positional argument: ${arg}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
port,
|
|
57
|
+
openBrowser,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function spawnDetached(command: string, args: string[]) {
|
|
62
|
+
spawn(command, args, { stdio: "ignore", detached: true }).unref()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasCommand(command: string) {
|
|
66
|
+
const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
|
|
67
|
+
return result.status === 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function canOpenMacApp(appName: string) {
|
|
71
|
+
const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
|
|
72
|
+
return result.status === 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function openUrl(url: string) {
|
|
76
|
+
const platform = process.platform
|
|
77
|
+
if (platform === "darwin") {
|
|
78
|
+
const appCandidates = [
|
|
79
|
+
"Google Chrome",
|
|
80
|
+
"Chromium",
|
|
81
|
+
"Brave Browser",
|
|
82
|
+
"Microsoft Edge",
|
|
83
|
+
"Arc",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
for (const appName of appCandidates) {
|
|
87
|
+
if (!canOpenMacApp(appName)) continue
|
|
88
|
+
spawnDetached("open", ["-a", appName, "--args", `--app=${url}`])
|
|
89
|
+
console.log(`${LOG_PREFIX} opened in app window via ${appName}`)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
spawnDetached("open", [url])
|
|
94
|
+
console.log(`${LOG_PREFIX} opened in default browser`)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (platform === "win32") {
|
|
98
|
+
const browserCommands = ["chrome", "msedge", "brave", "chromium"]
|
|
99
|
+
for (const command of browserCommands) {
|
|
100
|
+
if (!hasCommand(command)) continue
|
|
101
|
+
spawnDetached(command, [`--app=${url}`])
|
|
102
|
+
console.log(`${LOG_PREFIX} opened in app window via ${command}`)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
spawnDetached("cmd", ["/c", "start", "", url])
|
|
107
|
+
console.log(`${LOG_PREFIX} opened in default browser`)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const browserCommands = ["google-chrome", "chromium", "brave-browser", "microsoft-edge"]
|
|
112
|
+
for (const command of browserCommands) {
|
|
113
|
+
if (!hasCommand(command)) continue
|
|
114
|
+
spawnDetached(command, [`--app=${url}`])
|
|
115
|
+
console.log(`${LOG_PREFIX} opened in app window via ${command}`)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
spawnDetached("xdg-open", [url])
|
|
120
|
+
console.log(`${LOG_PREFIX} opened in default browser`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const options = parseArgs(process.argv.slice(2))
|
|
124
|
+
const { port, stop } = await startKannaServer(options)
|
|
125
|
+
const url = `http://localhost:${port}`
|
|
126
|
+
const launchUrl = `${url}/projects`
|
|
127
|
+
|
|
128
|
+
console.log(`${LOG_PREFIX} listening on ${url}`)
|
|
129
|
+
console.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
|
|
130
|
+
|
|
131
|
+
if (options.openBrowser) {
|
|
132
|
+
openUrl(launchUrl)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const shutdown = async () => {
|
|
136
|
+
await stop()
|
|
137
|
+
process.exit(0)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
process.on("SIGINT", () => {
|
|
141
|
+
void shutdown()
|
|
142
|
+
})
|
|
143
|
+
process.on("SIGTERM", () => {
|
|
144
|
+
void shutdown()
|
|
145
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
export interface DiscoveredProject {
|
|
6
|
+
localPath: string
|
|
7
|
+
title: string
|
|
8
|
+
modifiedAt: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resolveEncodedClaudePath(folderName: string) {
|
|
12
|
+
const segments = folderName.replace(/^-/, "").split("-").filter(Boolean)
|
|
13
|
+
let currentPath = ""
|
|
14
|
+
let remainingSegments = [...segments]
|
|
15
|
+
|
|
16
|
+
while (remainingSegments.length > 0) {
|
|
17
|
+
let found = false
|
|
18
|
+
|
|
19
|
+
for (let index = remainingSegments.length; index >= 1; index -= 1) {
|
|
20
|
+
const segment = remainingSegments.slice(0, index).join("-")
|
|
21
|
+
const candidate = `${currentPath}/${segment}`
|
|
22
|
+
|
|
23
|
+
if (existsSync(candidate)) {
|
|
24
|
+
currentPath = candidate
|
|
25
|
+
remainingSegments = remainingSegments.slice(index)
|
|
26
|
+
found = true
|
|
27
|
+
break
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!found) {
|
|
32
|
+
const [head, ...tail] = remainingSegments
|
|
33
|
+
currentPath = `${currentPath}/${head}`
|
|
34
|
+
remainingSegments = tail
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return currentPath || "/"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function discoverClaudeProjects(homeDir: string = homedir()): DiscoveredProject[] {
|
|
42
|
+
const projectsDir = path.join(homeDir, ".claude", "projects")
|
|
43
|
+
if (!existsSync(projectsDir)) {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const entries = readdirSync(projectsDir, { withFileTypes: true })
|
|
48
|
+
const projects: DiscoveredProject[] = []
|
|
49
|
+
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!entry.isDirectory()) continue
|
|
52
|
+
|
|
53
|
+
const resolvedPath = resolveEncodedClaudePath(entry.name)
|
|
54
|
+
if (!existsSync(resolvedPath)) continue
|
|
55
|
+
|
|
56
|
+
const stat = statSync(path.join(projectsDir, entry.name))
|
|
57
|
+
projects.push({
|
|
58
|
+
localPath: resolvedPath,
|
|
59
|
+
title: path.basename(resolvedPath) || resolvedPath,
|
|
60
|
+
modifiedAt: stat.mtimeMs,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return projects.sort((a, b) => b.modifiedAt - a.modifiedAt)
|
|
65
|
+
}
|