@exreve/exk 1.0.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.
@@ -0,0 +1,122 @@
1
+ #!/bin/bash
2
+
3
+ # Install exk CLI as pm2 daemon (runs exk daemon)
4
+ # Works for npm global install (`npm i -g exk`) and standalone
5
+
6
+ set -e
7
+
8
+ PM2_APP_NAME="exk_cli"
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+
11
+ # EXK_PKG_DIR: set by `exk register` - points to npm package root (node_modules/exk)
12
+ EXK_PKG_DIR="${EXK_PKG_DIR:-$SCRIPT_DIR}"
13
+ # EXK_BIN: path to the exk binary (global npm bin)
14
+ EXK_BIN="${EXK_BIN:-$(command -v exk 2>/dev/null || echo "")}"
15
+
16
+ INSTALL_DIR="$HOME/.talk-to-code"
17
+
18
+ # Ensure PATH includes nvm/node for pm2
19
+ if [ -f "$HOME/.nvm/nvm.sh" ]; then
20
+ . "$HOME/.nvm/nvm.sh"
21
+ fi
22
+
23
+ # Check for uninstall flag
24
+ if [ "$1" == "--uninstall" ] || [ "$1" == "--remove" ] || [ "$1" == "-u" ] || [ "$1" == "-r" ]; then
25
+ echo "Uninstalling exk daemon from pm2..."
26
+
27
+ if command -v pm2 >/dev/null 2>&1; then
28
+ pm2 delete "$PM2_APP_NAME" 2>/dev/null || true
29
+ pm2 save --force 2>/dev/null || true
30
+ fi
31
+ rm -f "$INSTALL_DIR/ecosystem.config.cjs" 2>/dev/null || true
32
+
33
+ echo ""
34
+ echo "exk daemon uninstalled from pm2."
35
+ echo "To remove the package: npm uninstall -g exk"
36
+ exit 0
37
+ fi
38
+
39
+ echo "Installing exk daemon as pm2 process (name: $PM2_APP_NAME)..."
40
+
41
+ # Find exk binary
42
+ if [ -z "$EXK_BIN" ] || [ ! -x "$EXK_BIN" ]; then
43
+ EXK_BIN="$(command -v exk 2>/dev/null || true)"
44
+ fi
45
+ if [ -z "$EXK_BIN" ]; then
46
+ echo "Error: exk binary not found in PATH"
47
+ echo "Make sure exk is installed: npm i -g exk"
48
+ exit 1
49
+ fi
50
+
51
+ echo " exk binary: $EXK_BIN"
52
+ echo " package dir: $EXK_PKG_DIR"
53
+
54
+ # Ensure pm2 is available
55
+ if ! command -v pm2 >/dev/null 2>&1; then
56
+ for nvm_dir in "$HOME/.nvm/versions/node"/*/bin; do
57
+ [ -d "$nvm_dir" ] && export PATH="$nvm_dir:$PATH"
58
+ done
59
+ if ! command -v pm2 >/dev/null 2>&1; then
60
+ echo "Installing pm2..."
61
+ (npm install -g pm2 2>/dev/null) || true
62
+ fi
63
+ if ! command -v pm2 >/dev/null 2>&1; then
64
+ echo "Error: pm2 not found. Run: npm install -g pm2"
65
+ exit 1
66
+ fi
67
+ fi
68
+
69
+ # Stop and delete if already running
70
+ pm2 delete "$PM2_APP_NAME" 2>/dev/null || true
71
+
72
+ # Build PATH for daemon
73
+ NVM_BIN=""
74
+ if [ -d "$HOME/.nvm/versions/node" ]; then
75
+ for d in "$HOME/.nvm/versions/node"/*/bin; do
76
+ [ -d "$d" ] && NVM_BIN="${d}:"
77
+ done
78
+ fi
79
+ NODE_BIN_FALLBACK="$(command -v node 2>/dev/null | xargs dirname 2>/dev/null)"
80
+ [ -n "$NODE_BIN_FALLBACK" ] && NVM_BIN="${NODE_BIN_FALLBACK}:${NVM_BIN}"
81
+ DAEMON_PATH="$HOME/.local/bin:${NVM_BIN}/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
82
+ DAEMON_PATH="${DAEMON_PATH//::/:}"
83
+
84
+ # Create config dir
85
+ mkdir -p "$INSTALL_DIR"
86
+
87
+ # Use ecosystem file for reliable pm2 config
88
+ ECOSYSTEM="$INSTALL_DIR/ecosystem.config.cjs"
89
+ cat > "$ECOSYSTEM" << ECOSYSTEM_EOF
90
+ module.exports = {
91
+ apps: [{
92
+ name: "$PM2_APP_NAME",
93
+ script: "$EXK_BIN",
94
+ args: "daemon",
95
+ env: {
96
+ HOME: "$HOME",
97
+ USER: "${USER:-$(whoami)}",
98
+ PATH: "$DAEMON_PATH",
99
+ EXK_PKG_DIR: "$EXK_PKG_DIR"
100
+ }
101
+ }]
102
+ };
103
+ ECOSYSTEM_EOF
104
+
105
+ if ! pm2 start "$ECOSYSTEM"; then
106
+ echo "Error: pm2 start failed. Try running manually: pm2 start $ECOSYSTEM"
107
+ exit 1
108
+ fi
109
+
110
+ # Persist across reboots
111
+ pm2 save
112
+ pm2 startup 2>/dev/null || echo "Note: Run 'pm2 startup' as root to enable auto-start on reboot"
113
+
114
+ echo ""
115
+ echo "exk installed and running as pm2 daemon."
116
+ echo ""
117
+ echo "Commands:"
118
+ echo " pm2 status - list processes"
119
+ echo " pm2 logs $PM2_APP_NAME - view logs"
120
+ echo " pm2 restart $PM2_APP_NAME - restart"
121
+ echo ""
122
+ echo "To uninstall: exk uninstall"
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Module MCP Server
3
+ *
4
+ * Exposes enabled modules as MCP tools to the agent.
5
+ * This allows the agent to interact with modules like user-choice through standard tool calls.
6
+ */
7
+
8
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
9
+ import type { SdkMcpToolDefinition } from '@anthropic-ai/claude-agent-sdk'
10
+ import { z } from 'zod'
11
+
12
+ /** MCP tool result shape (matches CallToolResult) */
13
+ type ToolResult = { content: Array<{ type: 'text'; text: string }>; isError?: boolean }
14
+
15
+ export interface ModuleMcpServerConfig {
16
+ enabledModules: string[]
17
+ moduleSettings: Record<string, any>
18
+ onChoiceRequest?: (request: ChoiceRequest) => Promise<ChoiceResponse>
19
+ }
20
+
21
+ export interface ChoiceRequest {
22
+ choiceId: string
23
+ question: string
24
+ options: Array<{ label: string; value: string }>
25
+ timeout?: number
26
+ }
27
+
28
+ export interface ChoiceResponse {
29
+ choiceId: string
30
+ selectedValue: string | null
31
+ }
32
+
33
+ /**
34
+ * Create a tool for the user-choice module
35
+ */
36
+ function createUserChoiceTool(onChoiceRequest?: (request: ChoiceRequest) => Promise<ChoiceResponse>): SdkMcpToolDefinition<any> {
37
+ const schema = {
38
+ question: z.string(),
39
+ options: z.array(z.object({ label: z.string(), value: z.string() })),
40
+ timeout: z.number().optional()
41
+ }
42
+ return tool(
43
+ 'user_choice_request',
44
+ 'Request user input when making decisions. Use this when you need the user to choose between options or provide input on a decision. This tool will present a modal to the user with your question and wait for their response.',
45
+ schema,
46
+ async (args, _extra): Promise<ToolResult> => {
47
+ if (!onChoiceRequest) {
48
+ return {
49
+ content: [
50
+ {
51
+ type: 'text',
52
+ text: 'Error: User choice is not enabled. Please use an alternative approach or ask the user directly.'
53
+ }
54
+ ],
55
+ isError: true
56
+ }
57
+ }
58
+
59
+ const choiceId = `choice-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
60
+
61
+ try {
62
+ const response = await onChoiceRequest({
63
+ choiceId,
64
+ question: args.question as string,
65
+ options: args.options as Array<{ label: string; value: string }>,
66
+ timeout: args.timeout as number | undefined
67
+ })
68
+
69
+ if (response.selectedValue === null) {
70
+ return {
71
+ content: [
72
+ {
73
+ type: 'text',
74
+ text: 'The user did not respond within the timeout period. Please proceed with a reasonable default or ask again.'
75
+ }
76
+ ]
77
+ }
78
+ }
79
+
80
+ return {
81
+ content: [
82
+ {
83
+ type: 'text',
84
+ text: `The user selected: ${response.selectedValue}`
85
+ }
86
+ ]
87
+ }
88
+ } catch (error) {
89
+ return {
90
+ content: [
91
+ {
92
+ type: 'text',
93
+ text: `Error requesting user choice: ${error instanceof Error ? error.message : String(error)}`
94
+ }
95
+ ],
96
+ isError: true
97
+ }
98
+ }
99
+ }
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Get available tools based on enabled modules
105
+ */
106
+ function getModuleTools(config: ModuleMcpServerConfig): SdkMcpToolDefinition<any>[] {
107
+ const tools: SdkMcpToolDefinition<any>[] = []
108
+
109
+ // User choice module
110
+ if (config.enabledModules.includes('user-choice')) {
111
+ tools.push(createUserChoiceTool(config.onChoiceRequest))
112
+ }
113
+
114
+ // Add more module tools here as they are implemented
115
+ return tools
116
+ }
117
+
118
+ /**
119
+ * Create the module MCP server
120
+ */
121
+ export function createModuleMcpServer(config: ModuleMcpServerConfig) {
122
+ const tools = getModuleTools(config)
123
+
124
+ const server = createSdkMcpServer({
125
+ name: 'claude-voice-modules',
126
+ version: '1.0.0',
127
+ tools
128
+ })
129
+
130
+ return server
131
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@exreve/exk",
3
+ "version": "1.0.0",
4
+ "description": "exk - Control Claude CLI with voice and programmable interfaces",
5
+ "type": "module",
6
+ "bin": {
7
+ "exk": "./bin/exk"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "shared",
12
+ "skills",
13
+ "*.ts",
14
+ "install-service.sh",
15
+ "container-entrypoint.sh",
16
+ "tsconfig.json"
17
+ ],
18
+ "scripts": {
19
+ "build": "node build-cli-tarball.js",
20
+ "build:tsc": "tsc",
21
+ "typecheck": "tsc --noEmit",
22
+ "prepublishOnly": "node -e \"require('fs').chmodSync('bin/exk', 0o755)\""
23
+ },
24
+ "keywords": [
25
+ "claude",
26
+ "cli",
27
+ "voice",
28
+ "agent",
29
+ "talktocode"
30
+ ],
31
+ "author": "exreve",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": ""
36
+ },
37
+ "dependencies": {
38
+ "@anthropic-ai/claude-agent-sdk": "^0.1.23",
39
+ "@anthropic-ai/sdk": "^0.74.0",
40
+ "@fastify/static": "^9.0.0",
41
+ "@types/node": "^22.10.2",
42
+ "@xenova/transformers": "^2.17.2",
43
+ "better-sqlite3": "^9.0.0",
44
+ "chokidar": "^3.6.0",
45
+ "commander": "^13.1.0",
46
+ "express": "^4.18.2",
47
+ "fastify": "^5.7.2",
48
+ "node-fetch": "^3.3.2",
49
+ "pino-pretty": "^11.0.0",
50
+ "socket.io-client": "^4.8.1",
51
+ "tsx": "^4.19.0",
52
+ "uuid": "^11.0.3"
53
+ },
54
+ "devDependencies": {
55
+ "@types/better-sqlite3": "^7.6.12",
56
+ "@types/chokidar": "^2.1.3",
57
+ "@types/uuid": "^10.0.0",
58
+ "@vercel/ncc": "^0.38.1",
59
+ "esbuild": "^0.27.2",
60
+ "js-confuser": "^1.1.0",
61
+ "ts-node": "^10.9.2",
62
+ "typescript": "^5.7.2"
63
+ },
64
+ "engines": {
65
+ "node": ">=20.0.0"
66
+ }
67
+ }
@@ -0,0 +1,341 @@
1
+ import { v4 as uuidv4 } from 'uuid'
2
+ import fs from 'fs/promises'
3
+ import path from 'path'
4
+ import type { ProjectConfig } from './shared/types'
5
+ import { agentSessionManager } from './agentSession'
6
+ import type { SessionOutput } from './shared/types'
7
+
8
+ const CONFIG_FILENAME = '.claude-voice.json'
9
+
10
+ /**
11
+ * Analyze project using Claude AI and generate structured JSON configuration
12
+ */
13
+ export async function analyzeProjectWithClaude(projectPath: string, projectName: string): Promise<ProjectConfig> {
14
+ // Create a temporary session ID for analysis
15
+ const analysisSessionId = `analysis-${uuidv4()}`
16
+
17
+ // Build the analysis prompt with structured output requirements
18
+ const analysisPrompt = `Analyze the project structure at ${projectPath} and generate a comprehensive project configuration.
19
+
20
+ CRITICAL: You must identify ALL applications/services in this project. Each app will get its own TypeScript runner file and entry in the JSON config.
21
+
22
+ Please examine:
23
+ 1. Project files and directory structure (use Read/Glob tools to explore)
24
+ 2. Framework and technology stack (React, Express, FastAPI, Django, etc.)
25
+ 3. Apps (applications/services) - each app can be at root or in subdirectories
26
+ 4. For each app, identify:
27
+ - App NAME: unique identifier for the app
28
+ - App TYPE: "static-frontend" (React/Vue/Angular/Svelte/Next.js static builds) or "backend" (Express/Fastify/FastAPI/Django/etc.)
29
+ - How to BUILD it (buildCommand - REQUIRED for static frontends)
30
+ - How to START it (startCommand - REQUIRED for all apps)
31
+ - How to STOP it (stopCommand - optional, defaults to killing process)
32
+ - How to RESTART it (restartCommand - optional, defaults to stop + start)
33
+ - Port number (port - REQUIRED for HTTP apps)
34
+ - Protocol (http/https/ws/etc.)
35
+ - HTTP endpoints/routes (endpoints array)
36
+ - Framework name (react/express/fastify/etc.)
37
+ - Directory location (relative to project root, empty string "" for root app)
38
+ - Build output directory (buildDir - for static frontends: dist/build/public)
39
+ - Environment variables (env object)
40
+ - Health check URL or command
41
+ 5. Check package.json scripts, Makefile, docker-compose.yml, Procfile, vite.config.js, next.config.js, etc. for commands
42
+ 6. Detect HTTP endpoints by examining route files, controllers, or API definitions
43
+ 7. For static frontends, identify the build output directory (usually dist, build, or public)
44
+ 8. Look for multiple apps in monorepos or multi-app projects
45
+
46
+ IMPORTANT OUTPUT REQUIREMENTS:
47
+ - You must output a JSON file named ".talk-to-code.json" in the project root
48
+ - The JSON must contain ALL apps/services found in the project
49
+ - Each app will be wrapped with a TypeScript runner file (generated automatically)
50
+ - Apps must be configured to work with the runner system
51
+
52
+ Output ONLY valid JSON matching this exact structure (no markdown, no code blocks, no explanations, just pure JSON):
53
+
54
+ {
55
+ "version": "1.0.0",
56
+ "projectName": "${projectName}",
57
+ "description": "Description of the project",
58
+ "apps": [
59
+ {
60
+ "name": "app-name",
61
+ "description": "Optional description",
62
+ "type": "http",
63
+ "port": 3000,
64
+ "protocol": "http",
65
+ "directory": "relative/path/to/app",
66
+ "framework": "react",
67
+ "appType": "static-frontend",
68
+ "buildDir": "dist",
69
+ "endpoints": ["/", "/api", "/api/v1"],
70
+ "env": {},
71
+ "healthCheck": {
72
+ "url": "http://localhost:3000/health"
73
+ },
74
+ "buildCommand": "npm run build",
75
+ "startCommand": "npm start",
76
+ "stopCommand": "pkill -f 'node.*start'",
77
+ "restartCommand": null,
78
+ "dependencies": []
79
+ }
80
+ ],
81
+ "directories": ["subdirectory1", "subdirectory2"],
82
+ "metadata": {
83
+ "analyzedAt": "${new Date().toISOString()}",
84
+ "analyzedBy": "talk-to-code-agent",
85
+ "framework": "detected-framework",
86
+ "language": "javascript",
87
+ "packageManager": "npm"
88
+ }
89
+ }
90
+
91
+ CRITICAL INSTRUCTIONS:
92
+ 1. Output ONLY the JSON object, nothing else
93
+ 2. No markdown formatting, no code blocks, no explanations
94
+ 3. Use Read/Glob tools to examine the project structure thoroughly
95
+ 4. Extract actual port numbers, commands, and configurations from files
96
+ 5. startCommand is REQUIRED for each app - must be the actual command to start the app
97
+ 6. stopCommand and restartCommand are optional (can be null)
98
+ 7. appType MUST be "static-frontend" for React/Vue/Angular/Svelte/Next.js static builds, or "backend" for Express/Fastify/FastAPI/Django/etc.
99
+ 8. buildDir MUST be specified for static frontends (usually "dist", "build", or "public")
100
+ 9. port is REQUIRED for HTTP apps
101
+ 10. endpoints should be an array of HTTP routes (e.g., ["/", "/api", "/api/v1"])
102
+ 11. directory should be relative path from project root, empty string "" for root app
103
+ 12. Be thorough and accurate - identify ALL apps/services in the project
104
+ 13. Each app will get a TypeScript runner file ({appName}_runner.ts) generated automatically
105
+ 14. The runner will wrap the app to collect logs, stats, and provide unified control
106
+ 15. After generating the JSON, write it to ".claude-voice.json" file in the project root using the Write tool`
107
+
108
+ let config: ProjectConfig | null = null
109
+ let lastAssistantMessage = ''
110
+ let errorOccurred = false
111
+ let foundJson = false
112
+ const assistantMessages: string[] = []
113
+
114
+ try {
115
+ // Create temporary session handler
116
+ await agentSessionManager.createSession({
117
+ sessionId: analysisSessionId,
118
+ projectPath,
119
+ onOutput: (output: SessionOutput) => {
120
+ // Capture assistant messages to extract JSON
121
+ if (output.type === 'assistant') {
122
+ const data = typeof output.data === 'string' ? output.data : JSON.stringify(output.data)
123
+ assistantMessages.push(data)
124
+ lastAssistantMessage = data
125
+ }
126
+ // Check for file writes (Claude might write the JSON file directly)
127
+ if (output.type === 'tool_result' && output.metadata?.toolName === 'Write') {
128
+ const toolResult = output.metadata.toolResult
129
+ if (toolResult && typeof toolResult === 'object' && 'file_path' in toolResult) {
130
+ const filePath = (toolResult as any).file_path
131
+ if (filePath && filePath.includes('.talk-to-code.json')) {
132
+ // Claude wrote the config file, mark as found
133
+ foundJson = true
134
+ }
135
+ }
136
+ }
137
+ if (output.type === 'result' && output.metadata?.isError) {
138
+ errorOccurred = true
139
+ }
140
+ },
141
+ onError: (error) => {
142
+ console.error(`Analysis session error: ${error}`)
143
+ errorOccurred = true
144
+ },
145
+ onComplete: () => {
146
+ // Session completed
147
+ }
148
+ })
149
+
150
+ // Send analysis prompt
151
+ await agentSessionManager.sendPrompt(analysisSessionId, analysisPrompt, [], {
152
+ sessionId: analysisSessionId,
153
+ projectPath,
154
+ onOutput: (output: SessionOutput) => {
155
+ // Capture assistant messages
156
+ if (output.type === 'assistant') {
157
+ const data = typeof output.data === 'string' ? output.data : JSON.stringify(output.data)
158
+ assistantMessages.push(data)
159
+ lastAssistantMessage = data
160
+ }
161
+ // Also check for file writes (Claude might write the JSON file directly)
162
+ if (output.type === 'tool_result' && output.metadata?.toolName === 'Write') {
163
+ // Claude wrote a file, check if it's the config file
164
+ const toolResult = output.metadata.toolResult
165
+ if (toolResult && typeof toolResult === 'object' && 'file_path' in toolResult) {
166
+ const filePath = (toolResult as any).file_path
167
+ if (filePath && filePath.includes('.talk-to-code.json')) {
168
+ // Claude wrote the config file, try to read it
169
+ setTimeout(async () => {
170
+ try {
171
+ const configPath = path.join(projectPath, '.talk-to-code.json')
172
+ const content = await fs.readFile(configPath, 'utf-8')
173
+ const parsed = JSON.parse(content) as ProjectConfig
174
+ if (parsed && parsed.apps) {
175
+ config = parsed
176
+ foundJson = true
177
+ }
178
+ } catch (error) {
179
+ // Ignore read errors
180
+ }
181
+ }, 1000)
182
+ }
183
+ }
184
+ }
185
+ },
186
+ onError: (error: string) => {
187
+ console.error(`Analysis error: ${error}`)
188
+ errorOccurred = true
189
+ },
190
+ onComplete: () => {
191
+ // Analysis complete
192
+ }
193
+ })
194
+
195
+ // Wait for Claude to process (give it time to read files and generate response)
196
+ // Check multiple times as Claude may take a while - wait up to 90 seconds
197
+ const configFilePath = path.join(projectPath, CONFIG_FILENAME)
198
+
199
+ for (let i = 0; i < 90; i++) {
200
+ await new Promise(resolve => setTimeout(resolve, 1000))
201
+
202
+ // Check if Claude wrote the config file directly
203
+ try {
204
+ await fs.access(configFilePath)
205
+ const fileContent = await fs.readFile(configFilePath, 'utf-8')
206
+ const parsed = JSON.parse(fileContent) as ProjectConfig
207
+ if (parsed && parsed.apps) {
208
+ config = parsed
209
+ foundJson = true
210
+ break
211
+ }
212
+ } catch {
213
+ // File doesn't exist yet or invalid JSON, continue waiting
214
+ }
215
+
216
+ // Check if we have a complete JSON response in messages
217
+ if (lastAssistantMessage && (lastAssistantMessage.includes('"version"') || lastAssistantMessage.includes('"apps"') || lastAssistantMessage.includes('"services"'))) {
218
+ foundJson = true
219
+ break
220
+ }
221
+
222
+ // Check if session completed (result message received)
223
+ if (errorOccurred) {
224
+ break
225
+ }
226
+ }
227
+
228
+ // If config was read from file, use it instead of parsing messages
229
+ if (!config || !foundJson) {
230
+ // Try to extract JSON from the assistant messages (check all messages, latest first)
231
+ for (let i = assistantMessages.length - 1; i >= 0; i--) {
232
+ let jsonStr = assistantMessages[i].trim()
233
+
234
+ // Remove markdown code blocks if present
235
+ jsonStr = jsonStr.replace(/^```json\s*/i, '').replace(/^```\s*/, '').replace(/\s*```$/, '')
236
+ jsonStr = jsonStr.trim()
237
+
238
+ // Try to extract JSON object if wrapped in text
239
+ const jsonMatch = jsonStr.match(/\{[\s\S]*\}/)
240
+ if (jsonMatch) {
241
+ jsonStr = jsonMatch[0]
242
+ }
243
+
244
+ try {
245
+ const parsedConfig = JSON.parse(jsonStr) as ProjectConfig
246
+ config = parsedConfig
247
+ foundJson = true
248
+ break
249
+ } catch (parseError: any) {
250
+ // Try next message
251
+ continue
252
+ }
253
+ }
254
+ }
255
+
256
+ // Validate and ensure required fields if config was loaded
257
+ if (config && foundJson) {
258
+ // Validate and ensure required fields
259
+ if (!config.version) config.version = '1.0.0'
260
+ if (!config.projectName) config.projectName = projectName
261
+ if (!config.apps) config.apps = []
262
+ if (!config.directories) config.directories = []
263
+
264
+ // Migrate old "services" to "apps" if present
265
+ if ((config as any).services && !config.apps) {
266
+ config.apps = (config as any).services
267
+ }
268
+
269
+ // Ensure each app has a startCommand
270
+ config.apps = config.apps.map(app => ({
271
+ ...app,
272
+ startCommand: app.startCommand || 'echo "No start command configured"',
273
+ }))
274
+
275
+ if (!config.metadata) {
276
+ config.metadata = {
277
+ analyzedAt: new Date().toISOString(),
278
+ analyzedBy: 'talk-to-code-agent'
279
+ }
280
+ } else {
281
+ config.metadata.analyzedAt = new Date().toISOString()
282
+ config.metadata.analyzedBy = 'talk-to-code-agent'
283
+ }
284
+ }
285
+
286
+ // Clean up session
287
+ try {
288
+ await agentSessionManager.deleteSession(analysisSessionId)
289
+ } catch (cleanupError) {
290
+ console.warn(`Failed to cleanup analysis session:`, cleanupError)
291
+ }
292
+
293
+ } catch (error: any) {
294
+ console.error(`Error during Claude analysis:`, error.message)
295
+ errorOccurred = true
296
+
297
+ // Try to cleanup session
298
+ try {
299
+ await agentSessionManager.deleteSession(analysisSessionId)
300
+ } catch {}
301
+ }
302
+
303
+ // If analysis failed or no config extracted, throw error (caller can handle fallback)
304
+ if (!config || errorOccurred) {
305
+ throw new Error(`Claude analysis failed or incomplete. Last message: ${lastAssistantMessage.substring(0, 200)}`)
306
+ }
307
+
308
+ return config
309
+ }
310
+
311
+ /**
312
+ * Read project configuration from file
313
+ */
314
+ export async function getProjectConfig(projectPath: string): Promise<ProjectConfig | null> {
315
+ try {
316
+ const configPath = path.join(projectPath, CONFIG_FILENAME)
317
+ const content = await fs.readFile(configPath, 'utf-8')
318
+ const config = JSON.parse(content) as ProjectConfig
319
+
320
+ // Migrate old "services" to "apps" if present
321
+ if ((config as any).services && !config.apps) {
322
+ config.apps = (config as any).services
323
+ }
324
+
325
+ return config
326
+ } catch (error: any) {
327
+ if (error.code === 'ENOENT') {
328
+ return null // Config file doesn't exist yet
329
+ }
330
+ throw error
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Save project configuration to file
336
+ */
337
+ export async function saveProjectConfig(projectPath: string, config: ProjectConfig): Promise<void> {
338
+ const configPath = path.join(projectPath, CONFIG_FILENAME)
339
+ const content = JSON.stringify(config, null, 2)
340
+ await fs.writeFile(configPath, content, 'utf-8')
341
+ }