@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.
- 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/auth.js +3 -3
- package/server/routes/mcp-repos.js +630 -0
- package/server/routes/mcp.js +11 -0
- package/server/routes/skills.js +20 -11
- 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/system-repo.js +116 -0
- package/server/services/user-directories.js +25 -6
- package/dist/assets/index-Cuc7jDbP.css +0 -32
- package/dist/assets/index-Dtp8bFzY.js +0 -151
- package/server/builtin-skills/.gitkeep +0 -0
- package/server/builtin-skills/html-deploy/SKILL.md +0 -40
- package/server/builtin-skills/html-deploy/scripts/delete.py +0 -31
- package/server/builtin-skills/html-deploy/scripts/deploy.py +0 -36
- package/server/builtin-skills/html-deploy/scripts/update.py +0 -38
- package/server/services/builtin-skills.js +0 -182
package/server/routes/skills.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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. 登录
|
|
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 };
|
|
@@ -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 {
|
|
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
|
|
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
|
|
|
@@ -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
|
|
284
|
-
await
|
|
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
|
}
|