@ian2018cs/agenthub 0.1.35 → 0.1.37

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.
@@ -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 };
@@ -1,6 +1,8 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import { readCodexMcpServers, formatMcpServersSection } from './codex-mcp.js';
3
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
 
@@ -283,6 +299,9 @@ export async function initUserDirectories(userUuid) {
283
299
  // Initialize system repo (built-in skill repository)
284
300
  await initSystemRepoForUser(userUuid);
285
301
 
302
+ // Initialize system MCP repo (built-in MCP repository)
303
+ await initSystemMcpRepoForUser(userUuid);
304
+
286
305
  return paths;
287
306
  }
288
307