@exreve/exk 1.0.26 → 1.0.27

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,43 @@ 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: process.env.MINIMAX_API_KEY || '',
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. If explicit providerId is given and has an API key configured, use that provider.
144
+ * 2. Else if model name matches one of a provider's model list, use that provider.
145
+ * 3. Else fall back to zai (default). */
146
+ function resolveProvider(model, providerId) {
147
+ // 1. Explicit provider selection
148
+ if (providerId && PROVIDERS[providerId]?.apiKey) {
149
+ const provider = PROVIDERS[providerId];
150
+ return { provider: providerId, apiKey: provider.apiKey, baseUrl: provider.baseUrl, model };
151
+ }
152
+ // 2. Match model name to a provider
153
+ for (const [id, config] of Object.entries(PROVIDERS)) {
154
+ if (config.models.includes(model) && config.apiKey) {
155
+ return { provider: id, apiKey: config.apiKey, baseUrl: config.baseUrl, model };
156
+ }
157
+ }
158
+ // 3. Fallback: use ai-config.json credentials (z.ai default)
159
+ const aiConfig = loadAiConfig();
160
+ return {
161
+ provider: 'zai',
162
+ apiKey: aiConfig.apiKey,
163
+ baseUrl: aiConfig.baseUrl || PROVIDERS.zai.baseUrl,
164
+ model,
165
+ };
166
+ }
130
167
  function loadAiConfig() {
131
168
  try {
132
169
  const data = readFileSync(AI_CONFIG_PATH, 'utf-8');
@@ -134,10 +171,11 @@ function loadAiConfig() {
134
171
  const apiKey = typeof config.authToken === 'string' ? config.authToken.trim() : '';
135
172
  const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : '';
136
173
  const model = typeof config.model === 'string' && config.model.trim() ? config.model.trim() : DEFAULT_AI_MODEL;
137
- return { apiKey, baseUrl, model };
174
+ const proxy = typeof config.proxy === 'string' ? config.proxy.trim() : '';
175
+ return { apiKey, baseUrl, model, proxy };
138
176
  }
139
177
  catch {
140
- return { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL };
178
+ return { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL, proxy: '' };
141
179
  }
142
180
  }
143
181
  /** Create (or reuse) an empty directory to use as CLAUDE_CONFIG_DIR.
@@ -151,32 +189,66 @@ function getEmptyConfigDir() {
151
189
  }
152
190
  return configDir;
153
191
  }
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) {
192
+ const PROXY_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'proxy.json');
193
+ /** Read proxy toggle state from disk (synchronous) */
194
+ function readProxyToggle() {
195
+ try {
196
+ const data = readFileSync(PROXY_CONFIG_PATH, 'utf-8');
197
+ return JSON.parse(data);
198
+ }
199
+ catch {
200
+ return { enabled: false };
201
+ }
202
+ }
203
+ /** Env for the Claude Code child: copy of host env with host ANTHROPIC_* stripped, then inject from provider or ai-config.
204
+ * If a local model is provided, override baseUrl to point to the anthropic-proxy adapter.
205
+ * If resolvedProvider is provided, use its credentials instead of ai-config defaults. */
206
+ function envForClaudeCodeChild(localModel, resolvedProvider) {
157
207
  const env = { ...process.env };
158
208
  // Strip any host ANTHROPIC_* vars to prevent leaking credentials or wrong URLs
159
209
  delete env.ANTHROPIC_API_KEY;
160
210
  delete env.ANTHROPIC_BASE_URL;
161
211
  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;
212
+ // Also strip model alias env vars to prevent stale overrides
213
+ delete env.ANTHROPIC_MODEL;
214
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
215
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
216
+ delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
217
+ // Determine credentials: use resolvedProvider if provided, else ai-config defaults
218
+ const { apiKey, baseUrl, proxy } = loadAiConfig();
219
+ const effectiveApiKey = resolvedProvider?.apiKey || apiKey;
220
+ const effectiveBaseUrl = resolvedProvider?.baseUrl || baseUrl;
221
+ if (effectiveApiKey)
222
+ env.ANTHROPIC_API_KEY = effectiveApiKey;
223
+ if (effectiveBaseUrl)
224
+ env.ANTHROPIC_BASE_URL = effectiveBaseUrl;
225
+ // For MiniMax specifically: override ALL model aliases so the SDK
226
+ // sends the correct model ID to the Anthropic-compatible endpoint
227
+ if (resolvedProvider?.provider === 'minimax') {
228
+ env.ANTHROPIC_MODEL = resolvedProvider.model;
229
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = resolvedProvider.model;
230
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = resolvedProvider.model;
231
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolvedProvider.model;
232
+ env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
233
+ }
234
+ // Apply proxy if enabled
235
+ const proxyToggle = readProxyToggle();
236
+ if (proxyToggle.enabled && proxy) {
237
+ env.HTTPS_PROXY = proxy;
238
+ env.HTTP_PROXY = proxy;
239
+ }
240
+ else {
241
+ // Clear any inherited proxy env
242
+ delete env.HTTPS_PROXY;
243
+ delete env.HTTP_PROXY;
244
+ delete env.https_proxy;
245
+ delete env.http_proxy;
246
+ }
173
247
  // Prevent ~/.claude/settings.json env section from overriding our base URL.
174
248
  // This redirects the Claude config dir to an empty dir so that
175
249
  // ~/.claude/settings.json (which may have ANTHROPIC_BASE_URL set to z.ai)
176
250
  // is never read during the CLI process initialization.
177
251
  env.CLAUDE_CONFIG_DIR = getEmptyConfigDir();
178
- // Allow Claude to run as root (e.g. inside Docker containers)
179
- env.IS_SANDBOX = '1';
180
252
  return env;
181
253
  }
182
254
  /** Get env overrides for a local model (adapter proxy URL + dummy key). Returns null if not a local model. */
@@ -316,7 +388,8 @@ export class AgentSessionManager {
316
388
  timestamp: Date.now(),
317
389
  promptId: handler.promptId,
318
390
  abortController: new AbortController(), // Pre-create for queued cancellation
319
- model: handler.model || session.model // Use handler model or fall back to session model
391
+ model: handler.model || session.model, // Use handler model or fall back to session model
392
+ attachments: handler.attachments // Pass attachments through
320
393
  });
