@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.
- package/dist/assets/index-BFl_Dhvn.css +32 -0
- package/dist/assets/index-IiiL0NTz.js +151 -0
- package/dist/assets/{vendor-icons-CX_nKP5H.js → vendor-icons-CDkQcAKw.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/server/cli.js +44 -5
- package/server/index.js +4 -0
- package/server/middleware/auth.js +14 -1
- package/server/routes/mcp-repos.js +630 -0
- package/server/routes/mcp.js +11 -0
- package/server/routes/skills.js +10 -1
- package/server/services/codex-mcp.js +148 -0
- package/server/services/feishu/command-handler.js +2 -2
- package/server/services/feishu/feishu-engine.js +2 -2
- package/server/services/system-mcp-repo.js +105 -0
- package/server/services/user-directories.js +22 -3
- package/dist/assets/index-Cuc7jDbP.css +0 -32
- package/dist/assets/index-SA2TCeJ4.js +0 -151
|
@@ -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. 登录
|
|
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, '暂无项目,请在
|
|
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. 登录
|
|
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. 登录
|
|
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
|
|
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
|
-
|
|
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
|
|