@wu529778790/open-im 1.9.4-beta.1 → 1.9.4-beta.11

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.
Files changed (76) hide show
  1. package/dist/access/access-control.js +1 -1
  2. package/dist/adapters/claude-sdk-adapter.js +51 -30
  3. package/dist/adapters/claude-sdk-adapter.test.js +50 -0
  4. package/dist/commands/handler.d.ts +1 -1
  5. package/dist/commands/handler.js +2 -2
  6. package/dist/config/credentials.d.ts +27 -0
  7. package/dist/config/credentials.js +85 -0
  8. package/dist/config/file-io.d.ts +11 -0
  9. package/dist/config/file-io.js +176 -0
  10. package/dist/config/types.d.ts +177 -0
  11. package/dist/config/types.js +1 -0
  12. package/dist/config.d.ts +3 -185
  13. package/dist/config.js +9 -194
  14. package/dist/dingtalk/api.d.ts +21 -0
  15. package/dist/dingtalk/api.js +115 -0
  16. package/dist/dingtalk/client.d.ts +10 -23
  17. package/dist/dingtalk/client.js +57 -607
  18. package/dist/dingtalk/event-handler.d.ts +1 -0
  19. package/dist/dingtalk/event-handler.js +50 -67
  20. package/dist/dingtalk/message-sender.js +1 -2
  21. package/dist/dingtalk/streaming-card.d.ts +20 -0
  22. package/dist/dingtalk/streaming-card.js +300 -0
  23. package/dist/dingtalk/webhook.d.ts +12 -0
  24. package/dist/dingtalk/webhook.js +223 -0
  25. package/dist/feishu/cardkit-manager.js +24 -1
  26. package/dist/feishu/event-handler.d.ts +1 -0
  27. package/dist/feishu/event-handler.js +160 -270
  28. package/dist/index.js +99 -106
  29. package/dist/platform/create-event-context.d.ts +28 -0
  30. package/dist/platform/create-event-context.js +30 -0
  31. package/dist/platform/create-event-context.test.d.ts +1 -0
  32. package/dist/platform/create-event-context.test.js +91 -0
  33. package/dist/platform/handle-ai-request.d.ts +108 -0
  34. package/dist/platform/handle-ai-request.js +169 -0
  35. package/dist/platform/handle-ai-request.test.d.ts +1 -0
  36. package/dist/platform/handle-ai-request.test.js +349 -0
  37. package/dist/platform/handle-text-flow.d.ts +79 -0
  38. package/dist/platform/handle-text-flow.js +114 -0
  39. package/dist/platform/handle-text-flow.test.d.ts +1 -0
  40. package/dist/platform/handle-text-flow.test.js +237 -0
  41. package/dist/qq/client.js +13 -0
  42. package/dist/qq/event-handler.d.ts +1 -0
  43. package/dist/qq/event-handler.js +137 -106
  44. package/dist/queue/request-queue.d.ts +1 -1
  45. package/dist/queue/request-queue.js +14 -1
  46. package/dist/queue/request-queue.test.d.ts +1 -0
  47. package/dist/queue/request-queue.test.js +114 -0
  48. package/dist/session/session-manager.js +8 -4
  49. package/dist/session/session-manager.test.js +92 -4
  50. package/dist/setup.js +9 -1
  51. package/dist/shared/ai-task.d.ts +2 -0
  52. package/dist/shared/ai-task.js +12 -2
  53. package/dist/shared/ai-task.test.js +127 -0
  54. package/dist/shared/task-cleanup.d.ts +8 -0
  55. package/dist/shared/task-cleanup.js +30 -0
  56. package/dist/shared/task-cleanup.test.d.ts +1 -0
  57. package/dist/shared/task-cleanup.test.js +74 -0
  58. package/dist/telegram/event-handler.d.ts +1 -0
  59. package/dist/telegram/event-handler.js +162 -189
  60. package/dist/wework/event-handler.d.ts +2 -1
  61. package/dist/wework/event-handler.js +87 -95
  62. package/dist/wework/message-sender.js +0 -3
  63. package/dist/workbuddy/centrifuge-client.d.ts +13 -11
  64. package/dist/workbuddy/centrifuge-client.js +132 -115
  65. package/dist/workbuddy/client.js +10 -1
  66. package/dist/workbuddy/event-handler.d.ts +2 -1
  67. package/dist/workbuddy/event-handler.js +109 -64
  68. package/dist/workbuddy/message-sender.d.ts +0 -5
  69. package/dist/workbuddy/message-sender.js +0 -17
  70. package/dist/workbuddy/oauth.js +0 -2
  71. package/dist/workbuddy/types.d.ts +1 -1
  72. package/package.json +2 -5
  73. package/dist/qq/event-handler.test.js +0 -68
  74. package/dist/shared/retry.d.ts +0 -10
  75. package/dist/shared/retry.js +0 -26
  76. /package/dist/{qq/event-handler.test.d.ts → adapters/claude-sdk-adapter.test.d.ts} +0 -0
