@exreve/exk 1.0.26 → 1.0.28

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/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { startApp, stopApp, restartApp, getAppStatuses, getAppLogs } from './app
16
16
  import { spawn, execSync } from 'child_process';
17
17
  import readline from 'readline';
18
18
  import { fileURLToPath } from 'url';
19
+ import { createHash } from 'crypto';
19
20
  // ============ Constants ============
20
21
  const CONFIG_DIR = path.join(os.homedir(), '.talk-to-code');
21
22
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
@@ -65,6 +66,33 @@ async function fetchAiConfig(authToken) {
65
66
  }
66
67
  }
67
68
  const AI_CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json');
69
+ const PROXY_CONFIG_FILE = path.join(CONFIG_DIR, 'proxy.json');
70
+ /** Read proxy toggle state from disk */
71
+ async function readProxyConfig() {
72
+ try {
73
+ const raw = await fs.readFile(PROXY_CONFIG_FILE, 'utf-8');
74
+ return JSON.parse(raw);
75
+ }
76
+ catch {
77
+ return { enabled: false };
78
+ }
79
+ }
80
+ /** Write proxy toggle state to disk */
81
+ async function writeProxyConfig(cfg) {
82
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
83
+ await fs.writeFile(PROXY_CONFIG_FILE, JSON.stringify(cfg, null, 2));
84
+ }
85
+ /** Get the proxy URL from ai-config.json (saved from backend) */
86
+ function getProxyUrl() {
87
+ try {
88
+ const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8');
89
+ const j = JSON.parse(raw);
90
+ return typeof j.proxy === 'string' ? j.proxy.trim() : '';
91
+ }
92
+ catch {
93
+ return '';
94
+ }
95
+ }
68
96
  /** True if ai-config.json has a model API key (not read from host ANTHROPIC_* env). */
69
97
  function hasAiCredentials() {
70
98
  try {
@@ -199,24 +227,97 @@ function getCliVersion() {
199
227
  return '0.0.0';
200
228
  }
201
229
  }
202
- // ============ Update Helpers ============
203
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
204
- let isUpdating = false;
205
- /** Check if a newer version is available on npm */
206
- async function checkForNpmUpdate() {
230
+ const CURRENT_FILE = fileURLToPath(import.meta.url);
231
+ const __dirname = path.dirname(CURRENT_FILE);
232
+ function getCliHash() {
233
+ return createHash('sha256').update(fsSync.readFileSync(CURRENT_FILE)).digest('hex');
234
+ }
235
+ async function checkForUpdate() {
207
236
  try {
208
- const currentVersion = getCliVersion();
209
- const latestVersion = execSync('npm view @exreve/exk version', { encoding: 'utf-8', timeout: 10000 }).trim();
210
- return {
211
- updateAvailable: currentVersion !== latestVersion,
212
- currentVersion,
213
- latestVersion
214
- };
237
+ const config = await readConfig();
238
+ const res = await fetch(`${config.apiUrl}/update/check`, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({ hash: getCliHash(), platform: os.platform() })
242
+ });
243
+ if (!res.ok)
244
+ return null;
245
+ return await res.json();
215
246
  }
216
247
  catch {
217
248
  return null;
218
249
  }
219
250
  }
251
+ async function replaceSelf(tarballBuffer) {
252
+ const extractDir = path.join(os.tmpdir(), `ttc-update-${Date.now()}`);
253
+ await fs.mkdir(extractDir, { recursive: true });
254
+ const tarPath = path.join(extractDir, 'ttc-cli.tar.gz');
255
+ await fs.writeFile(tarPath, tarballBuffer);
256
+ execSync(`tar -xzf "${tarPath}" -C "${extractDir}"`);
257
+ await fs.unlink(tarPath);
258
+ // Preserve user config/token files (never overwrite on update)
259
+ const preserveFiles = ['device-config.json', 'device-id.json', 'config.json'];
260
+ const preserved = {};
261
+ for (const f of preserveFiles) {
262
+ try {
263
+ preserved[f] = await fs.readFile(path.join(CONFIG_DIR, f));
264
+ }
265
+ catch {
266
+ /* file may not exist */
267
+ }
268
+ }
269
+ // Replace cli/ and shared/ in CONFIG_DIR
270
+ const cliDest = path.join(CONFIG_DIR, 'cli');
271
+ const sharedDest = path.join(CONFIG_DIR, 'shared');
272
+ await fs.rm(cliDest, { recursive: true, force: true });
273
+ await fs.rm(sharedDest, { recursive: true, force: true });
274
+ await fs.cp(path.join(extractDir, 'cli'), cliDest, { recursive: true });
275
+ await fs.cp(path.join(extractDir, 'shared'), sharedDest, { recursive: true });
276
+ // Update package.json and npm install
277
+ await fs.copyFile(path.join(extractDir, 'package.json'), path.join(CONFIG_DIR, 'package.json'));
278
+ await fs.rm(extractDir, { recursive: true, force: true });
279
+ // Restore preserved config
280
+ for (const f of preserveFiles) {
281
+ if (preserved[f])
282
+ await fs.writeFile(path.join(CONFIG_DIR, f), preserved[f]);
283
+ }
284
+ console.log('✓ CLI updated');
285
+ const npmPaths = [path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'), path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')];
286
+ const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm';
287
+ try {
288
+ execSync(`"${npmBin}" install --omit=dev`, { cwd: CONFIG_DIR, stdio: 'inherit' });
289
+ console.log('✓ Dependencies updated');
290
+ }
291
+ catch {
292
+ console.warn('⚠ npm install failed');
293
+ }
294
+ }
295
+ async function selfUpdate(force = false) {
296
+ const info = await checkForUpdate();
297
+ if (!info || !info.updateAvailable) {
298
+ console.log('✓ Already up to date');
299
+ return;
300
+ }
301
+ console.log('📦 Update available!');
302
+ if (!force && process.stdin.isTTY) {
303
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
304
+ const answer = await new Promise(r => rl.question('Update now? [Y/n] ', a => { rl.close(); r(a.trim()); }));
305
+ if (answer.toLowerCase().startsWith('n'))
306
+ return;
307
+ }
308
+ if (!info.downloadUrl || !info.hash)
309
+ return;
310
+ console.log('Downloading...');
311
+ const res = await fetch(info.downloadUrl);
312
+ if (!res.ok)
313
+ throw new Error('Download failed');
314
+ const bundle = Buffer.from(await res.arrayBuffer());
315
+ if (createHash('sha256').update(bundle).digest('hex') !== info.hash) {
316
+ throw new Error('Hash mismatch');
317
+ }
318
+ await replaceSelf(bundle);
319
+ process.exit(0);
320
+ }
220
321
  // ============ Commands ============
