@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.
- package/README.md +109 -0
- package/agentLogger.ts +162 -0
- package/agentSession.ts +1176 -0
- package/app-child.ts +2769 -0
- package/appManager.ts +275 -0
- package/appRunner.ts +475 -0
- package/bin/exk +45 -0
- package/container-entrypoint.sh +177 -0
- package/index.ts +2798 -0
- package/install-service.sh +122 -0
- package/moduleMcpServer.ts +131 -0
- package/package.json +67 -0
- package/projectAnalyzer.ts +341 -0
- package/projectManager.ts +111 -0
- package/runnerGenerator.ts +218 -0
- package/shared/types.ts +488 -0
- package/skills/code-review.md +49 -0
- package/skills/front-glass.md +36 -0
- package/skills/frontend-design.md +41 -0
- package/skills/index.ts +151 -0
- package/tsconfig.json +22 -0
- package/updater.ts +512 -0
|
@@ -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
|
+
}
|