feishu-user-plugin 1.2.1 → 1.3.1

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/src/config.js CHANGED
@@ -3,6 +3,118 @@ const path = require('path');
3
3
 
4
4
  const SERVER_NAMES = ['feishu-user-plugin', 'feishu'];
5
5
 
6
+ // --- Atomic file write ---
7
+ // Writes to a tmp file then renames, preventing partial reads / race conditions
8
+ // with Claude Code (which also reads/writes ~/.claude.json).
9
+ function _atomicWrite(filePath, content) {
10
+ const tmpPath = filePath + '.tmp.' + process.pid;
11
+ fs.writeFileSync(tmpPath, content);
12
+ fs.renameSync(tmpPath, filePath);
13
+ }
14
+
15
+ // --- Minimal TOML helpers (only handles MCP server config structure) ---
16
+
17
+ function _tomlEscape(s) {
18
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
19
+ }
20
+
21
+ /**
22
+ * Read key="value" pairs from a TOML section.
23
+ * Returns { key: value } or null if section not found.
24
+ */
25
+ function _readTomlSection(content, sectionPath) {
26
+ const header = `[${sectionPath}]`;
27
+ const idx = content.indexOf(header);
28
+ if (idx === -1) return null;
29
+
30
+ const afterHeader = content.slice(idx + header.length);
31
+ const nextSection = afterHeader.search(/^\[/m);
32
+ const sectionBody = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection);
33
+
34
+ const result = {};
35
+ for (const line of sectionBody.split('\n')) {
36
+ // String value: key = "value"
37
+ const strMatch = line.match(/^(\w+)\s*=\s*"(.*)"/);
38
+ if (strMatch) { result[strMatch[1]] = strMatch[2]; continue; }
39
+ // Array value: key = ["a", "b"]
40
+ const arrMatch = line.match(/^(\w+)\s*=\s*\[(.*)\]/);
41
+ if (arrMatch) {
42
+ result[arrMatch[1]] = arrMatch[2].split(',').map(s => s.trim().replace(/^"|"$/g, ''));
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+
48
+ /**
49
+ * Update or append key-value pairs in a TOML section.
50
+ * Creates the section if it doesn't exist.
51
+ */
52
+ function _updateTomlSection(content, sectionPath, updates) {
53
+ const header = `[${sectionPath}]`;
54
+ const idx = content.indexOf(header);
55
+
56
+ if (idx === -1) {
57
+ // Append new section
58
+ let block = '\n' + header + '\n';
59
+ for (const [k, v] of Object.entries(updates)) {
60
+ block += `${k} = "${_tomlEscape(v)}"\n`;
61
+ }
62
+ return content.trimEnd() + '\n' + block;
63
+ }
64
+
65
+ // Find section boundaries
66
+ const afterHeader = idx + header.length;
67
+ const rest = content.slice(afterHeader);
68
+ const nextSection = rest.search(/^\[/m);
69
+ const sectionEnd = nextSection === -1 ? content.length : afterHeader + nextSection;
70
+ let sectionBody = content.slice(afterHeader, sectionEnd);
71
+
72
+ for (const [k, v] of Object.entries(updates)) {
73
+ const escaped = _tomlEscape(v);
74
+ const keyRegex = new RegExp(`^${k}\\s*=\\s*".*"`, 'm');
75
+ if (keyRegex.test(sectionBody)) {
76
+ sectionBody = sectionBody.replace(keyRegex, `${k} = "${escaped}"`);
77
+ } else {
78
+ sectionBody = sectionBody.trimEnd() + `\n${k} = "${escaped}"\n`;
79
+ }
80
+ }
81
+
82
+ return content.slice(0, afterHeader) + sectionBody + content.slice(sectionEnd);
83
+ }
84
+
85
+ /**
86
+ * Generate a complete TOML MCP server entry.
87
+ */
88
+ function _generateTomlServerEntry(serverName, env) {
89
+ const section = `mcp_servers.${serverName}`;
90
+ // Codex uses Content-Length framing; our server uses newline-delimited JSON.
91
+ // The bridge script translates between the two automatically.
92
+ // Resolve bridge path: prefer local repo, fall back to npx-installed package.
93
+ const localBridge = path.join(__dirname, '..', 'scripts', 'mcp_stdio_bridge.js');
94
+ let block = `[${section}]\n`;
95
+ block += `command = "node"\n`;
96
+ block += `args = ["${_tomlEscape(localBridge)}"]\n\n`;
97
+ block += `[${section}.env]\n`;
98
+ for (const [k, v] of Object.entries(env)) {
99
+ block += `${k} = "${_tomlEscape(v)}"\n`;
100
+ }
101
+ return block;
102
+ }
103
+
104
+ /**
105
+ * Remove all TOML sections matching a server name.
106
+ */
107
+ function _removeTomlServer(content, serverName) {
108
+ // Remove [mcp_servers.<name>] and [mcp_servers.<name>.*] sections
109
+ const pattern = new RegExp(
110
+ `\\[mcp_servers\\.${serverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^\\]]*\\][^\\[]*`,
111
+ 'g'
112
+ );
113
+ return content.replace(pattern, '');
114
+ }
115
+
116
+ // --- JSON config helpers ---
117
+
6
118
  /**
7
119
  * Search an mcpServers object for a feishu-user-plugin entry.
8
120
  * Returns { serverName, serverEnv } or null.
@@ -22,16 +134,19 @@ function _findInServers(servers) {
22
134
  * Discover the MCP config file containing feishu-user-plugin server entry.
23
135
  *
24
136
  * Search order:
25
- * 1. ~/.claude.json — top-level mcpServers
26
- * 2. ~/.claude.json — projects[*].mcpServers (Claude Code project-level config)
137
+ * 1. ~/.claude.json — top-level mcpServers (Claude Code)
138
+ * 2. ~/.claude.json — projects[*].mcpServers (Claude Code project-level)
27
139
  * 3. ~/.claude/.claude.json — same two-level search
28
- * 4. <cwd>/.mcp.json — top-level mcpServers (reliable in CLI mode)
140
+ * 4. <cwd>/.mcp.json — top-level mcpServers
141
+ * 5. ~/.codex/config.toml — Codex MCP config
29
142
  *
30
143
  * Returns { configPath, config, serverName, serverEnv, projectPath? } or null.
31
144
  */
32
145
  function findMcpConfig() {
33
146
  const home = process.env.HOME;
34
- const candidates = [
147
+
148
+ // --- JSON candidates ---
149
+ const jsonCandidates = [
35
150
  ...(home ? [
36
151
  path.join(home, '.claude.json'),
37
152
  path.join(home, '.claude', '.claude.json'),
@@ -39,7 +154,7 @@ function findMcpConfig() {
39
154
  path.join(process.cwd(), '.mcp.json'),
40
155
  ];
41
156
 
42
- for (const configPath of candidates) {
157
+ for (const configPath of jsonCandidates) {
43
158
  try {
44
159
  const raw = fs.readFileSync(configPath, 'utf8');
45
160
  const config = JSON.parse(raw);
@@ -72,6 +187,25 @@ function findMcpConfig() {
72
187
  }
73
188
  }
74
189
  }
190
+
191
+ // --- Codex TOML ---
192
+ if (home) {
193
+ const codexConfig = path.join(home, '.codex', 'config.toml');
194
+ try {
195
+ const raw = fs.readFileSync(codexConfig, 'utf8');
196
+ for (const name of SERVER_NAMES) {
197
+ const env = _readTomlSection(raw, `mcp_servers.${name}.env`);
198
+ if (env) {
199
+ return { configPath: codexConfig, config: null, serverName: name, serverEnv: env, projectPath: null };
200
+ }
201
+ }
202
+ } catch (e) {
203
+ if (e.code !== 'ENOENT') {
204
+ console.error(`[feishu-user-plugin] Warning: Failed to parse ${codexConfig}: ${e.message}`);
205
+ }
206
+ }
207
+ }
208
+
75
209
  return null;
76
210
  }
77
211
 
@@ -87,7 +221,7 @@ function readCredentials() {
87
221
 
88
222
  /**
89
223
  * Persist key-value updates into the MCP config's env block.
90
- * Uses findMcpConfig() to locate the correct entry, then writes back.
224
+ * Uses findMcpConfig() to locate the correct entry, then writes back atomically.
91
225
  * Returns true if persisted successfully, false otherwise.
92
226
  */
93
227
  function persistToConfig(updates) {
@@ -100,6 +234,17 @@ function persistToConfig(updates) {
100
234
 
101
235
  const { configPath, config, serverName, projectPath } = found;
102
236
 
237
+ // --- TOML path ---
238
+ if (configPath.endsWith('.toml')) {
239
+ let content = '';
240
+ try { content = fs.readFileSync(configPath, 'utf8'); } catch {}
241
+ content = _updateTomlSection(content, `mcp_servers.${serverName}.env`, updates);
242
+ _atomicWrite(configPath, content);
243
+ console.error(`[feishu-user-plugin] Config persisted to ${configPath}`);
244
+ return true;
245
+ }
246
+
247
+ // --- JSON path ---
103
248
  // Navigate to the correct env object
104
249
  let env;
105
250
  if (projectPath) {
@@ -111,7 +256,7 @@ function persistToConfig(updates) {
111
256
  }
112
257
 
113
258
  Object.assign(env, updates);
114
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
259
+ _atomicWrite(configPath, JSON.stringify(config, null, 2) + '\n');
115
260
  console.error(`[feishu-user-plugin] Config persisted to ${configPath}${projectPath ? ` (project: ${projectPath})` : ''}`);
116
261
  return true;
117
262
  } catch (e) {
@@ -124,22 +269,33 @@ function persistToConfig(updates) {
124
269
  * Write a complete feishu-user-plugin MCP server entry to a config file.
125
270
  * Used by the setup wizard.
126
271
  *
127
- * If an existing config is found via findMcpConfig(), updates it in-place
128
- * (preserving its location — top-level or project-level).
129
- * Otherwise, writes to ~/.claude.json top-level mcpServers.
130
- *
131
272
  * @param {object} env - The env vars to write
132
- * @param {string} [configPath] - Override the target config file path
133
- * @param {string} [projectPath] - If writing to a project-level entry
134
- * @returns {{ configPath: string }} The path that was written
273
+ * @param {object} [options] - { configPath, projectPath, client }
274
+ * client: 'claude' (default) | 'codex' | 'both'
275
+ * @returns {{ configPath: string, codexConfigPath?: string }}
135
276
  */
136
- function writeNewConfig(env, configPath, projectPath) {
277
+ function writeNewConfig(env, configPath, projectPath, client) {
278
+ const results = {};
279
+
280
+ // --- Claude Code (JSON) ---
281
+ if (client !== 'codex') {
282
+ results.configPath = _writeClaudeConfig(env, configPath, projectPath);
283
+ }
284
+
285
+ // --- Codex (TOML) ---
286
+ if (client === 'codex' || client === 'both') {
287
+ results.codexConfigPath = _writeCodexConfig(env);
288
+ }
289
+
290
+ return results;
291
+ }
292
+
293
+ function _writeClaudeConfig(env, configPath, projectPath) {
137
294
  if (!configPath) {
138
295
  configPath = path.join(process.env.HOME || '', '.claude.json');
139
296
  }
140
297
 
141
298
  if (projectPath) {
142
- // Verify the project entry still exists; warn if it was removed between discovery and write
143
299
  let existing = {};
144
300
  try { existing = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
145
301
  if (!existing.projects?.[projectPath]) {
@@ -160,29 +316,48 @@ function writeNewConfig(env, configPath, projectPath) {
160
316
  };
161
317
 
162
318
  if (projectPath && config.projects?.[projectPath]) {
163
- // Write into existing project-level config
164
319
  if (!config.projects[projectPath].mcpServers) config.projects[projectPath].mcpServers = {};
165
320
  config.projects[projectPath].mcpServers['feishu-user-plugin'] = serverEntry;
166
321
  if (config.projects[projectPath].mcpServers.feishu) {
167
322
  delete config.projects[projectPath].mcpServers.feishu;
168
323
  }
169
324
  } else if (configPath.endsWith('.mcp.json') && !config.mcpServers) {
170
- // Bare .mcp.json format: server entries at top level (no mcpServers wrapper)
171
325
  config['feishu-user-plugin'] = serverEntry;
172
- if (config.feishu) {
173
- delete config.feishu;
174
- }
326
+ if (config.feishu) delete config.feishu;
175
327
  } else {
176
- // Write to top-level mcpServers (default for ~/.claude.json)
177
328
  if (!config.mcpServers) config.mcpServers = {};
178
329
  config.mcpServers['feishu-user-plugin'] = serverEntry;
179
- if (config.mcpServers.feishu) {
180
- delete config.mcpServers.feishu;
181
- }
330
+ if (config.mcpServers.feishu) delete config.mcpServers.feishu;
182
331
  }
183
332
 
184
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
185
- return { configPath };
333
+ _atomicWrite(configPath, JSON.stringify(config, null, 2) + '\n');
334
+ return configPath;
335
+ }
336
+
337
+ function _writeCodexConfig(env) {
338
+ const home = process.env.HOME || '';
339
+ const codexDir = path.join(home, '.codex');
340
+ const configPath = path.join(codexDir, 'config.toml');
341
+
342
+ // Ensure ~/.codex/ exists
343
+ if (!fs.existsSync(codexDir)) {
344
+ fs.mkdirSync(codexDir, { recursive: true });
345
+ }
346
+
347
+ let content = '';
348
+ try { content = fs.readFileSync(configPath, 'utf8'); } catch {}
349
+
350
+ // Remove existing feishu entries
351
+ for (const name of SERVER_NAMES) {
352
+ content = _removeTomlServer(content, name);
353
+ }
354
+
355
+ // Append new entry
356
+ content = content.trimEnd() + '\n\n' + _generateTomlServerEntry('feishu-user-plugin', env);
357
+
358
+ _atomicWrite(configPath, content);
359
+ console.error(`[feishu-user-plugin] Codex config written to ${configPath}`);
360
+ return configPath;
186
361
  }
187
362
 
188
363
  module.exports = { findMcpConfig, readCredentials, persistToConfig, writeNewConfig, SERVER_NAMES };