@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.
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from '
4
4
  import { symlink as fsSymlink } from 'fs';
5
5
  import { getSkillContent } from './skills/index.js';
6
6
  import { isLocalModel, unwrapModelName, startOpenAIAdapter, getAdapterConfig } from './openaiAdapter.js';
7
- import { createModuleMcpServer, getModuleToolHint } from './moduleMcpServer.js';
7
+ import { createModuleMcpServer } from './moduleMcpServer.js';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
10
  import { createRequire } from 'module';
@@ -127,6 +127,48 @@ function lookupToolNameFromHistory(messages, toolUseId) {
127
127
  // (Do not read ANTHROPIC_* / CLAUDE_MODEL from the host environment — only this file + code default model.)
128
128
  const AI_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'ai-config.json');
129
129
  const DEFAULT_AI_MODEL = 'glm-5.1';
130
+ const PROVIDERS = {
131
+ zai: {
132
+ apiKey: process.env.ZHIPU_API_KEY || '',
133
+ baseUrl: process.env.CLI_AI_BASE_URL || 'https://api.z.ai/api/anthropic',
134
+ models: ['glm-5.1', 'glm-4.7', 'glm-4.5-air'],
135
+ },
136
+ minimax: {
137
+ apiKey: '', // Populated from ai-config.json (served by backend)
138
+ baseUrl: 'https://api.minimax.io/anthropic',
139
+ models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed'],
140
+ },
141
+ };
142
+ /** Resolve which provider to use based on model name or explicit provider ID.
143
+ * 1. Populate provider API keys from ai-config.json (served by backend).
144
+ * 2. If explicit providerId is given and has an API key configured, use that provider.
145
+ * 3. Else if model name matches one of a provider's model list, use that provider.
146
+ * 4. Else fall back to zai (default). */
147
+ function resolveProvider(model, providerId) {
148
+ // Populate provider keys from ai-config.json
149
+ const aiConfig = loadAiConfig();
150
+ PROVIDERS.minimax.apiKey = aiConfig.minimaxApiKey || process.env.MINIMAX_API_KEY || '';
151
+ if (!PROVIDERS.zai.apiKey)
152
+ PROVIDERS.zai.apiKey = aiConfig.apiKey || '';
153
+ // 1. Explicit provider selection
154
+ if (providerId && PROVIDERS[providerId]?.apiKey) {
155
+ const provider = PROVIDERS[providerId];
156
+ return { provider: providerId, apiKey: provider.apiKey, baseUrl: provider.baseUrl, model };
157
+ }
158
+ // 2. Match model name to a provider
159
+ for (const [id, config] of Object.entries(PROVIDERS)) {
160
+ if (config.models.includes(model) && config.apiKey) {
161
+ return { provider: id, apiKey: config.apiKey, baseUrl: config.baseUrl, model };
162
+ }
163
+ }
164
+ // 3. Fallback: use ai-config.json credentials (z.ai default)
165
+ return {
166
+ provider: 'zai',
167
+ apiKey: aiConfig.apiKey,
168
+ baseUrl: aiConfig.baseUrl || PROVIDERS.zai.baseUrl,
169
+ model,
170
+ };
171
+ }
130
172
  function loadAiConfig() {
131
173
  try {
132
174
  const data = readFileSync(AI_CONFIG_PATH, 'utf-8');
@@ -134,10 +176,12 @@ function loadAiConfig() {
134
176
  const apiKey = typeof config.authToken === 'string' ? config.authToken.trim() : '';
135
177
  const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : '';
136
178
  const model = typeof config.model === 'string' && config.model.trim() ? config.model.trim() : DEFAULT_AI_MODEL;
137
- return { apiKey, baseUrl, model };
179
+ const proxy = typeof config.proxy === 'string' ? config.proxy.trim() : '';
180
+ const minimaxApiKey = typeof config.minimaxApiKey === 'string' ? config.minimaxApiKey.trim() : '';
181
+ return { apiKey, baseUrl, model, proxy, minimaxApiKey };
138
182
  }
139
183
  catch {
140
- return { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL };
184
+ return { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL, proxy: '', minimaxApiKey: '' };
141
185
  }
142
186
  }
143
187
  /** Create (or reuse) an empty directory to use as CLAUDE_CONFIG_DIR.
@@ -151,32 +195,66 @@ function getEmptyConfigDir() {
151
195
  }
152
196
  return configDir;
153
197
  }
154
- /** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from ai-config only.
155
- * If a local model is provided, override baseUrl to point to the anthropic-proxy adapter. */
156
- function envForClaudeCodeChild(localModel) {
198
+ const PROXY_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'proxy.json');
199
+ /** Read proxy toggle state from disk (synchronous) */
200
+ function readProxyToggle() {
201
+ try {
202
+ const data = readFileSync(PROXY_CONFIG_PATH, 'utf-8');
203
+ return JSON.parse(data);
204
+ }
205
+ catch {
206
+ return { enabled: false };
207
+ }
208
+ }
209
+ /** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from provider or ai-config.
210
+ * If a local model is provided, override baseUrl to point to the anthropic-proxy adapter.
211
+ * If resolvedProvider is provided, use its credentials instead of ai-config defaults. */
212
+ function envForClaudeCodeChild(localModel, resolvedProvider) {
157
213
  const env = { ...process.env };
158
214
  // Strip any host ANTHROPIC_* vars to prevent leaking credentials or wrong URLs
159
215
  delete env.ANTHROPIC_API_KEY;
160
216
  delete env.ANTHROPIC_BASE_URL;
161
217
  delete env.ANTHROPIC_AUTH_TOKEN;
162
- // Inject from our ai-config.json only
163
- const { apiKey, baseUrl } = loadAiConfig();
164
- if (apiKey)
165
- env.ANTHROPIC_API_KEY = apiKey;
166
- if (baseUrl)
167
- env.ANTHROPIC_BASE_URL = baseUrl;
168
- // Clear any inherited proxy env
169
- delete env.HTTPS_PROXY;
170
- delete env.HTTP_PROXY;
171
- delete env.https_proxy;
172
- delete env.http_proxy;
218
+ // Also strip model alias env vars to prevent stale overrides
219
+ delete env.ANTHROPIC_MODEL;
220
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
221
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
222
+ delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
223
+ // Determine credentials: use resolvedProvider if provided, else ai-config defaults
224
+ const { apiKey, baseUrl, proxy } = loadAiConfig();
225
+ const effectiveApiKey = resolvedProvider?.apiKey || apiKey;
226
+ const effectiveBaseUrl = resolvedProvider?.baseUrl || baseUrl;
227
+ if (effectiveApiKey)
228
+ env.ANTHROPIC_API_KEY = effectiveApiKey;
229
+ if (effectiveBaseUrl)
230
+ env.ANTHROPIC_BASE_URL = effectiveBaseUrl;
231
+ // For MiniMax specifically: override ALL model aliases so the SDK
232
+ // sends the correct model ID to the Anthropic-compatible endpoint
233
+ if (resolvedProvider?.provider === 'minimax') {
234
+ env.ANTHROPIC_MODEL = resolvedProvider.model;
235
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = resolvedProvider.model;
236
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = resolvedProvider.model;
237
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolvedProvider.model;
238
+ env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
239
+ }
240
+ // Apply proxy if enabled
241
+ const proxyToggle = readProxyToggle();
242
+ if (proxyToggle.enabled && proxy) {
243
+ env.HTTPS_PROXY = proxy;
244
+ env.HTTP_PROXY = proxy;
245
+ }
246
+ else {
247
+ // Clear any inherited proxy env
248
+ delete env.HTTPS_PROXY;
249
+ delete env.HTTP_PROXY;
250
+ delete env.https_proxy;
251
+ delete env.http_proxy;
252
+ }
173
253
  // Prevent ~/.claude/settings.json env section from overriding our base URL.
174
254
  // This redirects the Claude config dir to an empty dir so that
175
255
  // ~/.claude/settings.json (which may have ANTHROPIC_BASE_URL set to z.ai)
176
256
  // is never read during the CLI process initialization.
177
257
  env.CLAUDE_CONFIG_DIR = getEmptyConfigDir();
178
- // Allow Claude to run as root (e.g. inside Docker containers)
179
- env.IS_SANDBOX = '1';
180
258
  return env;
181
259
  }
182
260
  /** Get env overrides for a local model (adapter proxy URL + dummy key). Returns null if not a local model. */
@@ -316,7 +394,8 @@ export class AgentSessionManager {
316
394
  timestamp: Date.now(),
317
395
  promptId: handler.promptId,
318
396
  abortController: new AbortController(), // Pre-create for queued cancellation
319
- model: handler.model || session.model // Use handler model or fall back to session model
397
+ model: handler.model || session.model, // Use handler model or fall back to session model
398
+ attachments: handler.attachments // Pass attachments through
320
399
  });
