@ian2018cs/agenthub 0.1.34 → 0.1.36

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.
@@ -5,7 +5,7 @@ import { spawn } from 'child_process';
5
5
  import multer from 'multer';
6
6
  import AdmZip from 'adm-zip';
7
7
  import { getUserPaths, getPublicPaths, DATA_DIR } from '../services/user-directories.js';
8
- import { markBuiltinSkillRemoved, isBuiltinSkillPath } from '../services/builtin-skills.js';
8
+ import { SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME } from '../services/system-repo.js';
9
9
 
10
10
  const router = express.Router();
11
11
 
@@ -316,7 +316,16 @@ router.get('/', async (req, res) => {
316
316
  isSymlink = stat.isSymbolicLink();
317
317
 
318
318
  if (isSymlink) {
319
- realPath = await fs.realpath(skillPath);
319
+ try {
320
+ realPath = await fs.realpath(skillPath);
321
+ } catch (realpathErr) {
322
+ if (realpathErr.code === 'ENOENT') {
323
+ // Dangling symlink - target no longer exists, remove it
324
+ await fs.unlink(skillPath).catch(() => {});
325
+ console.log(`[Skills] Removed dangling symlink: ${entry.name}`);
326
+ }
327
+ continue;
328
+ }
320
329
 
321
330
  // Determine source based on realPath
322
331
  if (realPath.includes('/skills-import/')) {
@@ -328,8 +337,6 @@ router.get('/', async (req, res) => {
328
337
  if (repoMatch) {
329
338
  repository = `${repoMatch[1]}/${repoMatch[2]}`;
330
339
  }
331
- } else if (realPath.includes('/builtin-skills/')) {
332
- source = 'builtin';
333
340
  }
334
341
  }
335
342
 
@@ -467,7 +474,6 @@ router.delete('/:name', async (req, res) => {
467
474
  let realPath = null;
468
475
  let isSymlink = false;
469
476
  let isImported = false;
470
- let isBuiltin = false;
471
477
 
472
478
  try {
473
479
  const stat = await fs.lstat(linkPath);
@@ -475,7 +481,6 @@ router.delete('/:name', async (req, res) => {
475
481
  if (isSymlink) {
476
482
  realPath = await fs.realpath(linkPath);
477
483
  isImported = realPath.includes('/skills-import/');
478
- isBuiltin = isBuiltinSkillPath(realPath);
479
484
  }
480
485
  } catch (err) {
481
486
  return res.status(404).json({ error: 'Skill not found' });
@@ -496,9 +501,6 @@ router.delete('/:name', async (req, res) => {
496
501
  } catch (err) {
497
502
  console.error('Error removing imported skill files:', err);
498
503
  }
499
- } else if (isBuiltin) {
500
- // Mark as removed so it won't be re-added on next sync
501
- await markBuiltinSkillRemoved(userUuid, name);
502
504
  }
503
505
 
504
506
  res.json({ success: true, message: 'Skill deleted' });
@@ -793,13 +795,15 @@ router.get('/repos', async (req, res) => {
793
795
  // Count skills in repo (supports nested dirs like skills/XXX)
794
796
  const repoSkills = await scanDirForSkills(realPath, `${owner}/${repo}`, null);
795
797
  const skillCount = repoSkills.length;
798
+ const isSystem = owner === SYSTEM_REPO_OWNER && repo === SYSTEM_REPO_NAME;
796
799
 
797
800
  repos.push({
798
801
  owner,
799
802
  repo,
800
- url: `https://github.com/${owner}/${repo}`,
803
+ url: isSystem ? null : `https://github.com/${owner}/${repo}`,
801
804
  skillCount,
802
- path: realPath
805
+ path: realPath,
806
+ isSystem
803
807
  });
804
808
  }
805
809
  }
@@ -988,6 +992,11 @@ router.delete('/repos/:owner/:repo', async (req, res) => {
988
992
  return res.status(400).json({ error: 'Owner and repo are required' });
989
993
  }
990
994
 
995
+ // System repo cannot be removed
996
+ if (owner === SYSTEM_REPO_OWNER && repo === SYSTEM_REPO_NAME) {
997
+ return res.status(403).json({ error: '内置仓库不能删除' });
998
+ }
999
+
991
1000
  const userPaths = getUserPaths(userUuid);
992
1001
  const userRepoPath = path.join(userPaths.skillsRepoDir, owner, repo);
993
1002
 
@@ -0,0 +1,148 @@
1
+ import { promises as fs } from 'fs';
2
+ import TOML from '@iarna/toml';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // TOML formatting helpers — produce clean output with inline tables for env
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function formatTomlValue(value) {
9
+ if (typeof value === 'string') return JSON.stringify(value);
10
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
11
+ if (Array.isArray(value)) return '[' + value.map(formatTomlValue).join(', ') + ']';
12
+ if (value !== null && typeof value === 'object') {
13
+ const pairs = Object.entries(value).map(([k, v]) => `${k} = ${formatTomlValue(v)}`).join(', ');
14
+ return `{ ${pairs} }`;
15
+ }
16
+ return JSON.stringify(value);
17
+ }
18
+
19
+ // Fields that should always be written as inline tables even when they are objects
20
+ const INLINE_TABLE_FIELDS = new Set(['env', 'http_headers', 'env_http_headers']);
21
+
22
+ // Canonical field order for a mcp_servers entry
23
+ const FIELD_ORDER = [
24
+ 'command', 'url', 'args', 'env', 'env_vars', 'cwd',
25
+ 'bearer_token_env_var', 'http_headers', 'env_http_headers',
26
+ 'startup_timeout_sec', 'tool_timeout_sec', 'enabled', 'required',
27
+ ];
28
+
29
+ function formatEntry(name, entry) {
30
+ const lines = [`[mcp_servers.${name}]`];
31
+ for (const key of FIELD_ORDER) {
32
+ if (entry[key] === undefined) continue;
33
+ const val = entry[key];
34
+ if (INLINE_TABLE_FIELDS.has(key) && val !== null && typeof val === 'object' && !Array.isArray(val)) {
35
+ const pairs = Object.entries(val).map(([k, v]) => `${k} = ${formatTomlValue(v)}`).join(', ');
36
+ lines.push(`${key} = { ${pairs} }`);
37
+ } else {
38
+ lines.push(`${key} = ${formatTomlValue(val)}`);
39
+ }
40
+ }
41
+ return lines.join('\n');
42
+ }
43
+
44
+ /**
45
+ * Format a mcp_servers map as a clean TOML block.
46
+ * Exported so that user-directories.js can append it to the base config template.
47
+ */
48
+ export function formatMcpServersSection(mcpServers) {
49
+ return Object.entries(mcpServers)
50
+ .map(([name, entry]) => formatEntry(name, entry))
51
+ .join('\n\n');
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Remove [mcp_servers.*] blocks from existing TOML text (text-based, safe)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function removeMcpServersFromContent(content) {
59
+ const lines = content.split('\n');
60
+ const result = [];
61
+ let inMcpServers = false;
62
+
63
+ for (const line of lines) {
64
+ const trimmed = line.trim();
65
+ if (/^\[mcp_servers[.\]]/.test(trimmed) || trimmed === '[mcp_servers]') {
66
+ inMcpServers = true;
67
+ } else if (inMcpServers && /^\[(?!mcp_servers)/.test(trimmed)) {
68
+ inMcpServers = false;
69
+ }
70
+ if (!inMcpServers) result.push(line);
71
+ }
72
+
73
+ return result.join('\n');
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Public API
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Convert a Claude mcp.json server config entry to the equivalent Codex
82
+ * mcp_servers entry (drops the `type` discriminator, keeps compatible fields).
83
+ */
84
+ export function mcpConfigToCodexEntry(serverConfig) {
85
+ const { type, command, args, env, cwd, url, ...rest } = serverConfig;
86
+ const entry = {};
87
+
88
+ if (type === 'stdio' || (!type && command)) {
89
+ if (command) entry.command = command;
90
+ if (args?.length) entry.args = args;
91
+ if (env && Object.keys(env).length > 0) entry.env = env;
92
+ if (cwd) entry.cwd = cwd;
93
+ } else if (type === 'http' || type === 'sse') {
94
+ if (url) entry.url = url;
95
+ }
96
+
97
+ for (const key of ['startup_timeout_sec', 'tool_timeout_sec', 'enabled', 'required', 'bearer_token_env_var']) {
98
+ if (rest[key] !== undefined) entry[key] = rest[key];
99
+ }
100
+
101
+ return entry;
102
+ }
103
+
104
+ /**
105
+ * Read the mcp_servers map from a Codex config.toml.
106
+ * Returns an empty object if the file doesn't exist or has no mcp_servers.
107
+ */
108
+ export async function readCodexMcpServers(configPath) {
109
+ try {
110
+ const parsed = TOML.parse(await fs.readFile(configPath, 'utf-8'));
111
+ return parsed.mcp_servers || {};
112
+ } catch {
113
+ return {};
114
+ }
115
+ }
116
+
117
+ async function writeMcpServers(configPath, mcpServers) {
118
+ let existingContent = '';
119
+ try { existingContent = await fs.readFile(configPath, 'utf-8'); } catch { /* file missing */ }
120
+
121
+ let newContent = removeMcpServersFromContent(existingContent).trimEnd();
122
+ if (Object.keys(mcpServers).length > 0) {
123
+ newContent += '\n\n' + formatMcpServersSection(mcpServers);
124
+ }
125
+ await fs.writeFile(configPath, newContent + '\n', 'utf-8');
126
+ }
127
+
128
+ /**
129
+ * Add / update MCP servers in a Codex config.toml.
130
+ * mcpJson: the parsed contents of a mcp.json file ({ [serverName]: config }).
131
+ */
132
+ export async function addToCodexConfig(configPath, mcpJson) {
133
+ const mcpServers = await readCodexMcpServers(configPath);
134
+ for (const [name, serverConfig] of Object.entries(mcpJson)) {
135
+ mcpServers[name] = mcpConfigToCodexEntry(serverConfig);
136
+ }
137
+ await writeMcpServers(configPath, mcpServers);
138
+ }
139
+
140
+ /**
141
+ * Remove a single MCP server from a Codex config.toml.
142
+ */
143
+ export async function removeFromCodexConfig(configPath, serverName) {
144
+ const mcpServers = await readCodexMcpServers(configPath);
145
+ if (!(serverName in mcpServers)) return;
146
+ delete mcpServers[serverName];
147
+ await writeMcpServers(configPath, mcpServers);
148
+ }
@@ -84,7 +84,7 @@ export class CommandHandler {
84
84
  async _handleAuth(feishuOpenId, chatId, messageId, token) {
85
85
  if (!token) {
86
86
  await this._reply(chatId, messageId,
87
- '请先完成账号绑定:\n\n1. 登录 Claude Code UI 网页\n2. 进入 **设置 → 账户**,点击「生成绑定 Token」\n3. 复制命令后在此发送:`/auth <token>`'
87
+ '请先完成账号绑定:\n\n1. 登录 AgentHub 网页:http://10.0.1.133:6175/\n2. 进入 **设置 → 账户**,点击「生成绑定 Token」\n3. 复制命令后在此发送:`/auth <token>`'
88
88
  );
89
89
  return true;
90
90
  }
@@ -295,7 +295,7 @@ export class CommandHandler {
295
295
  }
296
296
 
297
297
  if (projects.length === 0) {
298
- await this._reply(chatId, messageId, '暂无项目,请在 Claude Code UI 中创建项目。');
298
+ await this._reply(chatId, messageId, '暂无项目,请在 AgentHub 中创建项目。');
299
299
  return true;
300
300
  }
301
301
 
@@ -111,7 +111,7 @@ export class FeishuEngine {
111
111
  const binding = feishuDb.getBinding(feishuOpenId);
112
112
  if (!binding) {
113
113
  await this.larkClient.replyText(messageId,
114
- '👋 你好!请先完成账号绑定:\n\n1. 登录 Claude Code UI 网页\n2. 进入 **设置 → 账户**,点击「生成绑定 Token」\n3. 复制命令后在此发送:`/auth <token>`'
114
+ '👋 你好!请先完成账号绑定:\n\n1. 登录 AgentHub 网页:http://10.0.1.133:6175/\n2. 进入 **设置 → 账户**,点击「生成绑定 Token」\n3. 复制命令后在此发送:`/auth <token>`'
115
115
  );
116
116
  return;
117
117
  }
@@ -221,7 +221,7 @@ export class FeishuEngine {
221
221
  const binding = feishuDb.getBinding(feishuOpenId);
222
222
  if (!binding && !commandText.toLowerCase().startsWith('/auth')) {
223
223
  await this.larkClient.sendTextToUser(feishuOpenId,
224
- '👋 你好!请先完成账号绑定:\n\n1. 登录 Claude Code UI 网页\n2. 进入 **设置 → 账户**,点击「生成绑定 Token」\n3. 复制命令后在此发送:`/auth <token>`'
224
+ '👋 你好!请先完成账号绑定:\n\n1. 登录 AgentHub 网页:http://10.0.1.133:6175/\n2. 进入 **设置 → 账户**,点击「生成绑定 Token」\n3. 复制命令后在此发送:`/auth <token>`'
225
225
  );
226
226
  return;
227
227
  }
@@ -0,0 +1,105 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { getUserPaths, getPublicPaths } from './user-directories.js';
5
+
6
+ const SYSTEM_MCP_REPO_URL = 'git@git.amberweather.com:mcp-server/hupoer-mcps.git';
7
+ const SYSTEM_MCP_REPO_OWNER = 'mcp-server';
8
+ const SYSTEM_MCP_REPO_NAME = 'hupoer-mcps';
9
+
10
+ function cloneRepository(url, destinationPath) {
11
+ return new Promise((resolve, reject) => {
12
+ const gitProcess = spawn('git', ['clone', '--depth', '1', url, destinationPath], {
13
+ stdio: ['ignore', 'pipe', 'pipe'],
14
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
15
+ });
16
+
17
+ let stderr = '';
18
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
19
+ gitProcess.on('close', (code) => {
20
+ if (code === 0) resolve();
21
+ else reject(new Error(stderr || `Git clone failed with code ${code}`));
22
+ });
23
+ gitProcess.on('error', (err) => { reject(err); });
24
+ });
25
+ }
26
+
27
+ function updateRepository(repoPath) {
28
+ return new Promise((resolve, reject) => {
29
+ const gitProcess = spawn('git', ['pull', '--ff-only'], {
30
+ cwd: repoPath,
31
+ stdio: ['ignore', 'pipe', 'pipe'],
32
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
33
+ });
34
+
35
+ let stderr = '';
36
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
37
+ gitProcess.on('close', (code) => {
38
+ if (code === 0) resolve();
39
+ else reject(new Error(stderr || `Git pull failed with code ${code}`));
40
+ });
41
+ gitProcess.on('error', (err) => { reject(err); });
42
+ });
43
+ }
44
+
45
+ async function ensureSystemMcpRepo() {
46
+ const publicPaths = getPublicPaths();
47
+ const publicRepoPath = path.join(publicPaths.mcpRepoDir, SYSTEM_MCP_REPO_OWNER, SYSTEM_MCP_REPO_NAME);
48
+
49
+ try {
50
+ await fs.access(publicRepoPath);
51
+ // Already cloned — try to update
52
+ try {
53
+ await updateRepository(publicRepoPath);
54
+ } catch (err) {
55
+ console.log('[SystemMcpRepo] Failed to pull system MCP repo, using existing clone:', err.message);
56
+ }
57
+ } catch {
58
+ // Not yet cloned
59
+ await fs.mkdir(path.dirname(publicRepoPath), { recursive: true });
60
+ try {
61
+ await cloneRepository(SYSTEM_MCP_REPO_URL, publicRepoPath);
62
+ console.log('[SystemMcpRepo] Cloned system MCP repo to', publicRepoPath);
63
+ } catch (err) {
64
+ console.error('[SystemMcpRepo] Failed to clone system MCP repo:', err.message);
65
+ throw err;
66
+ }
67
+ }
68
+
69
+ return publicRepoPath;
70
+ }
71
+
72
+ /**
73
+ * Initialize the built-in MCP repo for a user.
74
+ * Idempotent: if the user already has the symlink, skip.
75
+ */
76
+ export async function initSystemMcpRepoForUser(userUuid) {
77
+ const userPaths = getUserPaths(userUuid);
78
+ const userRepoPath = path.join(userPaths.mcpRepoDir, SYSTEM_MCP_REPO_OWNER, SYSTEM_MCP_REPO_NAME);
79
+
80
+ try {
81
+ await fs.lstat(userRepoPath);
82
+ // Already set up — nothing to do
83
+ return;
84
+ } catch {
85
+ // Doesn't exist — proceed with setup
86
+ }
87
+
88
+ let publicRepoPath;
89
+ try {
90
+ publicRepoPath = await ensureSystemMcpRepo();
91
+ } catch (err) {
92
+ console.error('[SystemMcpRepo] Could not ensure system MCP repo, skipping user init:', err.message);
93
+ return;
94
+ }
95
+
96
+ await fs.mkdir(path.dirname(userRepoPath), { recursive: true });
97
+ try {
98
+ await fs.symlink(publicRepoPath, userRepoPath);
99
+ console.log(`[SystemMcpRepo] Linked system MCP repo for user ${userUuid}`);
100
+ } catch (err) {
101
+ console.error(`[SystemMcpRepo] Failed to create symlink for user ${userUuid}:`, err.message);
102
+ }
103
+ }
104
+
105
+ export { SYSTEM_MCP_REPO_OWNER, SYSTEM_MCP_REPO_NAME, SYSTEM_MCP_REPO_URL };
@@ -0,0 +1,116 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { getUserPaths, getPublicPaths } from './user-directories.js';
5
+
6
+ const SYSTEM_REPO_URL = 'git@git.amberweather.com:mcp-server/hupoer-skills.git';
7
+ const SYSTEM_REPO_OWNER = 'mcp-server';
8
+ const SYSTEM_REPO_NAME = 'hupoer-skills';
9
+
10
+ function cloneRepository(url, destinationPath) {
11
+ return new Promise((resolve, reject) => {
12
+ const gitProcess = spawn('git', ['clone', '--depth', '1', url, destinationPath], {
13
+ stdio: ['ignore', 'pipe', 'pipe'],
14
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
15
+ });
16
+
17
+ let stderr = '';
18
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
19
+
20
+ gitProcess.on('close', (code) => {
21
+ if (code === 0) resolve();
22
+ else reject(new Error(stderr || `Git clone failed with code ${code}`));
23
+ });
24
+
25
+ gitProcess.on('error', (err) => { reject(err); });
26
+ });
27
+ }
28
+
29
+ function updateRepository(repoPath) {
30
+ return new Promise((resolve, reject) => {
31
+ const gitProcess = spawn('git', ['pull', '--ff-only'], {
32
+ cwd: repoPath,
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
35
+ });
36
+
37
+ let stderr = '';
38
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
39
+
40
+ gitProcess.on('close', (code) => {
41
+ if (code === 0) resolve();
42
+ else reject(new Error(stderr || `Git pull failed with code ${code}`));
43
+ });
44
+
45
+ gitProcess.on('error', (err) => { reject(err); });
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Ensure the system repo is cloned to the shared public directory.
51
+ * If already cloned, attempts to pull latest changes.
52
+ * Returns the path to the public clone.
53
+ */
54
+ async function ensureSystemRepo() {
55
+ const publicPaths = getPublicPaths();
56
+ const publicRepoPath = path.join(publicPaths.skillsRepoDir, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME);
57
+
58
+ try {
59
+ await fs.access(publicRepoPath);
60
+ // Already cloned — try to update
61
+ try {
62
+ await updateRepository(publicRepoPath);
63
+ } catch (err) {
64
+ console.log('[SystemRepo] Failed to pull system repo, using existing clone:', err.message);
65
+ }
66
+ } catch {
67
+ // Not yet cloned
68
+ await fs.mkdir(path.dirname(publicRepoPath), { recursive: true });
69
+ try {
70
+ await cloneRepository(SYSTEM_REPO_URL, publicRepoPath);
71
+ console.log('[SystemRepo] Cloned system repo to', publicRepoPath);
72
+ } catch (err) {
73
+ console.error('[SystemRepo] Failed to clone system repo:', err.message);
74
+ throw err;
75
+ }
76
+ }
77
+
78
+ return publicRepoPath;
79
+ }
80
+
81
+ /**
82
+ * Initialize the system repo for a user.
83
+ * Idempotent: if the user already has the symlink, skip.
84
+ */
85
+ export async function initSystemRepoForUser(userUuid) {
86
+ const userPaths = getUserPaths(userUuid);
87
+ const userRepoPath = path.join(userPaths.skillsRepoDir, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME);
88
+
89
+ // If symlink/dir already exists, skip
90
+ try {
91
+ await fs.lstat(userRepoPath);
92
+ // Already set up — nothing to do
93
+ return;
94
+ } catch {
95
+ // Doesn't exist — proceed with setup
96
+ }
97
+
98
+ let publicRepoPath;
99
+ try {
100
+ publicRepoPath = await ensureSystemRepo();
101
+ } catch (err) {
102
+ console.error('[SystemRepo] Could not ensure system repo, skipping user init:', err.message);
103
+ return;
104
+ }
105
+
106
+ // Create user symlink
107
+ await fs.mkdir(path.dirname(userRepoPath), { recursive: true });
108
+ try {
109
+ await fs.symlink(publicRepoPath, userRepoPath);
110
+ console.log(`[SystemRepo] Linked system repo for user ${userUuid}`);
111
+ } catch (err) {
112
+ console.error(`[SystemRepo] Failed to create symlink for user ${userUuid}:`, err.message);
113
+ }
114
+ }
115
+
116
+ export { SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME, SYSTEM_REPO_URL };
@@ -1,6 +1,8 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
- import { initBuiltinSkills } from './builtin-skills.js';
3
+ import { readCodexMcpServers, formatMcpServersSection } from './codex-mcp.js';
4
+ import { initSystemRepoForUser } from './system-repo.js';
5
+ import { initSystemMcpRepoForUser } from './system-mcp-repo.js';
4
6
 
5
7
  // Base data directory (configurable via env)
6
8
  const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
@@ -26,6 +28,7 @@ export function getUserPaths(userUuid) {
26
28
  skillsDir: path.join(DATA_DIR, 'user-data', userUuid, '.claude', 'skills'),
27
29
  skillsImportDir: path.join(DATA_DIR, 'user-data', userUuid, 'skills-import'),
28
30
  skillsRepoDir: path.join(DATA_DIR, 'user-data', userUuid, 'skills-repo'),
31
+ mcpRepoDir: path.join(DATA_DIR, 'user-data', userUuid, 'mcp-repo'),
29
32
  codexHomeDir: path.join(DATA_DIR, 'user-data', userUuid, 'codex-home'),
30
33
  geminiHomeDir: path.join(DATA_DIR, 'user-data', userUuid, 'gemini-home'),
31
34
  };
@@ -37,6 +40,7 @@ export function getUserPaths(userUuid) {
37
40
  export function getPublicPaths() {
38
41
  return {
39
42
  skillsRepoDir: path.join(DATA_DIR, 'skills-repo'),
43
+ mcpRepoDir: path.join(DATA_DIR, 'mcp-repo'),
40
44
  };
41
45
  }
42
46
 
@@ -52,9 +56,12 @@ export async function initCodexDirectories(userUuid) {
52
56
 
53
57
  await fs.mkdir(codexDir, { recursive: true });
54
58
 
55
- // Write config.toml (overwrite to keep in sync with env)
59
+ // Write config.toml (overwrite to keep in sync with env, but preserve mcp_servers)
56
60
  const baseUrl = process.env.OPENAI_BASE_URL || '';
57
- const configToml = `model_provider = "custom"
61
+ const configTomlPath = path.join(codexDir, 'config.toml');
62
+
63
+ const existingMcpServers = await readCodexMcpServers(configTomlPath);
64
+ let configToml = `model_provider = "custom"
58
65
 
59
66
  [model_providers.custom]
60
67
  name = "custom"
@@ -62,7 +69,10 @@ wire_api = "responses"
62
69
  requires_openai_auth = true
63
70
  base_url = "${baseUrl}"
64
71
  `;
65
- await fs.writeFile(path.join(codexDir, 'config.toml'), configToml, 'utf8');
72
+ if (Object.keys(existingMcpServers).length > 0) {
73
+ configToml += '\n' + formatMcpServersSection(existingMcpServers) + '\n';
74
+ }
75
+ await fs.writeFile(configTomlPath, configToml, 'utf8');
66
76
 
67
77
  // Write auth.json only if it doesn't exist, to avoid overwriting user tokens
68
78
  const authJsonPath = path.join(codexDir, 'auth.json');
@@ -246,6 +256,12 @@ export async function initUserDirectories(userUuid) {
246
256
  await fs.mkdir(paths.skillsImportDir, { recursive: true });
247
257
  await fs.mkdir(paths.skillsRepoDir, { recursive: true });
248
258
 
259
+ // Create mcp-repo directory for the user
260
+ await fs.mkdir(paths.mcpRepoDir, { recursive: true });
261
+ // Ensure shared public mcp-repo directory exists
262
+ const publicPaths = getPublicPaths();
263
+ await fs.mkdir(publicPaths.mcpRepoDir, { recursive: true });
264
+
249
265
  // Initialize codex home directory with config files (after skillsDir exists for symlink)
250
266
  await initCodexDirectories(userUuid);
251
267
 
@@ -280,8 +296,11 @@ export async function initUserDirectories(userUuid) {
280
296
  await fs.writeFile(usageScanStatePath, JSON.stringify(scanState, null, 2));
281
297
  console.log(`Created .usage-scan-state.json for user ${userUuid}`);
282
298
 
283
- // Initialize built-in skills
284
- await initBuiltinSkills(userUuid);
299
+ // Initialize system repo (built-in skill repository)
300
+ await initSystemRepoForUser(userUuid);
301
+
302
+ // Initialize system MCP repo (built-in MCP repository)
303
+ await initSystemMcpRepoForUser(userUuid);
285
304
 
286
305
  return paths;
287
306
  }