@exreve/exk 1.0.2 → 1.0.3
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/agentSession.ts +36 -36
- package/app-child.ts +122 -19
- package/appManager.ts +2 -2
- package/appRunner.ts +1 -1
- package/index.ts +322 -74
- package/package.json +1 -1
- package/projectAnalyzer.ts +3 -3
- package/runnerGenerator.ts +1 -1
package/agentSession.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
2
|
-
import type { SessionOutput } from './shared/types'
|
|
2
|
+
import type { SessionOutput } from './shared/types.js'
|
|
3
3
|
import { execSync, spawn } from 'child_process'
|
|
4
4
|
import { existsSync, realpathSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
|
|
5
5
|
import { symlink as fsSymlink } from 'fs'
|
|
6
|
-
|
|
6
|
+
import type { AgentLogger } from './agentLogger.js' // type-only; runtime logging disabled elsewhere
|
|
7
7
|
// Memory system removed for performance
|
|
8
|
-
import { getSkillContent } from './skills/index'
|
|
9
|
-
import { createModuleMcpServer, type ChoiceRequest, type ChoiceResponse, type ModuleMcpServerConfig } from './moduleMcpServer'
|
|
8
|
+
import { getSkillContent } from './skills/index.js'
|
|
9
|
+
import { createModuleMcpServer, type ChoiceRequest, type ChoiceResponse, type ModuleMcpServerConfig } from './moduleMcpServer.js'
|
|
10
10
|
import path from 'path'
|
|
11
11
|
import os from 'os'
|
|
12
12
|
import { createRequire } from 'module'
|
|
@@ -78,31 +78,41 @@ export interface SessionHandler {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// AI config - loaded from server after registration, stored in ~/.talk-to-code/ai-config.json
|
|
81
|
+
// (Do not read ANTHROPIC_* / CLAUDE_MODEL from the host environment — only this file + code default model.)
|
|
81
82
|
const AI_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'ai-config.json')
|
|
83
|
+
const DEFAULT_AI_MODEL = 'glm-5.1'
|
|
82
84
|
|
|
83
|
-
function loadAiConfig(): {
|
|
85
|
+
function loadAiConfig(): { apiKey: string; baseUrl: string; model: string } {
|
|
84
86
|
try {
|
|
85
87
|
const data = readFileSync(AI_CONFIG_PATH, 'utf-8')
|
|
86
|
-
const config = JSON.parse(data)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
88
|
+
const config = JSON.parse(data) as { authToken?: string; baseUrl?: string; model?: string }
|
|
89
|
+
const apiKey = typeof config.authToken === 'string' ? config.authToken.trim() : ''
|
|
90
|
+
const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
|
|
91
|
+
const model =
|
|
92
|
+
typeof config.model === 'string' && config.model.trim() ? config.model.trim() : DEFAULT_AI_MODEL
|
|
93
|
+
return { apiKey, baseUrl, model }
|
|
92
94
|
} catch {
|
|
93
|
-
return {
|
|
94
|
-
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_API_KEY || '',
|
|
95
|
-
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL || '',
|
|
96
|
-
MODEL: process.env.CLAUDE_MODEL || 'glm-5.1',
|
|
97
|
-
}
|
|
95
|
+
return { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL }
|
|
98
96
|
}
|
|
99
97
|
}
|
|
100
98
|
|
|
99
|
+
/** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from ai-config only. */
|
|
100
|
+
function envForClaudeCodeChild(): NodeJS.ProcessEnv {
|
|
101
|
+
const env = { ...process.env } as NodeJS.ProcessEnv
|
|
102
|
+
delete env.ANTHROPIC_API_KEY
|
|
103
|
+
delete env.ANTHROPIC_BASE_URL
|
|
104
|
+
delete env.ANTHROPIC_AUTH_TOKEN
|
|
105
|
+
const { apiKey, baseUrl } = loadAiConfig()
|
|
106
|
+
if (apiKey) env.ANTHROPIC_API_KEY = apiKey
|
|
107
|
+
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl
|
|
108
|
+
return env
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
// Lazy config getter - reloads from file each time (so daemon picks up changes without restart)
|
|
102
112
|
const CLAUDE_CONFIG = {
|
|
103
|
-
get
|
|
104
|
-
get
|
|
105
|
-
get
|
|
113
|
+
get apiKey() { return loadAiConfig().apiKey },
|
|
114
|
+
get baseUrl() { return loadAiConfig().baseUrl },
|
|
115
|
+
get model() { return loadAiConfig().model },
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
interface ConversationMessage {
|
|
@@ -160,7 +170,7 @@ export class AgentSessionManager {
|
|
|
160
170
|
|
|
161
171
|
async createSession(handler: SessionHandler): Promise<void> {
|
|
162
172
|
const { sessionId, projectPath, model } = handler
|
|
163
|
-
const sessionModel = model || CLAUDE_CONFIG.
|
|
173
|
+
const sessionModel = model || CLAUDE_CONFIG.model
|
|
164
174
|
|
|
165
175
|
// Ensure project directory exists - prevents ENOENT errors when SDK spawns process
|
|
166
176
|
if (!existsSync(projectPath)) {
|
|
@@ -206,7 +216,7 @@ export class AgentSessionManager {
|
|
|
206
216
|
// DISABLED: File logging removed for performance
|
|
207
217
|
// const logger = new AgentLogger(sessionId)
|
|
208
218
|
// await logger.logSessionCreated(projectPath)
|
|
209
|
-
// await logger.logModelConfig(sessionModel, CLAUDE_CONFIG.
|
|
219
|
+
// await logger.logModelConfig(sessionModel, CLAUDE_CONFIG.baseUrl)
|
|
210
220
|
const logger = undefined
|
|
211
221
|
|
|
212
222
|
// Store the handler for this session
|
|
@@ -427,12 +437,6 @@ export class AgentSessionManager {
|
|
|
427
437
|
}
|
|
428
438
|
})
|
|
429
439
|
|
|
430
|
-
// Setup environment
|
|
431
|
-
process.env.ANTHROPIC_API_KEY = CLAUDE_CONFIG.ANTHROPIC_AUTH_TOKEN
|
|
432
|
-
if (CLAUDE_CONFIG.ANTHROPIC_BASE_URL) {
|
|
433
|
-
process.env.ANTHROPIC_BASE_URL = CLAUDE_CONFIG.ANTHROPIC_BASE_URL
|
|
434
|
-
}
|
|
435
|
-
|
|
436
440
|
// Use cached Claude executable path (resolved at module load time for performance)
|
|
437
441
|
const pathToClaudeCodeExecutable = CACHED_CLAUDE_PATH
|
|
438
442
|
// DISABLED: File logging removed for performance
|
|
@@ -447,8 +451,8 @@ export class AgentSessionManager {
|
|
|
447
451
|
const queryOptions: any = {
|
|
448
452
|
signal: abortController.signal, // Pass abort signal to SDK for interruption
|
|
449
453
|
cwd: projectPath,
|
|
450
|
-
apiKey: CLAUDE_CONFIG.
|
|
451
|
-
model: CLAUDE_CONFIG.
|
|
454
|
+
apiKey: CLAUDE_CONFIG.apiKey,
|
|
455
|
+
model: CLAUDE_CONFIG.model,
|
|
452
456
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
453
457
|
disallowedTools: ['AskUserQuestion'],
|
|
454
458
|
settingSources: ['project'], // Enable CLAUDE.md loading
|
|
@@ -555,11 +559,7 @@ export class AgentSessionManager {
|
|
|
555
559
|
|
|
556
560
|
return child
|
|
557
561
|
},
|
|
558
|
-
env:
|
|
559
|
-
...process.env,
|
|
560
|
-
ANTHROPIC_API_KEY: CLAUDE_CONFIG.ANTHROPIC_AUTH_TOKEN,
|
|
561
|
-
ANTHROPIC_BASE_URL: CLAUDE_CONFIG.ANTHROPIC_BASE_URL,
|
|
562
|
-
},
|
|
562
|
+
env: envForClaudeCodeChild(),
|
|
563
563
|
hooks: {
|
|
564
564
|
// PostToolUse hook DISABLED for tool_result emission.
|
|
565
565
|
// tool_result events are already emitted via the user message handler (line ~752)
|
|
@@ -587,10 +587,10 @@ export class AgentSessionManager {
|
|
|
587
587
|
}
|
|
588
588
|
|
|
589
589
|
// Log model being used for debugging
|
|
590
|
-
const sessionModel = session.model || CLAUDE_CONFIG.
|
|
590
|
+
const sessionModel = session.model || CLAUDE_CONFIG.model
|
|
591
591
|
// DISABLED: File logging removed for performance
|
|
592
592
|
// if (session.logger) {
|
|
593
|
-
// await session.logger.logModelConfig(sessionModel, CLAUDE_CONFIG.
|
|
593
|
+
// await session.logger.logModelConfig(sessionModel, CLAUDE_CONFIG.baseUrl)
|
|
594
594
|
// }
|
|
595
595
|
|
|
596
596
|
// Create query stream - resume session if we have a Claude session ID
|
package/app-child.ts
CHANGED
|
@@ -31,12 +31,12 @@ import type {
|
|
|
31
31
|
SessionResponse,
|
|
32
32
|
SessionsListResponse,
|
|
33
33
|
DevicesListResponse,
|
|
34
|
-
} from './shared/types'
|
|
35
|
-
import { createProject, deleteProject } from './projectManager'
|
|
36
|
-
import { agentSessionManager } from './agentSession'
|
|
37
|
-
import { getProjectConfig, analyzeProjectWithClaude, saveProjectConfig } from './projectAnalyzer'
|
|
38
|
-
import { generateRunnerCode } from './runnerGenerator'
|
|
39
|
-
import { startApp, stopApp, restartApp, getAppStatuses, getAppLogs } from './appManager'
|
|
34
|
+
} from './shared/types.js'
|
|
35
|
+
import { createProject, deleteProject } from './projectManager.js'
|
|
36
|
+
import { agentSessionManager } from './agentSession.js'
|
|
37
|
+
import { getProjectConfig, analyzeProjectWithClaude, saveProjectConfig } from './projectAnalyzer.js'
|
|
38
|
+
import { generateRunnerCode } from './runnerGenerator.js'
|
|
39
|
+
import { startApp, stopApp, restartApp, getAppStatuses, getAppLogs } from './appManager.js'
|
|
40
40
|
import { spawn, execSync } from 'child_process'
|
|
41
41
|
import readline from 'readline'
|
|
42
42
|
import { fileURLToPath } from 'url'
|
|
@@ -89,6 +89,8 @@ interface DeviceData {
|
|
|
89
89
|
const CONFIG_DIR = path.join(os.homedir(), '.talk-to-code')
|
|
90
90
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
|
|
91
91
|
const DEVICE_ID_FILE = path.join(CONFIG_DIR, 'device-id.json')
|
|
92
|
+
const DEVICE_CONFIG_FILE = path.join(CONFIG_DIR, 'device-config.json')
|
|
93
|
+
const AI_CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json')
|
|
92
94
|
const DEFAULT_API_URL = 'https://api.talk-to-code.com'
|
|
93
95
|
|
|
94
96
|
// ============ Helpers ============
|
|
@@ -107,6 +109,104 @@ async function writeConfig(config: Config): Promise<void> {
|
|
|
107
109
|
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2))
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Fetch AI config from server and save to ~/.talk-to-code/ai-config.json
|
|
114
|
+
*/
|
|
115
|
+
async function fetchAiConfig(authToken: string): Promise<boolean> {
|
|
116
|
+
try {
|
|
117
|
+
const config = await readConfig()
|
|
118
|
+
const res = await fetch(`${config.apiUrl}/config/ai`, {
|
|
119
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
120
|
+
})
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
console.log(`[fetchAiConfig] Server returned ${res.status}`)
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
const data = await res.json() as {
|
|
126
|
+
success: boolean
|
|
127
|
+
aiConfig?: { authToken: string; baseUrl: string; model: string }
|
|
128
|
+
}
|
|
129
|
+
if (!data.success || !data.aiConfig) {
|
|
130
|
+
console.log('[fetchAiConfig] No AI config available for this user')
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
await fs.writeFile(AI_CONFIG_FILE, JSON.stringify(data.aiConfig, null, 2))
|
|
134
|
+
console.log('[fetchAiConfig] AI config saved successfully')
|
|
135
|
+
return true
|
|
136
|
+
} catch (error: any) {
|
|
137
|
+
console.log(`[fetchAiConfig] Failed: ${error.message}`)
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasAiCredentials(): boolean {
|
|
143
|
+
try {
|
|
144
|
+
const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8')
|
|
145
|
+
const j = JSON.parse(raw) as { authToken?: string }
|
|
146
|
+
return typeof j.authToken === 'string' && j.authToken.trim().length > 0
|
|
147
|
+
} catch {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readDeviceAuthToken(): Promise<string | undefined> {
|
|
153
|
+
const env = process.env.TTC_AUTH_TOKEN?.trim()
|
|
154
|
+
if (env) return env
|
|
155
|
+
try {
|
|
156
|
+
const raw = await fs.readFile(DEVICE_CONFIG_FILE, 'utf-8')
|
|
157
|
+
const j = JSON.parse(raw) as { authToken?: string }
|
|
158
|
+
return typeof j.authToken === 'string' ? j.authToken.trim() : undefined
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type DiskDeviceConfig = { email?: string; authToken?: string }
|
|
165
|
+
|
|
166
|
+
async function readDiskDeviceConfig(): Promise<DiskDeviceConfig> {
|
|
167
|
+
try {
|
|
168
|
+
const raw = await fs.readFile(DEVICE_CONFIG_FILE, 'utf-8')
|
|
169
|
+
return JSON.parse(raw) as DiskDeviceConfig
|
|
170
|
+
} catch {
|
|
171
|
+
return {}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeDeviceConfigMerged(partial: { email?: string; authToken?: string }): Promise<void> {
|
|
176
|
+
const prev = await readDiskDeviceConfig()
|
|
177
|
+
const email = partial.email !== undefined ? partial.email : prev.email
|
|
178
|
+
let token: string | undefined
|
|
179
|
+
if (partial.authToken !== undefined) {
|
|
180
|
+
token = partial.authToken.trim() || undefined
|
|
181
|
+
} else {
|
|
182
|
+
token = typeof prev.authToken === 'string' && prev.authToken.trim() ? prev.authToken.trim() : undefined
|
|
183
|
+
}
|
|
184
|
+
const out: DiskDeviceConfig = {}
|
|
185
|
+
if (email) out.email = email
|
|
186
|
+
if (token) out.authToken = token
|
|
187
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true })
|
|
188
|
+
await fs.writeFile(DEVICE_CONFIG_FILE, JSON.stringify(out, null, 2))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function maybeFetchAiConfigIfMissing(): Promise<void> {
|
|
192
|
+
if (hasAiCredentials()) return
|
|
193
|
+
const jwt = await readDeviceAuthToken()
|
|
194
|
+
if (!jwt) return
|
|
195
|
+
const ok = await fetchAiConfig(jwt)
|
|
196
|
+
if (!ok && !hasAiCredentials()) {
|
|
197
|
+
const cfg = await readConfig()
|
|
198
|
+
console.warn(`[CLI] No AI key in ai-config.json yet. Could not sync from ${cfg.apiUrl}/config/ai — agent prompts will fail until this succeeds.`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function scheduleAiConfigSync(authToken: string): void {
|
|
203
|
+
void fetchAiConfig(authToken).then((ok) => {
|
|
204
|
+
if (!ok && !hasAiCredentials()) {
|
|
205
|
+
console.warn('[CLI] AI config sync failed; check backend /config/ai and apiUrl.')
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
110
210
|
async function getDeviceId(): Promise<string> {
|
|
111
211
|
try {
|
|
112
212
|
const data = await fs.readFile(DEVICE_ID_FILE, 'utf-8')
|
|
@@ -271,9 +371,8 @@ async function registerDevice(name: string | undefined, email: string | undefine
|
|
|
271
371
|
let deviceEmail = email
|
|
272
372
|
|
|
273
373
|
// If we already have a valid token, skip approval email and just register
|
|
274
|
-
const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
|
|
275
374
|
try {
|
|
276
|
-
const deviceConfig =
|
|
375
|
+
const deviceConfig = await readDiskDeviceConfig()
|
|
277
376
|
const existingToken = deviceConfig.authToken
|
|
278
377
|
if (existingToken && typeof existingToken === 'string') {
|
|
279
378
|
const config = await readConfig()
|
|
@@ -309,6 +408,7 @@ async function registerDevice(name: string | undefined, email: string | undefine
|
|
|
309
408
|
}
|
|
310
409
|
})
|
|
311
410
|
})
|
|
411
|
+
await fetchAiConfig(existingToken)
|
|
312
412
|
return
|
|
313
413
|
}
|
|
314
414
|
}
|
|
@@ -396,11 +496,7 @@ async function registerDevice(name: string | undefined, email: string | undefine
|
|
|
396
496
|
const token = await checkApproval()
|
|
397
497
|
if (token) {
|
|
398
498
|
// Device approved! Store permanent token
|
|
399
|
-
|
|
400
|
-
await fs.writeFile(deviceConfigFile, JSON.stringify({
|
|
401
|
-
email: deviceEmail,
|
|
402
|
-
authToken: token
|
|
403
|
-
}, null, 2))
|
|
499
|
+
await writeDeviceConfigMerged({ email: deviceEmail, authToken: token })
|
|
404
500
|
|
|
405
501
|
console.log(`\n✓ Device approved!`)
|
|
406
502
|
|
|
@@ -434,6 +530,7 @@ async function registerDevice(name: string | undefined, email: string | undefine
|
|
|
434
530
|
})
|
|
435
531
|
})
|
|
436
532
|
|
|
533
|
+
await fetchAiConfig(token)
|
|
437
534
|
return // Successfully registered
|
|
438
535
|
}
|
|
439
536
|
|
|
@@ -632,6 +729,8 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
632
729
|
}
|
|
633
730
|
console.log('')
|
|
634
731
|
|
|
732
|
+
await maybeFetchAiConfigIfMissing()
|
|
733
|
+
|
|
635
734
|
let socket: Socket | null = null
|
|
636
735
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
|
637
736
|
let reconnectAttempts = 0
|
|
@@ -1701,7 +1800,6 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1701
1800
|
}
|
|
1702
1801
|
|
|
1703
1802
|
// Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
|
|
1704
|
-
const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
|
|
1705
1803
|
let authToken = process.env.TTC_AUTH_TOKEN
|
|
1706
1804
|
let deviceEmail: string | undefined = email
|
|
1707
1805
|
let skipEmailFlow = false
|
|
@@ -1709,7 +1807,7 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1709
1807
|
// Load from device-config if no env token (reuse state from previous approval)
|
|
1710
1808
|
if (!authToken) {
|
|
1711
1809
|
try {
|
|
1712
|
-
const deviceConfig =
|
|
1810
|
+
const deviceConfig = await readDiskDeviceConfig()
|
|
1713
1811
|
authToken = deviceConfig.authToken
|
|
1714
1812
|
if (!deviceEmail && deviceConfig.email) deviceEmail = deviceConfig.email
|
|
1715
1813
|
} catch {
|
|
@@ -1741,7 +1839,7 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1741
1839
|
// Load device email from config if not using token and not provided
|
|
1742
1840
|
if (!deviceEmail && !skipEmailFlow) {
|
|
1743
1841
|
try {
|
|
1744
|
-
const deviceConfig =
|
|
1842
|
+
const deviceConfig = await readDiskDeviceConfig()
|
|
1745
1843
|
deviceEmail = deviceConfig.email
|
|
1746
1844
|
} catch {
|
|
1747
1845
|
// No device config yet
|
|
@@ -1782,13 +1880,18 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1782
1880
|
}
|
|
1783
1881
|
// Save config: email + authToken (preserve token so next run can reuse)
|
|
1784
1882
|
if (device.email) {
|
|
1785
|
-
|
|
1786
|
-
|
|
1883
|
+
void writeDeviceConfigMerged({
|
|
1884
|
+
email: device.email,
|
|
1885
|
+
...(authToken ? { authToken } : {}),
|
|
1886
|
+
}).catch(() => {})
|
|
1887
|
+
}
|
|
1888
|
+
if (authToken) {
|
|
1889
|
+
scheduleAiConfigSync(authToken)
|
|
1787
1890
|
}
|
|
1788
1891
|
} else if (needsApproval) {
|
|
1789
1892
|
// Persist email when approval was requested so restarts don't prompt again
|
|
1790
1893
|
if (deviceEmail) {
|
|
1791
|
-
|
|
1894
|
+
void writeDeviceConfigMerged({ email: deviceEmail }).catch(() => {})
|
|
1792
1895
|
}
|
|
1793
1896
|
// Only log once to avoid flooding logs every 30s on heartbeat
|
|
1794
1897
|
if (!needsApprovalLoggedOnce) {
|
package/appManager.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'fs/promises'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import os from 'os'
|
|
4
|
-
import type { ProjectApp } from './shared/types'
|
|
5
|
-
import { createRunner, BaseRunner, RunnerOutput, RunnerStats } from './appRunner'
|
|
4
|
+
import type { ProjectApp } from './shared/types.js'
|
|
5
|
+
import { createRunner, BaseRunner, RunnerOutput, RunnerStats } from './appRunner.js'
|
|
6
6
|
|
|
7
7
|
type RunningApp = {
|
|
8
8
|
runner: BaseRunner
|
package/appRunner.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn, ChildProcess } from 'child_process'
|
|
|
2
2
|
import fs from 'fs/promises'
|
|
3
3
|
import path from 'path'
|
|
4
4
|
import os from 'os'
|
|
5
|
-
import type { ProjectApp } from './shared/types'
|
|
5
|
+
import type { ProjectApp } from './shared/types.js'
|
|
6
6
|
|
|
7
7
|
/** Cross-platform: run a shell command (sh -c on Unix, cmd /c on Windows) */
|
|
8
8
|
function shellSpawnOpts(command: string): { shell: string; args: string[] } {
|
package/index.ts
CHANGED
|
@@ -22,12 +22,12 @@ import type {
|
|
|
22
22
|
SessionResponse,
|
|
23
23
|
SessionsListResponse,
|
|
24
24
|
DevicesListResponse,
|
|
25
|
-
} from './shared/types'
|
|
26
|
-
import { createProject, deleteProject } from './projectManager'
|
|
27
|
-
import { agentSessionManager } from './agentSession'
|
|
28
|
-
import { getProjectConfig, analyzeProjectWithClaude, saveProjectConfig } from './projectAnalyzer'
|
|
29
|
-
import { generateRunnerCode } from './runnerGenerator'
|
|
30
|
-
import { startApp, stopApp, restartApp, getAppStatuses, getAppLogs } from './appManager'
|
|
25
|
+
} from './shared/types.js'
|
|
26
|
+
import { createProject, deleteProject } from './projectManager.js'
|
|
27
|
+
import { agentSessionManager } from './agentSession.js'
|
|
28
|
+
import { getProjectConfig, analyzeProjectWithClaude, saveProjectConfig } from './projectAnalyzer.js'
|
|
29
|
+
import { generateRunnerCode } from './runnerGenerator.js'
|
|
30
|
+
import { startApp, stopApp, restartApp, getAppStatuses, getAppLogs } from './appManager.js'
|
|
31
31
|
import { spawn, execSync } from 'child_process'
|
|
32
32
|
import readline from 'readline'
|
|
33
33
|
import { fileURLToPath } from 'url'
|
|
@@ -48,6 +48,7 @@ interface DeviceData {
|
|
|
48
48
|
const CONFIG_DIR = path.join(os.homedir(), '.talk-to-code')
|
|
49
49
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
|
|
50
50
|
const DEVICE_ID_FILE = path.join(CONFIG_DIR, 'device-id.json')
|
|
51
|
+
const DEVICE_CONFIG_FILE = path.join(CONFIG_DIR, 'device-config.json')
|
|
51
52
|
const DEFAULT_API_URL = 'https://api.talk-to-code.com'
|
|
52
53
|
|
|
53
54
|
// ============ Helpers ============
|
|
@@ -71,7 +72,6 @@ async function writeConfig(config: Config): Promise<void> {
|
|
|
71
72
|
* Called after device registration and on daemon start
|
|
72
73
|
*/
|
|
73
74
|
async function fetchAiConfig(authToken: string): Promise<boolean> {
|
|
74
|
-
const AI_CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json')
|
|
75
75
|
try {
|
|
76
76
|
const config = await readConfig()
|
|
77
77
|
const res = await fetch(`${config.apiUrl}/config/ai`, {
|
|
@@ -95,6 +95,82 @@ async function fetchAiConfig(authToken: string): Promise<boolean> {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
const AI_CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json')
|
|
99
|
+
|
|
100
|
+
/** True if ai-config.json has a model API key (not read from host ANTHROPIC_* env). */
|
|
101
|
+
function hasAiCredentials(): boolean {
|
|
102
|
+
try {
|
|
103
|
+
const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8')
|
|
104
|
+
const j = JSON.parse(raw) as { authToken?: string }
|
|
105
|
+
return typeof j.authToken === 'string' && j.authToken.trim().length > 0
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readDeviceAuthToken(): Promise<string | undefined> {
|
|
112
|
+
const env = process.env.TTC_AUTH_TOKEN?.trim()
|
|
113
|
+
if (env) return env
|
|
114
|
+
try {
|
|
115
|
+
const raw = await fs.readFile(DEVICE_CONFIG_FILE, 'utf-8')
|
|
116
|
+
const j = JSON.parse(raw) as { authToken?: string }
|
|
117
|
+
return typeof j.authToken === 'string' ? j.authToken.trim() : undefined
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type DiskDeviceConfig = { email?: string; authToken?: string }
|
|
124
|
+
|
|
125
|
+
async function readDiskDeviceConfig(): Promise<DiskDeviceConfig> {
|
|
126
|
+
try {
|
|
127
|
+
const raw = await fs.readFile(DEVICE_CONFIG_FILE, 'utf-8')
|
|
128
|
+
return JSON.parse(raw) as DiskDeviceConfig
|
|
129
|
+
} catch {
|
|
130
|
+
return {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Update device-config.json without dropping an existing authToken.
|
|
136
|
+
* Omit authToken to keep the file’s current token; pass authToken: '' to clear (rare).
|
|
137
|
+
*/
|
|
138
|
+
async function writeDeviceConfigMerged(partial: { email?: string; authToken?: string }): Promise<void> {
|
|
139
|
+
const prev = await readDiskDeviceConfig()
|
|
140
|
+
const email = partial.email !== undefined ? partial.email : prev.email
|
|
141
|
+
let token: string | undefined
|
|
142
|
+
if (partial.authToken !== undefined) {
|
|
143
|
+
token = partial.authToken.trim() || undefined
|
|
144
|
+
} else {
|
|
145
|
+
token = typeof prev.authToken === 'string' && prev.authToken.trim() ? prev.authToken.trim() : undefined
|
|
146
|
+
}
|
|
147
|
+
const out: DiskDeviceConfig = {}
|
|
148
|
+
if (email) out.email = email
|
|
149
|
+
if (token) out.authToken = token
|
|
150
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true })
|
|
151
|
+
await fs.writeFile(DEVICE_CONFIG_FILE, JSON.stringify(out, null, 2))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** If AI cache is empty but we have a device JWT, try GET /config/ai once (non-fatal). */
|
|
155
|
+
async function maybeFetchAiConfigIfMissing(): Promise<void> {
|
|
156
|
+
if (hasAiCredentials()) return
|
|
157
|
+
const jwt = await readDeviceAuthToken()
|
|
158
|
+
if (!jwt) return
|
|
159
|
+
const ok = await fetchAiConfig(jwt)
|
|
160
|
+
if (!ok && !hasAiCredentials()) {
|
|
161
|
+
const cfg = await readConfig()
|
|
162
|
+
console.warn(`[CLI] No AI key in ai-config.json yet. Could not sync from ${cfg.apiUrl}/config/ai — agent prompts will fail until this succeeds.`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function scheduleAiConfigSync(authToken: string): void {
|
|
167
|
+
void fetchAiConfig(authToken).then((ok) => {
|
|
168
|
+
if (!ok && !hasAiCredentials()) {
|
|
169
|
+
console.warn('[CLI] AI config sync failed; check backend /config/ai and apiUrl.')
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
98
174
|
async function getDeviceId(): Promise<string> {
|
|
99
175
|
try {
|
|
100
176
|
const data = await fs.readFile(DEVICE_ID_FILE, 'utf-8')
|
|
@@ -147,6 +223,21 @@ async function connect(): Promise<Socket> {
|
|
|
147
223
|
})
|
|
148
224
|
}
|
|
149
225
|
|
|
226
|
+
// ============ Version Helpers ============
|
|
227
|
+
|
|
228
|
+
/** Read CLI version from the npm package.json (works when installed via npm i -g @exreve/exk) */
|
|
229
|
+
function getCliVersion(): string {
|
|
230
|
+
try {
|
|
231
|
+
// When installed via npm, package.json is at ../package.json relative to index.ts
|
|
232
|
+
const pkgPath = path.join(__dirname, 'package.json')
|
|
233
|
+
const raw = fsSync.readFileSync(pkgPath, 'utf-8')
|
|
234
|
+
const pkg = JSON.parse(raw) as { version?: string }
|
|
235
|
+
return pkg.version || '0.0.0'
|
|
236
|
+
} catch {
|
|
237
|
+
return '0.0.0'
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
150
241
|
// ============ Update Helpers ============
|
|
151
242
|
|
|
152
243
|
type UpdateCheckResponse = {
|
|
@@ -262,9 +353,8 @@ async function registerDevice(name: string | undefined, email: string | undefine
|
|
|
262
353
|
let deviceEmail = email
|
|
263
354
|
|
|
264
355
|
// If we already have a valid token, skip approval email and just register
|
|
265
|
-
const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
|
|
266
356
|
try {
|
|
267
|
-
const deviceConfig =
|
|
357
|
+
const deviceConfig = await readDiskDeviceConfig()
|
|
268
358
|
const existingToken = deviceConfig.authToken
|
|
269
359
|
if (existingToken && typeof existingToken === 'string') {
|
|
270
360
|
const config = await readConfig()
|
|
@@ -282,7 +372,8 @@ async function registerDevice(name: string | undefined, email: string | undefine
|
|
|
282
372
|
name: name || `CLI Device ${deviceId.slice(0, 8)}`,
|
|
283
373
|
ipAddress: getLocalIpAddress(),
|
|
284
374
|
hostname: getHostname(),
|
|
285
|
-
email: deviceEmail
|
|
375
|
+
email: deviceEmail,
|
|
376
|
+
cliVersion: getCliVersion()
|
|
286
377
|
}, ({ success, device, error }: {
|
|
287
378
|
success: boolean
|
|
288
379
|
device?: Device
|
|
@@ -391,11 +482,7 @@ async function registerDevice(name: string | undefined, email: string | undefine
|
|
|
391
482
|
const token = await checkApproval()
|
|
392
483
|
if (token) {
|
|
393
484
|
// Device approved! Store permanent token
|
|
394
|
-
|
|
395
|
-
await fs.writeFile(deviceConfigFile, JSON.stringify({
|
|
396
|
-
email: deviceEmail,
|
|
397
|
-
authToken: token
|
|
398
|
-
}, null, 2))
|
|
485
|
+
await writeDeviceConfigMerged({ email: deviceEmail, authToken: token })
|
|
399
486
|
|
|
400
487
|
console.log(`\n✓ Device approved!`)
|
|
401
488
|
|
|
@@ -408,7 +495,8 @@ async function registerDevice(name: string | undefined, email: string | undefine
|
|
|
408
495
|
name: name || `CLI Device ${deviceId.slice(0, 8)}`,
|
|
409
496
|
ipAddress: getLocalIpAddress(),
|
|
410
497
|
hostname: getHostname(),
|
|
411
|
-
email: deviceEmail
|
|
498
|
+
email: deviceEmail,
|
|
499
|
+
cliVersion: getCliVersion()
|
|
412
500
|
}, ({ success, device, error }: {
|
|
413
501
|
success: boolean
|
|
414
502
|
device?: Device
|
|
@@ -630,6 +718,8 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
630
718
|
}
|
|
631
719
|
console.log('')
|
|
632
720
|
|
|
721
|
+
await maybeFetchAiConfigIfMissing()
|
|
722
|
+
|
|
633
723
|
let socket: Socket | null = null
|
|
634
724
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
|
635
725
|
let reconnectAttempts = 0
|
|
@@ -1681,7 +1771,6 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1681
1771
|
}
|
|
1682
1772
|
|
|
1683
1773
|
// Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
|
|
1684
|
-
const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
|
|
1685
1774
|
let authToken = process.env.TTC_AUTH_TOKEN
|
|
1686
1775
|
let deviceEmail: string | undefined = email
|
|
1687
1776
|
let skipEmailFlow = false
|
|
@@ -1689,7 +1778,7 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1689
1778
|
// Load from device-config if no env token (reuse state from previous approval)
|
|
1690
1779
|
if (!authToken) {
|
|
1691
1780
|
try {
|
|
1692
|
-
const deviceConfig =
|
|
1781
|
+
const deviceConfig = await readDiskDeviceConfig()
|
|
1693
1782
|
authToken = deviceConfig.authToken
|
|
1694
1783
|
if (!deviceEmail && deviceConfig.email) deviceEmail = deviceConfig.email
|
|
1695
1784
|
} catch {
|
|
@@ -1721,7 +1810,7 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1721
1810
|
// Load device email from config if not using token and not provided
|
|
1722
1811
|
if (!deviceEmail && !skipEmailFlow) {
|
|
1723
1812
|
try {
|
|
1724
|
-
const deviceConfig =
|
|
1813
|
+
const deviceConfig = await readDiskDeviceConfig()
|
|
1725
1814
|
deviceEmail = deviceConfig.email
|
|
1726
1815
|
} catch {
|
|
1727
1816
|
// No device config yet
|
|
@@ -1741,6 +1830,7 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1741
1830
|
ipAddress,
|
|
1742
1831
|
hostname,
|
|
1743
1832
|
email: deviceEmail,
|
|
1833
|
+
cliVersion: getCliVersion(),
|
|
1744
1834
|
// When using auth token, include the token for verification
|
|
1745
1835
|
authToken: skipEmailFlow ? authToken : undefined
|
|
1746
1836
|
}, ({ success, needsApproval, message, device, error }: {
|
|
@@ -1760,19 +1850,20 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1760
1850
|
} else {
|
|
1761
1851
|
console.log(`Device registered: ${device.name}${device.approved ? ' (approved)' : ' (pending approval)'}`)
|
|
1762
1852
|
}
|
|
1763
|
-
// Save config: email
|
|
1853
|
+
// Save config: merge email; keep existing file authToken if in-memory token absent
|
|
1764
1854
|
if (device.email) {
|
|
1765
|
-
|
|
1766
|
-
|
|
1855
|
+
void writeDeviceConfigMerged({
|
|
1856
|
+
email: device.email,
|
|
1857
|
+
...(authToken ? { authToken } : {}),
|
|
1858
|
+
}).catch(() => {})
|
|
1767
1859
|
}
|
|
1768
|
-
// Fetch AI config from server (non-blocking, runs on each heartbeat)
|
|
1769
1860
|
if (authToken) {
|
|
1770
|
-
|
|
1861
|
+
scheduleAiConfigSync(authToken)
|
|
1771
1862
|
}
|
|
1772
1863
|
} else if (needsApproval) {
|
|
1773
|
-
// Persist email when approval was requested
|
|
1864
|
+
// Persist email when approval was requested; do not wipe authToken
|
|
1774
1865
|
if (deviceEmail) {
|
|
1775
|
-
|
|
1866
|
+
void writeDeviceConfigMerged({ email: deviceEmail }).catch(() => {})
|
|
1776
1867
|
}
|
|
1777
1868
|
// Only log once to avoid flooding logs every 30s on heartbeat
|
|
1778
1869
|
if (!needsApprovalLoggedOnce) {
|
|
@@ -1809,6 +1900,117 @@ async function runDaemon(foreground = false, email?: string): Promise<void> {
|
|
|
1809
1900
|
;(socket as any).heartbeatInterval = heartbeatInterval
|
|
1810
1901
|
})
|
|
1811
1902
|
|
|
1903
|
+
// ========== Version & Update Handlers ==========
|
|
1904
|
+
|
|
1905
|
+
// Respond with CLI version info
|
|
1906
|
+
socket.on('version:info', (_data: Record<string, never>, callback: (response: {
|
|
1907
|
+
success: boolean
|
|
1908
|
+
version?: string
|
|
1909
|
+
hash?: string
|
|
1910
|
+
date?: string
|
|
1911
|
+
nodeVersion?: string
|
|
1912
|
+
platform?: string
|
|
1913
|
+
arch?: string
|
|
1914
|
+
error?: string
|
|
1915
|
+
}) => void) => {
|
|
1916
|
+
callback({
|
|
1917
|
+
success: true,
|
|
1918
|
+
version: getCliVersion(),
|
|
1919
|
+
hash: getCliHash(),
|
|
1920
|
+
date: new Date().toISOString(),
|
|
1921
|
+
nodeVersion: process.version,
|
|
1922
|
+
platform: os.platform(),
|
|
1923
|
+
arch: os.arch()
|
|
1924
|
+
})
|
|
1925
|
+
})
|
|
1926
|
+
|
|
1927
|
+
// Force update: npm update -g @exreve/exk then restart PM2
|
|
1928
|
+
socket.on('force-update', (_data: Record<string, never>, callback: (response: {
|
|
1929
|
+
success: boolean
|
|
1930
|
+
message?: string
|
|
1931
|
+
error?: string
|
|
1932
|
+
}) => void) => {
|
|
1933
|
+
if (foreground) {
|
|
1934
|
+
console.log('[force-update] Received force update command from server')
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
callback?.({ success: true, message: 'Update initiated' })
|
|
1938
|
+
|
|
1939
|
+
// Run npm update in background, then restart
|
|
1940
|
+
const npmPaths = [
|
|
1941
|
+
path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'),
|
|
1942
|
+
path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')
|
|
1943
|
+
]
|
|
1944
|
+
const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm'
|
|
1945
|
+
|
|
1946
|
+
const updateProcess = spawn(npmBin, ['update', '-g', '@exreve/exk'], {
|
|
1947
|
+
stdio: 'pipe',
|
|
1948
|
+
detached: true
|
|
1949
|
+
})
|
|
1950
|
+
|
|
1951
|
+
let output = ''
|
|
1952
|
+
updateProcess.stdout?.on('data', (d: Buffer) => { output += d.toString() })
|
|
1953
|
+
updateProcess.stderr?.on('data', (d: Buffer) => { output += d.toString() })
|
|
1954
|
+
|
|
1955
|
+
updateProcess.on('close', (code) => {
|
|
1956
|
+
if (foreground) {
|
|
1957
|
+
console.log(`[force-update] npm update exited with code ${code}`)
|
|
1958
|
+
if (output) console.log(output)
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// Restart PM2 process "cli" after a short delay
|
|
1962
|
+
setTimeout(() => {
|
|
1963
|
+
try {
|
|
1964
|
+
execSync('pm2 restart cli', { stdio: 'inherit' })
|
|
1965
|
+
} catch {
|
|
1966
|
+
// If pm2 restart fails, just exit and let pm2 restart us
|
|
1967
|
+
process.exit(0)
|
|
1968
|
+
}
|
|
1969
|
+
}, 2000)
|
|
1970
|
+
})
|
|
1971
|
+
|
|
1972
|
+
updateProcess.on('error', (err) => {
|
|
1973
|
+
if (foreground) {
|
|
1974
|
+
console.error(`[force-update] npm update failed: ${err.message}`)
|
|
1975
|
+
}
|
|
1976
|
+
})
|
|
1977
|
+
})
|
|
1978
|
+
|
|
1979
|
+
// ========== update:start handler (legacy compatibility) ==========
|
|
1980
|
+
socket.on('update:start', (_data: Record<string, never>, callback: (response: {
|
|
1981
|
+
success: boolean
|
|
1982
|
+
message?: string
|
|
1983
|
+
error?: string
|
|
1984
|
+
}) => void) => {
|
|
1985
|
+
// Use npm-based self-update
|
|
1986
|
+
if (foreground) {
|
|
1987
|
+
console.log('[update:start] Starting npm self-update...')
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
callback?.({ success: true, message: 'Update started via npm' })
|
|
1991
|
+
|
|
1992
|
+
const npmPaths = [
|
|
1993
|
+
path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'),
|
|
1994
|
+
path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')
|
|
1995
|
+
]
|
|
1996
|
+
const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm'
|
|
1997
|
+
|
|
1998
|
+
const updateProcess = spawn(npmBin, ['update', '-g', '@exreve/exk'], {
|
|
1999
|
+
stdio: 'pipe',
|
|
2000
|
+
detached: true
|
|
2001
|
+
})
|
|
2002
|
+
|
|
2003
|
+
updateProcess.on('close', () => {
|
|
2004
|
+
setTimeout(() => {
|
|
2005
|
+
try {
|
|
2006
|
+
execSync('pm2 restart cli', { stdio: 'inherit' })
|
|
2007
|
+
} catch {
|
|
2008
|
+
process.exit(0)
|
|
2009
|
+
}
|
|
2010
|
+
}, 2000)
|
|
2011
|
+
})
|
|
2012
|
+
})
|
|
2013
|
+
|
|
1812
2014
|
// Cloudflared handlers
|
|
1813
2015
|
socket.on('cloudflared:check:request', async () => {
|
|
1814
2016
|
try {
|
|
@@ -2626,7 +2828,7 @@ const program = new Command()
|
|
|
2626
2828
|
program
|
|
2627
2829
|
.name('exk')
|
|
2628
2830
|
.description('exk - Control Claude CLI with voice and programmable interfaces')
|
|
2629
|
-
.version(
|
|
2831
|
+
.version(getCliVersion())
|
|
2630
2832
|
|
|
2631
2833
|
// Config command
|
|
2632
2834
|
program
|
|
@@ -2655,41 +2857,48 @@ program
|
|
|
2655
2857
|
// First register the device
|
|
2656
2858
|
await registerDevice(undefined, email)
|
|
2657
2859
|
|
|
2658
|
-
// After successful registration, install as service (Linux only)
|
|
2860
|
+
// After successful registration, install as PM2 service (Linux/macOS only)
|
|
2659
2861
|
if (process.platform === 'win32') {
|
|
2660
2862
|
console.log('\n✓ Device registered.')
|
|
2661
2863
|
console.log('On Windows, run the daemon manually: exk daemon')
|
|
2662
2864
|
} else {
|
|
2663
2865
|
console.log('\n📦 Installing exk daemon with pm2...')
|
|
2664
|
-
// install-service.sh: bundled at __dirname (npm package root)
|
|
2665
|
-
let installScript = path.join(__dirname, 'install-service.sh')
|
|
2666
|
-
const scriptDir = path.dirname(installScript)
|
|
2667
2866
|
|
|
2668
|
-
// Determine paths for the npm-installed package
|
|
2669
|
-
const pkgDir = path.resolve(__dirname)
|
|
2670
2867
|
const exkBin = process.argv[1] || 'exk'
|
|
2671
2868
|
|
|
2672
2869
|
try {
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
}
|
|
2870
|
+
// Check if pm2 is installed
|
|
2871
|
+
try {
|
|
2872
|
+
execSync('which pm2', { stdio: 'ignore' })
|
|
2873
|
+
} catch {
|
|
2874
|
+
console.log('Installing pm2...')
|
|
2875
|
+
execSync('npm i -g pm2', { stdio: 'inherit' })
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// Start exk daemon with pm2
|
|
2879
|
+
execSync(`pm2 start "${exkBin}" --name cli -- daemon`, {
|
|
2880
|
+
stdio: 'inherit'
|
|
2685
2881
|
})
|
|
2882
|
+
|
|
2883
|
+
// Save pm2 process list for auto-restart on reboot
|
|
2884
|
+
try {
|
|
2885
|
+
execSync('pm2 save', { stdio: 'inherit' })
|
|
2886
|
+
} catch {
|
|
2887
|
+
// Non-critical
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2686
2890
|
console.log('\n✓ exk installation complete!')
|
|
2687
2891
|
console.log('The service is now running in the background.')
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
console.log('
|
|
2691
|
-
|
|
2692
|
-
|
|
2892
|
+
console.log('\nUseful commands:')
|
|
2893
|
+
console.log(' pm2 logs cli - View logs')
|
|
2894
|
+
console.log(' pm2 restart cli - Restart service')
|
|
2895
|
+
console.log(' pm2 stop cli - Stop service')
|
|
2896
|
+
console.log(' pm2 delete cli - Remove service')
|
|
2897
|
+
} catch (err: unknown) {
|
|
2898
|
+
const error = err as Error
|
|
2899
|
+
console.log('\n⚠ PM2 installation had issues, but device is registered.')
|
|
2900
|
+
const execErr = error as { stderr?: Buffer; stdout?: Buffer }
|
|
2901
|
+
const errDetail = execErr.stderr?.toString() || execErr.stdout?.toString() || error.message
|
|
2693
2902
|
if (errDetail) console.error('Error:', errDetail)
|
|
2694
2903
|
console.log('You can start the daemon manually with: exk daemon')
|
|
2695
2904
|
}
|
|
@@ -2720,23 +2929,18 @@ program
|
|
|
2720
2929
|
|
|
2721
2930
|
if (process.platform === 'win32') {
|
|
2722
2931
|
console.log('On Windows there is no service to remove.')
|
|
2723
|
-
console.log('To remove exk completely: npm uninstall -g exk')
|
|
2932
|
+
console.log('To remove exk completely: npm uninstall -g @exreve/exk')
|
|
2724
2933
|
} else {
|
|
2725
|
-
let installScript = path.join(__dirname, 'install-service.sh')
|
|
2726
|
-
const scriptDir = path.dirname(installScript)
|
|
2727
2934
|
try {
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
cwd: scriptDir,
|
|
2733
|
-
env: { ...process.env, PATH: process.env.PATH }
|
|
2734
|
-
})
|
|
2935
|
+
execSync('pm2 delete cli', { stdio: 'inherit' })
|
|
2936
|
+
try {
|
|
2937
|
+
execSync('pm2 save', { stdio: 'inherit' })
|
|
2938
|
+
} catch { /* non-critical */ }
|
|
2735
2939
|
console.log('\n✓ Service uninstalled!')
|
|
2736
|
-
console.log('To remove the package completely: npm uninstall -g exk')
|
|
2737
|
-
} catch
|
|
2738
|
-
console.log('\n⚠
|
|
2739
|
-
console.log('To remove exk: npm uninstall -g exk')
|
|
2940
|
+
console.log('To remove the package completely: npm uninstall -g @exreve/exk')
|
|
2941
|
+
} catch {
|
|
2942
|
+
console.log('\n⚠ PM2 service not found or already removed.')
|
|
2943
|
+
console.log('To remove exk: npm uninstall -g @exreve/exk')
|
|
2740
2944
|
console.log(' rm -rf ~/.talk-to-code')
|
|
2741
2945
|
}
|
|
2742
2946
|
}
|
|
@@ -2745,13 +2949,48 @@ program
|
|
|
2745
2949
|
process.exit(0)
|
|
2746
2950
|
})
|
|
2747
2951
|
|
|
2748
|
-
// Update command
|
|
2952
|
+
// Update command - uses npm to update the global package
|
|
2749
2953
|
program
|
|
2750
2954
|
.command('update')
|
|
2751
|
-
.description('
|
|
2955
|
+
.description('Update exk to the latest version via npm')
|
|
2752
2956
|
.option('-f, --force', 'Update without confirmation')
|
|
2753
2957
|
.action(async (options: { force?: boolean }) => {
|
|
2754
|
-
|
|
2958
|
+
const currentVersion = getCliVersion()
|
|
2959
|
+
console.log(`Current version: ${currentVersion}`)
|
|
2960
|
+
|
|
2961
|
+
// Check latest version from npm
|
|
2962
|
+
try {
|
|
2963
|
+
const latestVersion = execSync('npm view @exreve/exk version', { encoding: 'utf-8' }).trim()
|
|
2964
|
+
console.log(`Latest version: ${latestVersion}`)
|
|
2965
|
+
|
|
2966
|
+
if (latestVersion === currentVersion) {
|
|
2967
|
+
console.log('✓ Already up to date')
|
|
2968
|
+
process.exit(0)
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
if (!options.force && process.stdin.isTTY) {
|
|
2972
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
2973
|
+
const answer = await new Promise<string>(r => rl.question('Update now? [Y/n] ', a => { rl.close(); r(a.trim()) }))
|
|
2974
|
+
if (answer.toLowerCase().startsWith('n')) {
|
|
2975
|
+
process.exit(0)
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
console.log('Updating...')
|
|
2980
|
+
execSync('npm i -g @exreve/exk@latest', { stdio: 'inherit' })
|
|
2981
|
+
console.log(`\n✓ Updated to ${latestVersion}`)
|
|
2982
|
+
|
|
2983
|
+
// Restart PM2 if running
|
|
2984
|
+
try {
|
|
2985
|
+
execSync('pm2 restart cli', { stdio: 'inherit' })
|
|
2986
|
+
} catch {
|
|
2987
|
+
// Not running under PM2, that's fine
|
|
2988
|
+
}
|
|
2989
|
+
} catch (error: any) {
|
|
2990
|
+
console.error('Update failed:', error.message)
|
|
2991
|
+
process.exit(1)
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2755
2994
|
process.exit(0)
|
|
2756
2995
|
})
|
|
2757
2996
|
|
|
@@ -2759,14 +2998,23 @@ program
|
|
|
2759
2998
|
.command('check-update')
|
|
2760
2999
|
.description('Check for updates')
|
|
2761
3000
|
.action(async () => {
|
|
2762
|
-
const
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
3001
|
+
const currentVersion = getCliVersion()
|
|
3002
|
+
console.log(`Current version: ${currentVersion}`)
|
|
3003
|
+
|
|
3004
|
+
try {
|
|
3005
|
+
const latestVersion = execSync('npm view @exreve/exk version', { encoding: 'utf-8' }).trim()
|
|
3006
|
+
console.log(`Latest version: ${latestVersion}`)
|
|
3007
|
+
|
|
3008
|
+
if (latestVersion === currentVersion) {
|
|
3009
|
+
console.log('✓ Up to date')
|
|
3010
|
+
} else {
|
|
3011
|
+
console.log(`📦 Update available: ${currentVersion} → ${latestVersion}`)
|
|
3012
|
+
console.log('Run "exk update" to install')
|
|
3013
|
+
}
|
|
3014
|
+
} catch (error: any) {
|
|
3015
|
+
console.error('Failed to check for updates:', error.message)
|
|
3016
|
+
process.exit(1)
|
|
2767
3017
|
}
|
|
2768
|
-
console.log('📦 Update available')
|
|
2769
|
-
console.log('Run "exk update" to install')
|
|
2770
3018
|
process.exit(0)
|
|
2771
3019
|
})
|
|
2772
3020
|
|
package/package.json
CHANGED
package/projectAnalyzer.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from 'uuid'
|
|
2
2
|
import fs from 'fs/promises'
|
|
3
3
|
import path from 'path'
|
|
4
|
-
import type { ProjectConfig } from './shared/types'
|
|
5
|
-
import { agentSessionManager } from './agentSession'
|
|
6
|
-
import type { SessionOutput } from './shared/types'
|
|
4
|
+
import type { ProjectConfig } from './shared/types.js'
|
|
5
|
+
import { agentSessionManager } from './agentSession.js'
|
|
6
|
+
import type { SessionOutput } from './shared/types.js'
|
|
7
7
|
|
|
8
8
|
const CONFIG_FILENAME = '.claude-voice.json'
|
|
9
9
|
|
package/runnerGenerator.ts
CHANGED