@@ -8,7 +8,7 @@ export class AccessControl {
8
8
  }
9
9
  isAllowed(userId) {
10
10
  if (this.allowedUserIds.size === 0) {
11
- log.warn(`Allowing user ${userId} no whitelist configured. Set allowedUserIds to restrict access.`);
11
+ log.warn(`SECURITY: Allowing user ${userId} -- no whitelist configured. Set allowedUserIds in config or OPEN_IM_ALLOWED_USER_IDS env var to restrict access.`);
12
12
  return true;
13
13
  }
14
14
  const allowed = this.allowedUserIds.has(userId);
@@ -9,8 +9,35 @@
9
9
  * 认证:ANTHROPIC_API_KEY 或 CLAUDE_CODE_OAUTH_TOKEN
10
10
  */
11
11
  import { unstable_v2_createSession, unstable_v2_resumeSession } from '@anthropic-ai/claude-agent-sdk';
12
+ import { existsSync, readFileSync } from 'fs';
13
+ import { homedir } from 'os';
14
+ import { join } from 'path';
12
15
  import { createLogger } from '../logger.js';
13
16
  const log = createLogger('ClaudeSDK');
17
+ function loadUserPluginSettings() {
18
+ try {
19
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
20
+ if (!existsSync(settingsPath))
21
+ return null;
22
+ const content = readFileSync(settingsPath, 'utf-8');
23
+ const settings = JSON.parse(content);
24
+ const result = {};
25
+ if (settings.enabledPlugins)
26
+ result.enabledPlugins = settings.enabledPlugins;
27
+ if (settings.extraKnownMarketplaces)
28
+ result.extraKnownMarketplaces = settings.extraKnownMarketplaces;
29
+ if (Object.keys(result).length === 0)
30
+ return null;
31
+ log.info(`Loaded user plugin settings: plugins=[${Object.keys(result.enabledPlugins ?? {}).join(', ')}]`);
32
+ return result;
33
+ }
34
+ catch (err) {
35
+ log.warn('Failed to read ~/.claude/settings.json for plugin config:', err);
36
+ return null;
37
+ }
38
+ }
39
+ // Pre-load user plugin settings to cache Claude Code user preferences
40
+ loadUserPluginSettings();
14
41
  // 存储所有活跃的 SDKSession 对象,key 为 sessionId
15
42
  // 使用 Map 而不是 Set,因为我们需要通过 sessionId 获取 session
16
43
  const activeSessions = new Map();
@@ -22,6 +49,8 @@ const sessionLastUsed = new Map();
22
49
  const runningSessions = new Set();
23
50
  const SESSION_IDLE_TTL_MS = 30 * 60 * 1000; // 30 分钟未使用则清理
24
51
  const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 每 5 分钟检查一次
52
+ const MAX_ACTIVE_SESSIONS = 100;
53
+ let sessionSeq = 0;
25
54
  const cleanupInterval = setInterval(() => {
26
55
  const now = Date.now();
27
56
  for (const [id, lastUsed] of sessionLastUsed) {
@@ -42,33 +71,17 @@ const cleanupInterval = setInterval(() => {
42
71
  }
43
72
  }, CLEANUP_INTERVAL_MS);
44
73
  cleanupInterval.unref(); // 不阻止进程退出
45
- // Lazy cleanup: check idle sessions periodically during getOrCreateSession calls
46
- let lazyCleanupCounter = 0;
47
- const LAZY_CLEANUP_INTERVAL = 10;
48
- let sessionSeq = 0;
49
- function lazyCleanupIdleSessions() {
50
- lazyCleanupCounter++;
51
- if (lazyCleanupCounter % LAZY_CLEANUP_INTERVAL !== 0)
52
- return;
53
- const now = Date.now();
54
- for (const [id, lastUsed] of sessionLastUsed) {
55
- if (runningSessions.has(id))
56
- continue; // 跳过正在运行任务的 session
57
- if (now - lastUsed > SESSION_IDLE_TTL_MS) {
58
- const s = activeSessions.get(id);
59
- if (s) {
60
- try {
61
- s.close();
62
- }
63
- catch { /* ignore */ }
64
- activeSessions.delete(id);
65
- }
66
- sessionLastUsed.delete(id);
67
- log.info(`Lazy cleanup: idle session ${id} (unused ${Math.round((now - lastUsed) / 60000)}min)`);
68
- }
69
- }
70
- }
71
- // Mutex to serialize process.chdir() calls across concurrent users
74
+ /**
75
+ * Serializes process.chdir() calls across concurrent users.
76
+ *
77
+ * process.chdir() is a process-wide global side effect — only one chdir can
78
+ * be active at a time. The SDK's createSession/resumeSession do not accept a
79
+ * `cwd` parameter, so we must chdir before calling them. This mutex ensures
80
+ * concurrent requests don't race on the working directory.
81
+ *
82
+ * **Limitation:** If the SDK ever supports a `cwd` option, this mutex should
83
+ * be removed entirely.
84
+ */
72
85
  let chdirMutex = Promise.resolve();
73
86
  function withChdirMutex(fn) {
74
87
  const previous = chdirMutex;
@@ -108,8 +121,10 @@ function isSessionCorruptionError(msg) {
108
121
  * @returns SDKSession 对象和实际的 sessionId
109
122
  */
110
123
  async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
111
- lazyCleanupIdleSessions();
112
124
  const resolvedModel = model?.trim() || 'claude-opus-4-5';
125
+ if (activeSessions.size >= MAX_ACTIVE_SESSIONS) {
126
+ throw new Error(`Session pool is full (${MAX_ACTIVE_SESSIONS}). Cannot create new session.`);
127
+ }
113
128
  const sessionOptions = {
114
129
  model: resolvedModel,
115
130
  permissionMode,
@@ -156,7 +171,7 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
156
171
  activeSessions.set(tempId, session);
157
172
  sessionLastUsed.set(tempId, Date.now());
158
173
  log.info(`Created new session (tempId: ${tempId})`);
159
- return { session, sessionId: tempId };
174
+ return { session, sessionId: tempId, wasReused: false };
160
175
  }
161
176
  finally {
162
177
  if (workDir && workDir !== originalCwd) {
@@ -260,7 +275,13 @@ export class ClaudeSDKAdapter {
260
275
  }
261
276
  // 获取实际的 sessionId(从 init 消息中)
262
277
  if (isSystemInit(msg)) {
263
- const newSessionId = msg.session_id;
278
+ const initMsg = msg;
279
+ // 记录 session 加载的插件和技能
280
+ const pluginNames = initMsg.plugins?.map(p => p.name).join(', ') ?? 'none';
281
+ const skillCount = initMsg.skills?.length ?? 0;
282
+ const toolCount = initMsg.tools?.length ?? 0;
283
+ log.info(`[V2] Init: plugins=[${pluginNames}], skills=${skillCount}, tools=${toolCount}`);
284
+ const newSessionId = initMsg.session_id;
264
285
  if (newSessionId && newSessionId !== actualSessionId) {
265
286
  // 更新 sessionId 映射
266
287
  // 清理 pending 临时 ID(actualSessionId 尚未赋值时用 pendingTempId)
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ // Mock the logger before importing the adapter under test
3
+ vi.mock('../logger.js', () => ({
4
+ createLogger: () => ({
5
+ info: vi.fn(),
6
+ warn: vi.fn(),
7
+ error: vi.fn(),
8
+ debug: vi.fn(),
9
+ }),
10
+ }));
11
+ // Mock the Claude Agent SDK
12
+ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
13
+ unstable_v2_createSession: vi.fn(),
14
+ unstable_v2_resumeSession: vi.fn(),
15
+ }));
16
+ // Import after mocks are set up
17
+ import { ClaudeSDKAdapter } from './claude-sdk-adapter.js';
18
+ describe('ClaudeSDKAdapter', () => {
19
+ let adapter;
20
+ beforeEach(() => {
21
+ adapter = new ClaudeSDKAdapter();
22
+ });
23
+ afterEach(() => {
24
+ // Clean up any active sessions/timers created during tests
25
+ ClaudeSDKAdapter.destroy();
26
+ });
27
+ it('implements the ToolAdapter interface', () => {
28
+ expect(adapter).toBeDefined();
29
+ expect(typeof adapter.toolId).toBe('string');
30
+ expect(typeof adapter.run).toBe('function');
31
+ });
32
+ it('has toolId set to claude-sdk', () => {
33
+ expect(adapter.toolId).toBe('claude-sdk');
34
+ });
35
+ it('has a run method that returns a RunHandle', () => {
36
+ const callbacks = {
37
+ onText: vi.fn(),
38
+ onComplete: vi.fn(),
39
+ onError: vi.fn(),
40
+ };
41
+ const handle = adapter.run('test prompt', undefined, '/tmp', callbacks);
42
+ expect(handle).toBeDefined();
43
+ expect(typeof handle.abort).toBe('function');
44
+ // Abort to clean up the background promise
45
+ handle.abort();
46
+ });
47
+ it('stop() (static destroy) does not throw', () => {
48
+ expect(() => ClaudeSDKAdapter.destroy()).not.toThrow();
49
+ });
50
+ });
@@ -18,7 +18,7 @@ export type ClaudeRequestHandler = (userId: string, chatId: string, prompt: stri
18
18
  export declare class CommandHandler {
19
19
  private deps;
20
20
  constructor(deps: CommandHandlerDeps);
21
- dispatch(text: string, chatId: string, userId: string, platform: Platform, handleClaudeRequest: ClaudeRequestHandler): Promise<boolean>;
21
+ dispatch(text: string, chatId: string, userId: string, platform: Platform, _handleClaudeRequest: ClaudeRequestHandler): Promise<boolean>;
22
22
  private handleHelp;
23
23
  private handleNew;
24
24
  private handlePwd;
@@ -9,7 +9,7 @@ export class CommandHandler {
9
9
  constructor(deps) {
10
10
  this.deps = deps;
11
11
  }
12
- async dispatch(text, chatId, userId, platform, handleClaudeRequest) {
12
+ async dispatch(text, chatId, userId, platform, _handleClaudeRequest) {
13
13
  const t = text.trim();
14
14
  if (platform === 'telegram' && t === '/start') {
15
15
  await this.deps.sender.sendTextReply(chatId, '欢迎使用 open-im AI CLI 桥接!\n\n发送消息与 AI 交互,输入 /help 查看帮助。');
@@ -75,7 +75,7 @@ export class CommandHandler {
75
75
  await this.deps.sender.sendTextReply(chatId, lines.join('\n'));
76
76
  return true;
77
77
  }
78
- async handleCd(chatId, userId, dir, platform) {
78
+ async handleCd(chatId, userId, dir, _platform) {
79
79
  // 如果 dir 为空,显示目录选择界面
80
80
  if (!dir) {
81
81
  const currentDir = this.deps.sessionManager.getWorkDir(userId);
@@ -0,0 +1,27 @@
1
+ import type { FileConfig } from './types.js';
2
+ /**
3
+ * Resolves a single credential value using the standard priority chain:
4
+ * environment variable → platform file config → legacy file config.
5
+ */
6
+ export declare function resolveCredential(envKey: string, fileValue?: string, legacyFileValue?: string): string | undefined;
7
+ /**
8
+ * Generic per-platform credential resolution.
9
+ * Returns all resolved credentials and whether the platform should be enabled.
10
+ */
11
+ export interface ResolvedPlatform {
12
+ enabled: boolean;
13
+ credentials: Record<string, string | undefined>;
14
+ }
15
+ export declare function resolvePlatformCredentials(envKeys: Record<string, string>, fileValues: Record<string, string | undefined>, legacyValues: Record<string, string | undefined>, requiredKeys: string[], enabledFlag?: boolean): ResolvedPlatform;
16
+ /**
17
+ * Extract WorkBuddy credentials, with legacy platforms.wechat migration support.
18
+ */
19
+ export declare function resolveWorkBuddyFileConfig(fileConfig: FileConfig): NonNullable<FileConfig['platforms']>['workbuddy'] | undefined;
20
+ /**
21
+ * Check if a CLI tool is available at the given path or on PATH.
22
+ */
23
+ export declare function checkCliAvailable(cliPath: string, toolName: string): void;
24
+ /**
25
+ * Resolve Windows-specific CLI path for npm global installs.
26
+ */
27
+ export declare function resolveWindowsCliPath(cliName: string, configuredPath: string): string;
@@ -0,0 +1,85 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join, isAbsolute } from 'node:path';
4
+ /**
5
+ * Resolves a single credential value using the standard priority chain:
6
+ * environment variable → platform file config → legacy file config.
7
+ */
8
+ export function resolveCredential(envKey, fileValue, legacyFileValue) {
9
+ return process.env[envKey] ?? fileValue ?? legacyFileValue;
10
+ }
11
+ export function resolvePlatformCredentials(envKeys, fileValues, legacyValues, requiredKeys, enabledFlag) {
12
+ const credentials = {};
13
+ for (const [name, envKey] of Object.entries(envKeys)) {
14
+ credentials[name] = resolveCredential(envKey, fileValues[name], legacyValues[name]);
15
+ }
16
+ const hasRequired = requiredKeys.every((key) => credentials[key] !== undefined && credentials[key] !== '');
17
+ return {
18
+ enabled: hasRequired && enabledFlag !== false,
19
+ credentials,
20
+ };
21
+ }
22
+ /**
23
+ * Extract WorkBuddy credentials, with legacy platforms.wechat migration support.
24
+ */
25
+ export function resolveWorkBuddyFileConfig(fileConfig) {
26
+ const direct = fileConfig.platforms?.workbuddy;
27
+ if (direct)
28
+ return direct;
29
+ const legacyWechat = fileConfig.platforms?.wechat;
30
+ if (legacyWechat?.workbuddyAccessToken && legacyWechat?.workbuddyRefreshToken) {
31
+ return {
32
+ accessToken: legacyWechat.workbuddyAccessToken,
33
+ refreshToken: legacyWechat.workbuddyRefreshToken,
34
+ userId: legacyWechat.userId,
35
+ baseUrl: legacyWechat.workbuddyBaseUrl,
36
+ };
37
+ }
38
+ return undefined;
39
+ }
40
+ /**
41
+ * Check if a CLI tool is available at the given path or on PATH.
42
+ */
43
+ export function checkCliAvailable(cliPath, toolName) {
44
+ if (isAbsolute(cliPath) || cliPath.includes('/') || cliPath.includes('\\')) {
45
+ try {
46
+ accessSync(cliPath, constants.F_OK);
47
+ }
48
+ catch {
49
+ throw new Error(`${toolName} CLI not found at: ${cliPath}`);
50
+ }
51
+ }
52
+ else {
53
+ const checkCommand = process.platform === 'win32' ? 'where' : 'which';
54
+ try {
55
+ execFileSync(checkCommand, [cliPath], {
56
+ stdio: 'pipe',
57
+ windowsHide: process.platform === 'win32',
58
+ });
59
+ }
60
+ catch {
61
+ throw new Error(`${toolName} CLI not found on PATH: ${cliPath}`);
62
+ }
63
+ }
64
+ }
65
+ /**
66
+ * Resolve Windows-specific CLI path for npm global installs.
67
+ */
68
+ export function resolveWindowsCliPath(cliName, configuredPath) {
69
+ if (process.platform !== 'win32' || configuredPath !== cliName)
70
+ return configuredPath;
71
+ const npmPaths = [
72
+ join(process.env.APPDATA || '', 'npm', `${cliName}.cmd`),
73
+ join(process.env.LOCALAPPDATA || '', 'npm', `${cliName}.cmd`),
74
+ ];
75
+ for (const p of npmPaths) {
76
+ try {
77
+ accessSync(p, constants.F_OK);
78
+ return p;
79
+ }
80
+ catch {
81
+ /* try next */
82
+ }
83
+ }
84
+ return configuredPath;
85
+ }
@@ -0,0 +1,11 @@
1
+ import type { AiCommand, FileConfig } from './types.js';
2
+ export declare const CONFIG_PATH: string;
3
+ export declare const CODEX_AUTH_PATHS: string[];
4
+ export declare function loadFileConfig(): FileConfig;
5
+ export declare function saveFileConfig(raw: FileConfig): void;
6
+ export declare function getClaudeConfigHome(): string;
7
+ export declare function loadClaudeSettingsEnv(): Record<string, string>;
8
+ export declare function saveClaudeSettingsEnv(env: Record<string, string>): void;
9
+ export declare function normalizeAiCommand(value: unknown, fallback: AiCommand): AiCommand;
10
+ export declare function hasCodexAuth(): boolean;
11
+ export declare function parseCommaSeparated(value: string): string[];
@@ -0,0 +1,176 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, chmodSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { createLogger } from '../logger.js';
5
+ import { APP_HOME } from '../constants.js';
6
+ const log = createLogger('config');
7
+ export const CONFIG_PATH = join(APP_HOME, 'config.json');
8
+ export const CODEX_AUTH_PATHS = [
9
+ join(homedir(), '.codex', 'auth.json'),
10
+ join(homedir(), '.config', 'codex', 'auth.json'),
11
+ join(homedir(), 'AppData', 'Roaming', 'codex', 'auth.json'),
12
+ ];
13
+ const OLD_ROOT_KEYS = [
14
+ 'claudeWorkDir',
15
+ 'claudeTimeoutMs',
16
+ 'claudeModel',
17
+ ];
18
+ const AI_COMMANDS = ['claude', 'codex', 'codebuddy'];
19
+ // Config cache with mtime tracking
20
+ let cachedConfig = null;
21
+ let cachedClaudeEnv = null;
22
+ function hasOldConfigFormat(raw) {
23
+ const hasOld = OLD_ROOT_KEYS.some((k) => raw[k] !== undefined && raw[k] !== null);
24
+ const hasNew = raw.tools && typeof raw.tools === 'object' && raw.tools.claude;
25
+ return !!hasOld && !hasNew;
26
+ }
27
+ function migrateToNewConfigFormat(raw) {
28
+ const tools = raw.tools || {};
29
+ const tc = tools.claude || {};
30
+ const tcod = tools.codex || {};
31
+ const tcb = tools.codebuddy || {};
32
+ const migrated = { ...raw };
33
+ migrated.tools = {
34
+ claude: {
35
+ ...tc,
36
+ workDir: tc.workDir ?? raw.claudeWorkDir ?? process.cwd(),
37
+ proxy: tc.proxy,
38
+ },
39
+ codex: {
40
+ ...tcod,
41
+ cliPath: tcod.cliPath ?? 'codex',
42
+ workDir: tcod.workDir ?? raw.claudeWorkDir ?? process.cwd(),
43
+ proxy: tcod.proxy,
44
+ },
45
+ codebuddy: {
46
+ ...tcb,
47
+ cliPath: tcb.cliPath ?? 'codebuddy',
48
+ },
49
+ };
50
+ for (const k of OLD_ROOT_KEYS) {
51
+ delete migrated[k];
52
+ }
53
+ return migrated;
54
+ }
55
+ export function loadFileConfig() {
56
+ try {
57
+ if (!existsSync(CONFIG_PATH))
58
+ return {};
59
+ const stats = statSync(CONFIG_PATH);
60
+ const currentMtime = stats.mtimeMs;
61
+ if (cachedConfig && cachedConfig.mtime === currentMtime) {
62
+ return cachedConfig.config;
63
+ }
64
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
65
+ if (!raw || typeof raw !== 'object')
66
+ return {};
67
+ if (hasOldConfigFormat(raw)) {
68
+ const migrated = migrateToNewConfigFormat(raw);
69
+ const dir = dirname(CONFIG_PATH);
70
+ if (!existsSync(dir))
71
+ mkdirSync(dir, { recursive: true });
72
+ writeFileSync(CONFIG_PATH, JSON.stringify(migrated, null, 2), 'utf-8');
73
+ try {
74
+ chmodSync(CONFIG_PATH, 0o600);
75
+ }
76
+ catch { /* ignore */ }
77
+ cachedConfig = { config: migrated, mtime: currentMtime };
78
+ return migrated;
79
+ }
80
+ cachedConfig = { config: raw, mtime: currentMtime };
81
+ return raw;
82
+ }
83
+ catch {
84
+ return {};
85
+ }
86
+ }
87
+ export function saveFileConfig(raw) {
88
+ const dir = dirname(CONFIG_PATH);
89
+ if (!existsSync(dir))
90
+ mkdirSync(dir, { recursive: true });
91
+ writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2), 'utf-8');
92
+ cachedConfig = null;
93
+ }
94
+ export function getClaudeConfigHome() {
95
+ return process.env.HOME || process.env.USERPROFILE || homedir();
96
+ }
97
+ export function loadClaudeSettingsEnv() {
98
+ const home = getClaudeConfigHome();
99
+ const paths = [
100
+ join(home, '.claude', 'settings.json'),
101
+ join(home, '.claude.json'),
102
+ ];
103
+ for (const p of paths) {
104
+ try {
105
+ if (existsSync(p)) {
106
+ const stats = statSync(p);
107
+ const currentMtime = stats.mtimeMs;
108
+ if (cachedClaudeEnv && cachedClaudeEnv.mtime === currentMtime && cachedClaudeEnv.env) {
109
+ return cachedClaudeEnv.env;
110
+ }
111
+ const raw = JSON.parse(readFileSync(p, 'utf-8'));
112
+ const env = raw?.env;
113
+ if (env && typeof env === 'object') {
114
+ const result = {};
115
+ for (const [k, v] of Object.entries(env)) {
116
+ if (v != null && typeof k === 'string') {
117
+ result[k] = String(v);
118
+ }
119
+ }
120
+ cachedClaudeEnv = { env: result, mtime: currentMtime };
121
+ return result;
122
+ }
123
+ }
124
+ }
125
+ catch {
126
+ /* file not found or parse error, try next path */
127
+ }
128
+ }
129
+ return {};
130
+ }
131
+ export function saveClaudeSettingsEnv(env) {
132
+ const home = getClaudeConfigHome();
133
+ const claudeSettingsPath = join(home, '.claude', 'settings.json');
134
+ const claudeDir = join(home, '.claude');
135
+ try {
136
+ if (!existsSync(claudeDir)) {
137
+ mkdirSync(claudeDir, { recursive: true });
138
+ }
139
+ let existing = {};
140
+ if (existsSync(claudeSettingsPath)) {
141
+ try {
142
+ existing = JSON.parse(readFileSync(claudeSettingsPath, 'utf-8'));
143
+ }
144
+ catch {
145
+ // file format error, start fresh
146
+ }
147
+ }
148
+ existing.env = { ...existing.env, ...env };
149
+ writeFileSync(claudeSettingsPath, JSON.stringify(existing, null, 2), 'utf-8');
150
+ cachedClaudeEnv = null;
151
+ }
152
+ catch (error) {
153
+ log.error('Failed to save Claude settings:', error);
154
+ throw new Error(`Failed to save Claude settings: ${error instanceof Error ? error.message : String(error)}`);
155
+ }
156
+ }
157
+ export function normalizeAiCommand(value, fallback) {
158
+ return typeof value === 'string' && AI_COMMANDS.includes(value)
159
+ ? value
160
+ : fallback;
161
+ }
162
+ export function hasCodexAuth() {
163
+ if (process.env.OPENAI_API_KEY)
164
+ return true;
165
+ return CODEX_AUTH_PATHS.some((p) => {
166
+ try {
167
+ return existsSync(p) && readFileSync(p, 'utf-8').trim().length > 0;
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ });
173
+ }
174
+ export function parseCommaSeparated(value) {
175
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
176
+ }