321
394
  // Start processing queue if not already processing
322
395
  if (!session.isProcessingQueue) {
@@ -339,13 +412,30 @@ export class AgentSessionManager {
339
412
  session.isProcessingQueue = true;
340
413
  while (session.promptQueue.length > 0 && !this.emergencyStopInProgress.has(sessionId)) {
341
414
  const queuedPrompt = session.promptQueue.shift();
342
- const { prompt, enhancers, handler, promptId: queuedPromptId, abortController: queuedAbortController } = queuedPrompt;
415
+ const { enhancers, handler, promptId: queuedPromptId, abortController: queuedAbortController } = queuedPrompt;
343
416
  const { projectPath, promptId, onOutput, onError, onComplete, onStatusUpdate } = handler;
344
417
  const promptStartTime = Date.now();
418
+ // Write attachments to temp dir and inject paths into prompt
419
+ let effectivePrompt = queuedPrompt.prompt;
420
+ let attachmentDir;
421
+ if (queuedPrompt.attachments && queuedPrompt.attachments.length > 0) {
422
+ attachmentDir = path.join(os.tmpdir(), 'talk-to-code', 'attachments', sessionId, String(promptId || Date.now()));
423
+ mkdirSync(attachmentDir, { recursive: true });
424
+ const attachmentLines = [];
425
+ for (const att of queuedPrompt.attachments) {
426
+ const safeName = att.filename.replace(/[^a-zA-Z0-9._-]/g, '_');
427
+ const filePath = path.join(attachmentDir, safeName);
428
+ const buf = Buffer.from(att.content, 'base64');
429
+ writeFileSync(filePath, buf);
430
+ attachmentLines.push(`- ${safeName} (${att.mimeType}): path="${filePath}"`);
431
+ }
432
+ 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')}`;
433
+ console.log(`[agentSession] Wrote ${queuedPrompt.attachments.length} attachment(s) to ${attachmentDir}`);
434
+ }
345
435
  try {
346
436
  // Verify promptId is present in handler
347
437
  if (!promptId) {
348
- console.error(`[agentSession] Missing promptId in handler for prompt: ${prompt.substring(0, 50)}...`);
438
+ console.error(`[agentSession] Missing promptId in handler for prompt: ${queuedPrompt.prompt.substring(0, 50)}...`);
349
439
  onError?.('Missing promptId in handler');
350
440
  continue;
351
441
  }
@@ -375,11 +465,11 @@ export class AgentSessionManager {
375
465
  }
376
466
  session.activeQueryStream = undefined;
377
467
  // Build final prompt with enhancers
378
- let finalPrompt = prompt;
468
+ let finalPrompt = effectivePrompt;
379
469
  if (enhancers && enhancers.length > 0) {
380
470
  const skillContent = getSkillContent(enhancers);
381
471
  if (skillContent) {
382
- finalPrompt = `${skillContent}\n\n${prompt}`;
472
+ finalPrompt = `${skillContent}\n\n${effectivePrompt}`;
383
473
  }
384
474
  }
385
475
  // Add user message to history
@@ -418,25 +508,16 @@ export class AgentSessionManager {
418
508
  apiKey: CLAUDE_CONFIG.apiKey,
419
509
  model: CLAUDE_CONFIG.model,
420
510
  tools: { type: 'preset', preset: 'claude_code' },
421
- disallowedTools: ['AskUserQuestion'],
511
+ disallowedTools: attachmentDir
512
+ ? ['AskUserQuestion', 'analyze_image'] // Disable built-in analyze_image when we have our own
513
+ : ['AskUserQuestion'],
422
514
  settingSources: ['project'], // Enable CLAUDE.md loading
423
515
  permissionMode: 'bypassPermissions',
424
516
  allowDangerouslySkipPermissions: true,
425
517
  // Create a fresh MCP server for each query call (SDK connects transport internally, cannot reuse)
426
518
  ...(() => {
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 {};
519
+ const mcpServer = this.buildMcpServer(sessionId, attachmentDir);
520
+ return mcpServer ? { mcpServers: [mcpServer] } : {};
440
521
  })(),
441
522
  ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
442
523
  spawnClaudeCodeProcess: (spawnOptions) => {
@@ -582,29 +663,45 @@ export class AgentSessionManager {
582
663
  console.log(`[agentSession] effectiveSettings for local model:`, JSON.stringify(effectiveSettings));
583
664
  }
584
665
  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 };
666
+ // Resolve provider for multi-provider switching (Z.ai / MiniMax)
667
+ const resolved = resolveProvider(sessionModel);
668
+ console.log(`[agentSession] Resolved provider: ${resolved.provider} for model: ${sessionModel}`);
669
+ effectiveApiKey = resolved.apiKey;
670
+ effectiveEnv = envForClaudeCodeChild(undefined, resolved);
671
+ // Build settings env to prevent ~/.claude/settings.json from overriding our credentials
672
+ const settingsEnv = {
673
+ ANTHROPIC_API_KEY: resolved.apiKey,
674
+ ANTHROPIC_BASE_URL: resolved.baseUrl,
675
+ };
676
+ // For MiniMax: also override all model aliases in settings
677
+ if (resolved.provider === 'minimax') {
678
+ settingsEnv.ANTHROPIC_MODEL = resolved.model;
679
+ settingsEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = resolved.model;
680
+ settingsEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = resolved.model;
681
+ settingsEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolved.model;
682
+ settingsEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
594
683
  }
684
+ effectiveSettings = { env: settingsEnv };
685
+ console.log(`[agentSession] Provider: ${resolved.provider}, baseUrl: ${resolved.baseUrl}, model: ${resolved.model}`);
595
686
  }
596
687
  // Create query stream - resume session if we have a Claude session ID
597
688
  // Always explicitly set model even when resuming to ensure we use the session's model
598
689
  const queryStream = query({
599
- prompt,
690
+ prompt: finalPrompt,
600
691
  options: {
601
692
  ...queryOptions,
602
693
  apiKey: effectiveApiKey,
603
694
  model: effectiveModel,
604
695
  env: effectiveEnv,
605
696
  ...(effectiveSettings ? { settings: effectiveSettings } : {}),
606
- ...(session.claudeSessionId && !localOverrides ? { resume: session.claudeSessionId } : {})
697
+ ...(session.claudeSessionId && !localOverrides && (() => {
698
+ // Don't resume if provider changed since last session (context format may differ)
699
+ const persisted = loadSessionState(sessionId);
700
+ const currentProvider = resolveProvider(sessionModel).provider;
701
+ return persisted?.provider === currentProvider;
702
+ })() ? { resume: session.claudeSessionId } : {})
607
703
  // Note: don't resume session for local models - context format differs
704
+ // Note: also don't resume if provider differs from persisted session provider (context format may differ)
608
705
  }
609
706
  });
610
707
  session.activeQueryStream = queryStream;
@@ -633,7 +730,7 @@ export class AgentSessionManager {
633
730
  const systemMsg = message;
634
731
  if (systemMsg.session_id && !session.claudeSessionId) {
635
732
  session.claudeSessionId = systemMsg.session_id;
636
- saveSessionState(sessionId, { claudeSessionId: systemMsg.session_id, model: session.model, updatedAt: Date.now() });
733
+ saveSessionState(sessionId, { claudeSessionId: systemMsg.session_id, model: session.model, provider: resolveProvider(session.model).provider, updatedAt: Date.now() });
637
734
  }
638
735
  }
639
736
  if (message.type === 'assistant') {
@@ -641,7 +738,7 @@ export class AgentSessionManager {
641
738
  // Capture Claude session ID from assistant message if not already set
642
739
  if (msg.session_id && !session.claudeSessionId) {
643
740
  session.claudeSessionId = msg.session_id;
644
- saveSessionState(sessionId, { claudeSessionId: msg.session_id, model: session.model, updatedAt: Date.now() });
741
+ saveSessionState(sessionId, { claudeSessionId: msg.session_id, model: session.model, provider: resolveProvider(session.model).provider, updatedAt: Date.now() });
645
742
  }
646
743
  session.messages.push({
647
744
  role: 'assistant',
@@ -1144,22 +1241,23 @@ export class AgentSessionManager {
1144
1241
  * The SDK's query() connects the MCP server's internal transport, so we cannot
1145
1242
  * reuse a single instance across multiple queries. This must be called fresh each time.
1146
1243
  */
1147
- buildMcpServer(sessionId) {
1244
+ buildMcpServer(sessionId, attachmentDir) {
1148
1245
  const session = this.sessions.get(sessionId);
1149
1246
  if (!session) {
1150
1247
  console.log(`[buildMcpServer] No session found for ${sessionId}`);
1151
1248
  return undefined;
1152
1249
  }
1153
1250
  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`);
1251
+ console.log(`[buildMcpServer] Session ${sessionId}: enabledModules=${JSON.stringify(enabledModules)}, attachmentDir=${attachmentDir || 'none'}`);
1252
+ if (enabledModules.length === 0 && !attachmentDir) {
1253
+ console.log(`[buildMcpServer] No enabled modules and no attachments, skipping MCP server creation`);
1157
1254
  return undefined;
1158
1255
  }
1159
1256
  const handler = this.sessionHandlers.get(sessionId);
1160
1257
  return createModuleMcpServer({
1161
1258
  enabledModules,
1162
1259
  moduleSettings: session.moduleSettings || {},
1260
+ attachmentDir,
1163
1261
  onChoiceRequest: handler?.onChoiceRequest
1164
1262
  ? async (request) => {
1165
1263
  return new Promise((resolve) => {