221
322
  async function registerDevice(name, email) {
222
323
  try {
@@ -516,9 +617,9 @@ async function runDaemon(foreground = false, email) {
516
617
  const ipAddress = getLocalIpAddress();
517
618
  const hostname = getHostname();
518
619
  // Silent update check on startup
519
- checkForNpmUpdate().then(info => {
620
+ checkForUpdate().then(info => {
520
621
  if (info?.updateAvailable)
521
- console.log(`📦 Update available: ${info.currentVersion} → ${info.latestVersion} (run "exk update")`);
622
+ console.log('📦 Update available: run "ttc update"');
522
623
  }).catch(() => { });
523
624
  if (foreground)
524
625
  console.log('=== TalkToCode CLI (Foreground) ===');
@@ -1263,6 +1364,7 @@ async function runDaemon(foreground = false, email) {
1263
1364
  projectPath,
1264
1365
  promptId: capturedPromptId,
1265
1366
  model: model, // Pass the model from the session
1367
+ attachments: data.attachments, // Pass attachments from frontend
1266
1368
  onStatusUpdate: (status) => {
1267
1369
  // Emit status update from CLI (CLI is source of truth)
1268
1370
  // Use captured promptId to ensure correct prompt is updated
@@ -1642,13 +1744,10 @@ async function runDaemon(foreground = false, email) {
1642
1744
  // ========== Version & Update Handlers ==========
1643
1745
  // Respond with CLI version info
1644
1746
  socket.on('version:info', (_data, callback) => {
1645
- if (isUpdating) {
1646
- callback({ success: false, error: 'Update in progress' });
1647
- return;
1648
- }
1649
1747
  callback({
1650
1748
  success: true,
1651
1749
  version: getCliVersion(),
1750
+ hash: getCliHash(),
1652
1751
  date: new Date().toISOString(),
1653
1752
  nodeVersion: process.version,
1654
1753
  platform: os.platform(),
@@ -1657,10 +1756,6 @@ async function runDaemon(foreground = false, email) {
1657
1756
  });
1658
1757
  // Force update: npm update -g @exreve/exk then restart PM2
1659
1758
  socket.on('force-update', (_data, callback) => {
1660
- if (isUpdating) {
1661
- callback?.({ success: false, error: 'Update already in progress' });
1662
- return;
1663
- }
1664
1759
  if (foreground) {
1665
1760
  console.log('[force-update] Received force update command from server');
1666
1761
  }
@@ -1684,9 +1779,15 @@ async function runDaemon(foreground = false, email) {
1684
1779
  if (output)
1685
1780
  console.log(output);
1686
1781
  }
1687
- // Exit and let PM2 restart us with the new version
1782
+ // Restart PM2 process "cli" after a short delay
1688
1783
  setTimeout(() => {
1689
- process.exit(0);
1784
+ try {
1785
+ execSync('pm2 restart cli', { stdio: 'inherit' });
1786
+ }
1787
+ catch {
1788
+ // If pm2 restart fails, just exit and let pm2 restart us
1789
+ process.exit(0);
1790
+ }
1690
1791
  }, 2000);
1691
1792
  });
1692
1793
  updateProcess.on('error', (err) => {
@@ -1695,7 +1796,67 @@ async function runDaemon(foreground = false, email) {
1695
1796
  }
1696
1797
  });
1697
1798
  });
1799
+ // ========== update:start handler (legacy compatibility) ==========
1800
+ socket.on('update:start', (_data, callback) => {
1801
+ // Use npm-based self-update
1802
+ if (foreground) {
1803
+ console.log('[update:start] Starting npm self-update...');
1804
+ }
1805
+ callback?.({ success: true, message: 'Update started via npm' });
1806
+ const npmPaths = [
1807
+ path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'),
1808
+ path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')
1809
+ ];
1810
+ const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm';
1811
+ const updateProcess = spawn(npmBin, ['update', '-g', '@exreve/exk'], {
1812
+ stdio: 'pipe',
1813
+ detached: true
1814
+ });
1815
+ updateProcess.on('close', () => {
1816
+ setTimeout(() => {
1817
+ try {
1818
+ execSync('pm2 restart cli', { stdio: 'inherit' });
1819
+ }
1820
+ catch {
1821
+ process.exit(0);
1822
+ }
1823
+ }, 2000);
1824
+ });
1825
+ });
1698
1826
  // Cloudflared handlers
1827
+ // Proxy toggle handler
1828
+ socket.on('proxy:toggle:request', async (data, callback) => {
1829
+ try {
1830
+ const { enable } = data;
1831
+ const proxyUrl = getProxyUrl();
1832
+ if (enable && !proxyUrl) {
1833
+ callback?.({ success: false, enabled: false });
1834
+ return;
1835
+ }
1836
+ await writeProxyConfig({ enabled: enable });
1837
+ if (foreground) {
1838
+ console.log(`[CLI] Proxy ${enable ? 'enabled' : 'disabled'}${proxyUrl ? `: ${proxyUrl}` : ''}`);
1839
+ }
1840
+ else {
1841
+ console.log(`Proxy ${enable ? 'enabled' : 'disabled'}`);
1842
+ }
1843
+ callback?.({ success: true, enabled: enable, proxyUrl: enable ? proxyUrl : undefined });
1844
+ }
1845
+ catch (error) {
1846
+ callback?.({ success: false, enabled: false });
1847
+ }
1848
+ });
1849
+ // Proxy status handler
1850
+ socket.on('proxy:status:request', async (_data, callback) => {
1851
+ try {
1852
+ const proxyConfig = await readProxyConfig();
1853
+ const proxyUrl = getProxyUrl();
1854
+ callback?.({ enabled: proxyConfig.enabled, proxyUrl: proxyConfig.enabled ? proxyUrl : undefined });
1855
+ }
1856
+ catch {
1857
+ callback?.({ enabled: false });
1858
+ }
1859
+ });
1699
1860
  socket.on('cloudflared:check:request', async () => {
1700
1861
  try {
1701
1862
  let installed = false;
@@ -2205,7 +2366,7 @@ async function runDaemon(foreground = false, email) {
2205
2366
  const { name, image, ports = [], env = {}, runAsRoot = false } = data;
2206
2367
  // Get CLI directory path (where this script is running from)
2207
2368
  // Use the same pattern as elsewhere in the file
2208
- const cliDir = __dirname;
2369
+ const cliDir = path.dirname(CURRENT_FILE);
2209
2370
  // Build docker run command
2210
2371
  let cmd = `${runtime} run -d --name ${name}`;
2211
2372
  // Security: Running as root INSIDE container is safe - it's still isolated from host
@@ -2679,4 +2840,37 @@ program
2679
2840
  process.exit(1);
2680
2841
  });
2681
2842
  });
2843
+ // Enable proxy command
2844
+ program
2845
+ .command('enable')
2846
+ .description('Enable a feature (e.g. proxy)')
2847
+ .argument('<feature>', 'Feature to enable (proxy)')
2848
+ .action(async (feature) => {
2849
+ if (feature !== 'proxy') {
2850
+ console.error(`Unknown feature: ${feature}. Available: proxy`);
2851
+ process.exit(1);
2852
+ }
2853
+ const proxyUrl = getProxyUrl();
2854
+ if (!proxyUrl) {
2855
+ console.log('No proxy URL configured. Run "exk daemon" first to sync config from server.');
2856
+ process.exit(1);
2857
+ }
2858
+ await writeProxyConfig({ enabled: true });
2859
+ console.log(`Proxy enabled: ${proxyUrl}`);
2860
+ process.exit(0);
2861
+ });
2862
+ // Disable proxy command
2863
+ program
2864
+ .command('disable')
2865
+ .description('Disable a feature (e.g. proxy)')
2866
+ .argument('<feature>', 'Feature to disable (proxy)')
2867
+ .action(async (feature) => {
2868
+ if (feature !== 'proxy') {
2869
+ console.error(`Unknown feature: ${feature}. Available: proxy`);
2870
+ process.exit(1);
2871
+ }
2872
+ await writeProxyConfig({ enabled: false });
2873
+ console.log('Proxy disabled');
2874
+ process.exit(0);
2875
+ });
2682
2876
  program.parse();
@@ -3,9 +3,13 @@
3
3
  *
4
4
  * Exposes enabled modules as MCP tools to the agent.
5
5
  * This allows the agent to interact with modules like user-choice through standard tool calls.
6
+ * Also provides built-in tools like analyze_image for vision capabilities.
6
7
  */
7
8
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
8
9
  import { z } from 'zod';
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import * as os from 'os';
9
13
  /**
10
14
  * Create a tool for the user-choice module
11
15
  */
@@ -15,7 +19,7 @@ function createUserChoiceTool(onChoiceRequest) {
15
19
  options: z.array(z.object({ label: z.string(), value: z.string() })),
16
20
  timeout: z.number().optional()
17
21
  };
18
- return tool('user_choice_request', 'Present a choice modal to the user and wait for their selection. Use this whenever you need a decision, confirmation, or preference from the user. The AskUserQuestion tool is disabled this is your only way to get interactive user input. Always use this before proceeding with ambiguous tasks or destructive actions.', schema, async (args, _extra) => {
22
+ return tool('user_choice_request', '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.', schema, async (args, _extra) => {
19
23
  if (!onChoiceRequest) {
20
24
  return {
21
25
  content: [
@@ -67,6 +71,74 @@ function createUserChoiceTool(onChoiceRequest) {
67
71
  }
68
72
  });
69
73
  }
74
+ /**
75
+ * Convert a file to a data URI for vision API consumption
76
+ */
77
+ function fileToDataUri(filePath) {
78
+ try {
79
+ const buf = fs.readFileSync(filePath);
80
+ const ext = path.extname(filePath).toLowerCase().replace('.', '');
81
+ const mimeMap = {
82
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
83
+ gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp',
84
+ svg: 'image/svg+xml',
85
+ };
86
+ const mime = mimeMap[ext] || 'application/octet-stream';
87
+ return `data:${mime};base64,${buf.toString('base64')}`;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ /**
94
+ * Create the analyze_image tool for vision capabilities via OpenRouter
95
+ */
96
+ function createAnalyzeImageTool(attachmentDir) {
97
+ const workDir = attachmentDir || os.tmpdir();
98
+ return tool('analyze_image', 'Analyze one or more image files using a vision model. Pass the path to an image file and a question. Returns a detailed text answer about the image content.', {
99
+ image_path: z.string().describe('Path to the image file to analyze (can be relative to working directory, e.g. "attachments/photo.jpg")'),
100
+ question: z.string().describe('Question or instruction about the image. Be specific about what you want to know.'),
101
+ }, async (args) => {
102
+ const apiKey = process.env.OPENROUTER_API_KEY || '';
103
+ if (!apiKey) {
104
+ return { content: [{ type: 'text', text: 'Error: OPENROUTER_API_KEY not configured.' }], isError: true };
105
+ }
106
+ try {
107
+ // Resolve relative paths against the attachment dir
108
+ const imagePath = path.resolve(workDir, args.image_path);
109
+ if (!fs.existsSync(imagePath)) {
110
+ return { content: [{ type: 'text', text: `Error: Image file not found: ${args.image_path}` }], isError: true };
111
+ }
112
+ const dataUri = fileToDataUri(imagePath);
113
+ if (!dataUri) {
114
+ return { content: [{ type: 'text', text: `Error: Could not read image file: ${args.image_path}` }], isError: true };
115
+ }
116
+ const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions';
117
+ const OPENROUTER_MODEL = 'qwen/qwen3.5-27b';
118
+ const res = await fetch(OPENROUTER_ENDPOINT, {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
121
+ body: JSON.stringify({
122
+ model: OPENROUTER_MODEL,
123
+ messages: [{ role: 'user', content: [
124
+ { type: 'text', text: args.question },
125
+ { type: 'image_url', image_url: { url: dataUri } },
126
+ ] }],
127
+ }),
128
+ signal: AbortSignal.timeout(60_000),
129
+ });
130
+ const raw = await res.text();
131
+ if (!res.ok) {
132
+ return { content: [{ type: 'text', text: `Error from vision API (${res.status}): ${raw.slice(0, 500)}` }], isError: true };
133
+ }
134
+ const parsed = JSON.parse(raw);
135
+ return { content: [{ type: 'text', text: parsed.choices?.[0]?.message?.content || raw }] };
136
+ }
137
+ catch (error) {
138
+ return { content: [{ type: 'text', text: `Error analyzing image: ${error.message}` }], isError: true };
139
+ }
140
+ });
141
+ }
70
142
  /**
71
143
  * Get available tools based on enabled modules
72
144
  */
@@ -76,6 +148,10 @@ function getModuleTools(config) {
76
148
  if (config.enabledModules.includes('user-choice')) {
77
149
  tools.push(createUserChoiceTool(config.onChoiceRequest));
78
150
  }
151
+ // Always add analyze_image tool if attachmentDir is provided (i.e. there are attachments)
152
+ if (config.attachmentDir) {
153
+ tools.push(createAnalyzeImageTool(config.attachmentDir));
154
+ }
79
155
  // Add more module tools here as they are implemented
80
156
  return tools;
81
157
  }
@@ -91,25 +167,3 @@ export function createModuleMcpServer(config) {
91
167
  });
92
168
  return server;
93
169
  }
94
- /**
95
- * Get a system prompt hint describing available module tools.
96
- * This ensures the model knows about custom MCP tools upfront without
97
- * having to discover them through the MCP tool-listing mechanism.
98
- */
99
- export function getModuleToolHint(enabledModules) {
100
- if (enabledModules.length === 0)
101
- return null;
102
- const toolDescriptions = [];
103
- if (enabledModules.includes('user-choice')) {
104
- toolDescriptions.push(`- user_choice_request(question: str, options: [{label: str, value: str}], timeout?: int): Present a modal to the remote user with a question and clickable options. Returns the user's selection.
105
- IMPORTANT: The built-in AskUserQuestion tool is DISABLED. Whenever you need user input, decisions, or confirmations, you MUST use user_choice_request instead.
106
- Always call this tool when:
107
- - There are multiple valid approaches and you need the user to pick one
108
- - You need explicit confirmation before a destructive or significant action
109
- - The user's preference matters for how to proceed`);
110
- }
111
- // Add more module tool descriptions here as they are implemented
112
- if (toolDescriptions.length === 0)
113
- return null;
114
- return `You have access to the following custom MCP tools:\n${toolDescriptions.join('\n')}\n\nThese tools are your ONLY way to interact with the user (AskUserQuestion is disabled). Call them directly — do NOT ask the user to type responses in chat.`;
115
- }
Binary file