321
400
  // Start processing queue if not already processing
322
401
  if (!session.isProcessingQueue) {
@@ -339,13 +418,30 @@ export class AgentSessionManager {
339
418
  session.isProcessingQueue = true;
340
419
  while (session.promptQueue.length > 0 && !this.emergencyStopInProgress.has(sessionId)) {
341
420
  const queuedPrompt = session.promptQueue.shift();
342
- const { prompt, enhancers, handler, promptId: queuedPromptId, abortController: queuedAbortController } = queuedPrompt;
421
+ const { enhancers, handler, promptId: queuedPromptId, abortController: queuedAbortController } = queuedPrompt;
343
422
  const { projectPath, promptId, onOutput, onError, onComplete, onStatusUpdate } = handler;
344
423
  const promptStartTime = Date.now();
424
+ // Write attachments to temp dir and inject paths into prompt
425
+ let effectivePrompt = queuedPrompt.prompt;
426
+ let attachmentDir;
427
+ if (queuedPrompt.attachments && queuedPrompt.attachments.length > 0) {
428
+ attachmentDir = path.join(os.tmpdir(), 'talk-to-code', 'attachments', sessionId, String(promptId || Date.now()));
429
+ mkdirSync(attachmentDir, { recursive: true });
430
+ const attachmentLines = [];
431
+ for (const att of queuedPrompt.attachments) {
432
+ const safeName = att.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
433
+ const filePath = path.join(attachmentDir, safeName);
434
+ const buf = Buffer.from(att.content, 'base64');
435
+ writeFileSync(filePath, buf);
436
+ attachmentLines.push(`- ${safeName} (${att.mimeType}): path="${filePath}"`);
437
+ }
438
+ effectivePrompt += `\n\n[Attachments]\nThe following files are attached and available on disk. Use the analyze_image tool to examine images.\n${attachmentLines.join('\n')}`;
439
+ console.log(`[agentSession] Wrote ${queuedPrompt.attachments.length} attachment(s) to ${attachmentDir}`);
440
+ }
345
441
  try {
346
442
  // Verify promptId is present in handler
347
443
  if (!promptId) {
348
- console.error(`[agentSession] Missing promptId in handler for prompt: ${prompt.substring(0, 50)}...`);
444
+ console.error(`[agentSession] Missing promptId in handler for prompt: ${queuedPrompt.prompt.substring(0, 50)}...`);
349
445
  onError?.('Missing promptId in handler');
350
446
  continue;
351
447
  }
@@ -375,11 +471,11 @@ export class AgentSessionManager {
375
471
  }
376
472
  session.activeQueryStream = undefined;
377
473
  // Build final prompt with enhancers
378
- let finalPrompt = prompt;
474
+ let finalPrompt = effectivePrompt;
379
475
  if (enhancers && enhancers.length > 0) {
380
476
  const skillContent = getSkillContent(enhancers);
381
477
  if (skillContent) {
382
- finalPrompt = `${skillContent}\n\n${prompt}`;
478
+ finalPrompt = `${skillContent}\n\n${effectivePrompt}`;
383
479
  }
384
480
  }
385
481
  // Add user message to history
@@ -418,25 +514,16 @@ export class AgentSessionManager {
418
514
  apiKey: CLAUDE_CONFIG.apiKey,
419
515
  model: CLAUDE_CONFIG.model,
420
516
  tools: { type: 'preset', preset: 'claude_code' },
421
- disallowedTools: ['AskUserQuestion'],
517
+ disallowedTools: attachmentDir
518
+ ? ['AskUserQuestion', 'analyze_image'] // Disable built-in analyze_image when we have our own
519
+ : ['AskUserQuestion'],
422
520
  settingSources: ['project'], // Enable CLAUDE.md loading
423
521
  permissionMode: 'bypassPermissions',
424
522
  allowDangerouslySkipPermissions: true,
425
523
  // Create a fresh MCP server for each query call (SDK connects transport internally, cannot reuse)
426
524
  ...(() => {
427
- const mcpServer = this.buildMcpServer(sessionId);
428
- if (mcpServer) {
429
- const toolHint = getModuleToolHint(session.enabledModules || []);
430
- console.log(`[agentSession] MCP server created: name=${mcpServer.name}, hasHint=${!!toolHint}`);
431
- console.log(`[agentSession] MCP server keys:`, Object.keys(mcpServer));
432
- console.log(`[agentSession] MCP server type:`, mcpServer.type);
433
- return {
434
- mcpServers: { [mcpServer.name]: mcpServer },
435
- ...(toolHint ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: toolHint } } : {})
436
- };
437
- }
438
- console.log(`[agentSession] No MCP server created (enabledModules=${JSON.stringify(session.enabledModules)})`);
439
- return {};
525
+ const mcpServer = this.buildMcpServer(sessionId, attachmentDir);
526
+ return mcpServer ? { mcpServers: [mcpServer] } : {};
440
527
  })(),
441
528
  ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
442
529
  spawnClaudeCodeProcess: (spawnOptions) => {
@@ -582,29 +669,45 @@ export class AgentSessionManager {
582
669
  console.log(`[agentSession] effectiveSettings for local model:`, JSON.stringify(effectiveSettings));
583
670
  }
584
671
  else {
585
- // Even for non-local models, ensure ~/.claude/settings.json env doesn't override our baseUrl
586
- const aiConfig = loadAiConfig();
587
- if (aiConfig.baseUrl || aiConfig.apiKey) {
588
- const settingsEnv = {};
589
- if (aiConfig.apiKey)
590
- settingsEnv.ANTHROPIC_API_KEY = aiConfig.apiKey;
591
- if (aiConfig.baseUrl)
592
- settingsEnv.ANTHROPIC_BASE_URL = aiConfig.baseUrl;
593
- effectiveSettings = { env: settingsEnv };
672
+ // Resolve provider for multi-provider switching (Z.ai / MiniMax)
673
+ const resolved = resolveProvider(sessionModel);
674
+ console.log(`[agentSession] Resolved provider: ${resolved.provider} for model: ${sessionModel}`);
675
+ effectiveApiKey = resolved.apiKey;
676
+ effectiveEnv = envForClaudeCodeChild(undefined, resolved);
677
+ // Build settings env to prevent ~/.claude/settings.json from overriding our credentials
678
+ const settingsEnv = {
679
+ ANTHROPIC_API_KEY: resolved.apiKey,
680
+ ANTHROPIC_BASE_URL: resolved.baseUrl,
681
+ };
682
+ // For MiniMax: also override all model aliases in settings
683
+ if (resolved.provider === 'minimax') {
684
+ settingsEnv.ANTHROPIC_MODEL = resolved.model;
685
+ settingsEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = resolved.model;
686
+ settingsEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = resolved.model;
687
+ settingsEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolved.model;
688
+ settingsEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
594
689
  }
690
+ effectiveSettings = { env: settingsEnv };
691
+ console.log(`[agentSession] Provider: ${resolved.provider}, baseUrl: ${resolved.baseUrl}, model: ${resolved.model}`);
595
692
  }
596
693
  // Create query stream - resume session if we have a Claude session ID
597
694
  // Always explicitly set model even when resuming to ensure we use the session's model
598
695
  const queryStream = query({
599
- prompt,
696
+ prompt: finalPrompt,
600
697
  options: {
601
698
  ...queryOptions,
602
699
  apiKey: effectiveApiKey,
603
700
  model: effectiveModel,
604
701
  env: effectiveEnv,
605
702
  ...(effectiveSettings ? { settings: effectiveSettings } : {}),
606
- ...(session.claudeSessionId && !localOverrides ? { resume: session.claudeSessionId } : {})
703
+ ...(session.claudeSessionId && !localOverrides && (() => {
704
+ // Don't resume if provider changed since last session (context format may differ)
705
+ const persisted = loadSessionState(sessionId);
706
+ const currentProvider = resolveProvider(sessionModel).provider;
707
+ return persisted?.provider === currentProvider;
708
+ })() ? { resume: session.claudeSessionId } : {})
607
709
  // Note: don't resume session for local models - context format differs
710
+ // Note: also don't resume if provider differs from persisted session provider (context format may differ)
608
711
  }
609
712
  });
610
713
  session.activeQueryStream = queryStream;
@@ -633,7 +736,7 @@ export class AgentSessionManager {
633
736
  const systemMsg = message;
634
737
  if (systemMsg.session_id && !session.claudeSessionId) {
635
738
  session.claudeSessionId = systemMsg.session_id;
636
- saveSessionState(sessionId, { claudeSessionId: systemMsg.session_id, model: session.model, updatedAt: Date.now() });
739
+ saveSessionState(sessionId, { claudeSessionId: systemMsg.session_id, model: session.model, provider: resolveProvider(session.model).provider, updatedAt: Date.now() });
637
740
  }
638
741
  }
639
742
  if (message.type === 'assistant') {
@@ -641,7 +744,7 @@ export class AgentSessionManager {
641
744
  // Capture Claude session ID from assistant message if not already set
642
745
  if (msg.session_id && !session.claudeSessionId) {
643
746
  session.claudeSessionId = msg.session_id;
644
- saveSessionState(sessionId, { claudeSessionId: msg.session_id, model: session.model, updatedAt: Date.now() });
747
+ saveSessionState(sessionId, { claudeSessionId: msg.session_id, model: session.model, provider: resolveProvider(session.model).provider, updatedAt: Date.now() });
645
748
  }
646
749
  session.messages.push({
647
750
  role: 'assistant',
@@ -1144,22 +1247,23 @@ export class AgentSessionManager {
1144
1247
  * The SDK's query() connects the MCP server's internal transport, so we cannot
1145
1248
  * reuse a single instance across multiple queries. This must be called fresh each time.
1146
1249
  */
1147
- buildMcpServer(sessionId) {
1250
+ buildMcpServer(sessionId, attachmentDir) {
1148
1251
  const session = this.sessions.get(sessionId);
1149
1252
  if (!session) {
1150
1253
  console.log(`[buildMcpServer] No session found for ${sessionId}`);
1151
1254
  return undefined;
1152
1255
  }
1153
1256
  const enabledModules = session.enabledModules || [];
1154
- console.log(`[buildMcpServer] Session ${sessionId}: enabledModules=${JSON.stringify(enabledModules)}`);
1155
- if (enabledModules.length === 0) {
1156
- console.log(`[buildMcpServer] No enabled modules, skipping MCP server creation`);
1257
+ console.log(`[buildMcpServer] Session ${sessionId}: enabledModules=${JSON.stringify(enabledModules)}, attachmentDir=${attachmentDir || 'none'}`);
1258
+ if (enabledModules.length === 0 && !attachmentDir) {
1259
+ console.log(`[buildMcpServer] No enabled modules and no attachments, skipping MCP server creation`);
1157
1260
  return undefined;
1158
1261
  }
1159
1262
  const handler = this.sessionHandlers.get(sessionId);
1160
1263
  return createModuleMcpServer({
1161
1264
  enabledModules,
1162
1265
  moduleSettings: session.moduleSettings || {},
1266
+ attachmentDir,
1163
1267
  onChoiceRequest: handler?.onChoiceRequest
1164
1268
  ? async (request) => {
1165
1269
  return new Promise((resolve) => {