@sswl/ai-manager 0.1.0
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/README.md +27 -0
- package/bin/ai-manager.js +8 -0
- package/lib/cli.js +919 -0
- package/lib/runtime.js +1441 -0
- package/package.json +17 -0
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,1441 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const cp = require("child_process");
|
|
8
|
+
|
|
9
|
+
const APP_ROOT = path.join(os.homedir(), ".sswl-ai-coding-platform");
|
|
10
|
+
const REPO_DIR = path.join(APP_ROOT, "repo");
|
|
11
|
+
const STATE_DIR = path.join(APP_ROOT, "state");
|
|
12
|
+
const CLI_STATE_DIR = path.join(STATE_DIR, "cli-manager");
|
|
13
|
+
const LEGACY_GUI_STATE_DIR = path.join(STATE_DIR, "gui-manager");
|
|
14
|
+
const RUNTIME_DIR = path.join(APP_ROOT, "runtime");
|
|
15
|
+
const LOG_DIR = path.join(APP_ROOT, "logs");
|
|
16
|
+
const GUI_INSTALL_DIR = path.join(CLI_STATE_DIR, "installations");
|
|
17
|
+
const PLAN_DIR = path.join(CLI_STATE_DIR, "activation-plans");
|
|
18
|
+
const PROJECTS_DIR = path.join(CLI_STATE_DIR, "projects");
|
|
19
|
+
const SETTINGS_FILE = path.join(CLI_STATE_DIR, "settings.json");
|
|
20
|
+
const LEGACY_SETTINGS_FILE = path.join(LEGACY_GUI_STATE_DIR, "settings.json");
|
|
21
|
+
const DEFAULT_REPO_URL = "http://172.19.105.131/common-components/sswl-ai-coding-platform.git";
|
|
22
|
+
const SUPPORTED_TOOLS = ["claude-code", "codex"];
|
|
23
|
+
const REPO_MARKERS = ["catalog", "registry", "scripts", "standards"];
|
|
24
|
+
|
|
25
|
+
const TOOL_DETECTORS = {
|
|
26
|
+
"claude-code": {
|
|
27
|
+
commands: ["claude"],
|
|
28
|
+
paths: [path.join(os.homedir(), ".claude")]
|
|
29
|
+
},
|
|
30
|
+
codex: {
|
|
31
|
+
commands: ["codex"],
|
|
32
|
+
paths: [path.join(os.homedir(), ".codex")]
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function ensureDir(dirPath) {
|
|
37
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureRuntimeDirs() {
|
|
41
|
+
[APP_ROOT, STATE_DIR, CLI_STATE_DIR, RUNTIME_DIR, LOG_DIR, GUI_INSTALL_DIR, PLAN_DIR, PROJECTS_DIR].forEach(ensureDir);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readJson(filePath, fallback = null) {
|
|
45
|
+
if (!fs.existsSync(filePath)) {
|
|
46
|
+
return fallback;
|
|
47
|
+
}
|
|
48
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeJson(filePath, value) {
|
|
52
|
+
ensureDir(path.dirname(filePath));
|
|
53
|
+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pathExists(targetPath) {
|
|
57
|
+
return Boolean(targetPath) && Boolean(fs.lstatSync(targetPath, { throwIfNoEntry: false }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isDirectory(targetPath) {
|
|
61
|
+
const stat = fs.lstatSync(targetPath, { throwIfNoEntry: false });
|
|
62
|
+
return Boolean(stat?.isDirectory());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function removePath(targetPath) {
|
|
66
|
+
if (!targetPath) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
70
|
+
const protectedPaths = new Set([
|
|
71
|
+
path.resolve("."),
|
|
72
|
+
path.resolve(__dirname, "..", "..", "..", ".."),
|
|
73
|
+
path.resolve(os.homedir()),
|
|
74
|
+
path.parse(resolvedTarget).root
|
|
75
|
+
]);
|
|
76
|
+
if (protectedPaths.has(resolvedTarget)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const stat = fs.lstatSync(targetPath, { throwIfNoEntry: false });
|
|
80
|
+
if (stat || fs.existsSync(targetPath)) {
|
|
81
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function runCommand(command, args, options = {}) {
|
|
86
|
+
const result = cp.spawnSync(command, args, {
|
|
87
|
+
encoding: "utf8",
|
|
88
|
+
...options
|
|
89
|
+
});
|
|
90
|
+
if (result.status !== 0) {
|
|
91
|
+
throw new Error((result.stderr || result.stdout || `${command} failed`).trim());
|
|
92
|
+
}
|
|
93
|
+
return (result.stdout || "").trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function streamCommand(command, args, options = {}, hooks = {}) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const child = cp.spawn(command, args, {
|
|
99
|
+
...options,
|
|
100
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
101
|
+
});
|
|
102
|
+
let stdout = "";
|
|
103
|
+
let stderr = "";
|
|
104
|
+
|
|
105
|
+
const emit = (chunk, source) => {
|
|
106
|
+
const text = chunk.toString();
|
|
107
|
+
const lines = text.replace(/\r/g, "").split("\n").filter(Boolean);
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
hooks.onLog?.(`[${source}] ${line}`);
|
|
110
|
+
}
|
|
111
|
+
return text;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
child.stdout.on("data", (chunk) => {
|
|
115
|
+
stdout += emit(chunk, command);
|
|
116
|
+
});
|
|
117
|
+
child.stderr.on("data", (chunk) => {
|
|
118
|
+
stderr += emit(chunk, command);
|
|
119
|
+
});
|
|
120
|
+
child.on("error", (error) => reject(error));
|
|
121
|
+
child.on("close", (code) => {
|
|
122
|
+
if (code === 0) {
|
|
123
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
reject(new Error((stderr || stdout || `${command} failed`).trim()));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findCommandPath(commandName) {
|
|
132
|
+
const lookupCommand = process.platform === "win32" ? "where" : "which";
|
|
133
|
+
const result = cp.spawnSync(lookupCommand, [commandName], { encoding: "utf8" });
|
|
134
|
+
if (result.status !== 0) {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
return String(result.stdout || "").split(/\r?\n/).find(Boolean) || "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function detectInstalledTool(tool) {
|
|
141
|
+
const detector = TOOL_DETECTORS[tool] || {};
|
|
142
|
+
const commandPath = (detector.commands || []).map(findCommandPath).find(Boolean) || "";
|
|
143
|
+
const detectedPath = (detector.paths || []).find((candidate) => candidate && pathExists(candidate)) || "";
|
|
144
|
+
return {
|
|
145
|
+
installed: Boolean(commandPath || detectedPath),
|
|
146
|
+
command_path: commandPath,
|
|
147
|
+
detected_path: detectedPath
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeAssetStatus(value) {
|
|
152
|
+
return String(value || "").trim().toLowerCase() === "release" ? "release" : "dev";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeArray(value) {
|
|
156
|
+
if (Array.isArray(value)) {
|
|
157
|
+
return value.filter((item) => item !== "[]" && item !== "");
|
|
158
|
+
}
|
|
159
|
+
if (value === undefined || value === null || value === "" || value === "[]") {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
return [String(value)];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function looksLikeRepoRoot(dirPath) {
|
|
166
|
+
return REPO_MARKERS.every((name) => pathExists(path.join(dirPath, name)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resolveRepoRoot(inputPath) {
|
|
170
|
+
if (!inputPath) {
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
let current = path.resolve(inputPath);
|
|
174
|
+
const stat = fs.lstatSync(current, { throwIfNoEntry: false });
|
|
175
|
+
if (!stat) {
|
|
176
|
+
return current;
|
|
177
|
+
}
|
|
178
|
+
if (!stat.isDirectory()) {
|
|
179
|
+
current = path.dirname(current);
|
|
180
|
+
}
|
|
181
|
+
while (true) {
|
|
182
|
+
if (looksLikeRepoRoot(current)) {
|
|
183
|
+
return current;
|
|
184
|
+
}
|
|
185
|
+
const parent = path.dirname(current);
|
|
186
|
+
if (parent === current) {
|
|
187
|
+
return path.resolve(inputPath);
|
|
188
|
+
}
|
|
189
|
+
current = parent;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function loadSettings() {
|
|
194
|
+
ensureRuntimeDirs();
|
|
195
|
+
const defaults = {
|
|
196
|
+
repo_url: DEFAULT_REPO_URL,
|
|
197
|
+
repo_dir: REPO_DIR,
|
|
198
|
+
current_scope: "project",
|
|
199
|
+
last_project_root: ""
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
...defaults,
|
|
203
|
+
...(readJson(LEGACY_SETTINGS_FILE, {}) || {}),
|
|
204
|
+
...(readJson(SETTINGS_FILE, {}) || {})
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function saveSettings(partial = {}) {
|
|
209
|
+
const next = {
|
|
210
|
+
...loadSettings(),
|
|
211
|
+
...Object.fromEntries(Object.entries(partial).filter(([, value]) => value !== undefined))
|
|
212
|
+
};
|
|
213
|
+
writeJson(SETTINGS_FILE, next);
|
|
214
|
+
return next;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function updateSettings(payload = {}) {
|
|
218
|
+
const settings = saveSettings({
|
|
219
|
+
repo_url: payload.repo_url ? String(payload.repo_url).trim() : undefined,
|
|
220
|
+
repo_dir: payload.repo_dir ? resolveRepoRoot(payload.repo_dir) : undefined,
|
|
221
|
+
current_scope: payload.current_scope || undefined,
|
|
222
|
+
last_project_root: Object.prototype.hasOwnProperty.call(payload, "last_project_root")
|
|
223
|
+
? (payload.last_project_root ? path.resolve(payload.last_project_root) : "")
|
|
224
|
+
: undefined
|
|
225
|
+
});
|
|
226
|
+
return { settings };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getRepoStatus(repoDir) {
|
|
230
|
+
if (!fs.existsSync(path.join(repoDir, ".git"))) {
|
|
231
|
+
return {
|
|
232
|
+
exists: false,
|
|
233
|
+
repo_dir: repoDir,
|
|
234
|
+
repo_url: DEFAULT_REPO_URL,
|
|
235
|
+
branch: "",
|
|
236
|
+
commit: "",
|
|
237
|
+
remote_commit: "",
|
|
238
|
+
dirty: false
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const branch = runCommand("git", ["-C", repoDir, "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
242
|
+
const commit = runCommand("git", ["-C", repoDir, "rev-parse", "HEAD"]);
|
|
243
|
+
const repoUrl = runCommand("git", ["-C", repoDir, "remote", "get-url", "origin"]);
|
|
244
|
+
const status = runCommand("git", ["-C", repoDir, "status", "--porcelain"]);
|
|
245
|
+
let remoteCommit = "";
|
|
246
|
+
try {
|
|
247
|
+
remoteCommit = runCommand("git", ["-C", repoDir, "rev-parse", "@{u}"]);
|
|
248
|
+
} catch (_error) {
|
|
249
|
+
remoteCommit = "";
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
exists: true,
|
|
253
|
+
repo_dir: repoDir,
|
|
254
|
+
repo_url: repoUrl,
|
|
255
|
+
branch,
|
|
256
|
+
commit,
|
|
257
|
+
remote_commit: remoteCommit,
|
|
258
|
+
dirty: Boolean(status)
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function enterpriseProbeTarget(repoUrl = "") {
|
|
263
|
+
const candidate = String(repoUrl || DEFAULT_REPO_URL).trim();
|
|
264
|
+
if (!/^https?:\/\//i.test(candidate)) {
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const parsed = new URL(candidate);
|
|
269
|
+
return `${parsed.protocol}//${parsed.host}/`;
|
|
270
|
+
} catch (_error) {
|
|
271
|
+
return "";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function checkEnterpriseReachability({ repoUrl = "", timeoutMs = 2500 } = {}) {
|
|
276
|
+
const target = enterpriseProbeTarget(repoUrl);
|
|
277
|
+
if (!target) {
|
|
278
|
+
return {
|
|
279
|
+
enforced: false,
|
|
280
|
+
reachable: true,
|
|
281
|
+
target: "",
|
|
282
|
+
checked_at: new Date().toISOString(),
|
|
283
|
+
status_label: "未配置企业网络探针",
|
|
284
|
+
error: ""
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const controller = new AbortController();
|
|
288
|
+
const timer = setTimeout(() => controller.abort(), Math.max(500, Number(timeoutMs) || 2500));
|
|
289
|
+
try {
|
|
290
|
+
const response = await fetch(target, {
|
|
291
|
+
method: "GET",
|
|
292
|
+
redirect: "manual",
|
|
293
|
+
signal: controller.signal,
|
|
294
|
+
headers: { "cache-control": "no-cache" }
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
enforced: true,
|
|
298
|
+
reachable: true,
|
|
299
|
+
target,
|
|
300
|
+
checked_at: new Date().toISOString(),
|
|
301
|
+
status_label: "企业网络可达",
|
|
302
|
+
status_code: response.status,
|
|
303
|
+
error: ""
|
|
304
|
+
};
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return {
|
|
307
|
+
enforced: true,
|
|
308
|
+
reachable: false,
|
|
309
|
+
target,
|
|
310
|
+
checked_at: new Date().toISOString(),
|
|
311
|
+
status_label: "未连接企业 VPN / 内网",
|
|
312
|
+
error: error.name === "AbortError" ? "网络探测超时" : (error.message || String(error))
|
|
313
|
+
};
|
|
314
|
+
} finally {
|
|
315
|
+
clearTimeout(timer);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function assertEnterpriseReachable({ repoUrl = "", timeoutMs = 2500 } = {}) {
|
|
320
|
+
const result = await checkEnterpriseReachability({ repoUrl, timeoutMs });
|
|
321
|
+
if (!result.enforced || result.reachable) {
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
throw new Error([
|
|
325
|
+
"当前未连接企业 VPN / 内网,已阻止继续操作。",
|
|
326
|
+
`探测地址:${result.target || "(empty)"}`,
|
|
327
|
+
result.error ? `原因:${result.error}` : ""
|
|
328
|
+
].filter(Boolean).join("\n"));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function nodeScriptCommand(scriptPath, repoDir) {
|
|
332
|
+
return {
|
|
333
|
+
command: process.execPath,
|
|
334
|
+
args: [scriptPath],
|
|
335
|
+
label: `node ${scriptPath}`,
|
|
336
|
+
options: {
|
|
337
|
+
env: {
|
|
338
|
+
...process.env,
|
|
339
|
+
SSWL_CONTROL_PLANE_REPO_ROOT: repoDir
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function platformScriptCommand(scriptBaseName, repoDir) {
|
|
346
|
+
const jsScriptPath = path.join(repoDir, "scripts", `${scriptBaseName}.js`);
|
|
347
|
+
if (!pathExists(jsScriptPath)) {
|
|
348
|
+
throw new Error(`仓库缺少脚本:${jsScriptPath}`);
|
|
349
|
+
}
|
|
350
|
+
return nodeScriptCommand(jsScriptPath, repoDir);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function ensureBaseDistDirs(repoRoot) {
|
|
354
|
+
ensureDir(path.join(repoRoot, "dist"));
|
|
355
|
+
for (const tool of SUPPORTED_TOOLS) {
|
|
356
|
+
ensureDir(path.join(repoRoot, "dist", tool));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function syncRepo({ repoUrl = "", repoDir = "" } = {}, hooks = {}) {
|
|
361
|
+
ensureRuntimeDirs();
|
|
362
|
+
const currentSettings = loadSettings();
|
|
363
|
+
const requestedRepoUrl = repoUrl ? String(repoUrl).trim() : currentSettings.repo_url || DEFAULT_REPO_URL;
|
|
364
|
+
await assertEnterpriseReachable({ repoUrl: requestedRepoUrl });
|
|
365
|
+
const requestedRepoDir = resolveRepoRoot(repoDir || currentSettings.repo_dir || REPO_DIR);
|
|
366
|
+
const settings = saveSettings({
|
|
367
|
+
repo_url: requestedRepoUrl,
|
|
368
|
+
repo_dir: requestedRepoDir
|
|
369
|
+
});
|
|
370
|
+
const resolvedRepoUrl = settings.repo_url;
|
|
371
|
+
const resolvedRepoDir = settings.repo_dir;
|
|
372
|
+
const reportProgress = (percent, stage, detail = "") => hooks.onProgress?.({ percent, stage, detail });
|
|
373
|
+
|
|
374
|
+
if (!fs.existsSync(path.join(resolvedRepoDir, ".git"))) {
|
|
375
|
+
ensureDir(path.dirname(resolvedRepoDir));
|
|
376
|
+
reportProgress(8, "clone", "开始克隆仓库");
|
|
377
|
+
hooks.onLog?.(`[sync] git clone ${resolvedRepoUrl} ${resolvedRepoDir}`);
|
|
378
|
+
await streamCommand("git", ["clone", resolvedRepoUrl, resolvedRepoDir], {}, hooks);
|
|
379
|
+
} else {
|
|
380
|
+
const currentOrigin = runCommand("git", ["-C", resolvedRepoDir, "remote", "get-url", "origin"]);
|
|
381
|
+
if (resolvedRepoUrl && currentOrigin !== resolvedRepoUrl) {
|
|
382
|
+
hooks.onLog?.(`[sync] git remote set-url origin ${resolvedRepoUrl}`);
|
|
383
|
+
runCommand("git", ["-C", resolvedRepoDir, "remote", "set-url", "origin", resolvedRepoUrl]);
|
|
384
|
+
}
|
|
385
|
+
reportProgress(18, "fetch", "开始拉取远端变更");
|
|
386
|
+
hooks.onLog?.(`[sync] git -C ${resolvedRepoDir} fetch --all --prune`);
|
|
387
|
+
await streamCommand("git", ["-C", resolvedRepoDir, "fetch", "--all", "--prune"], {}, hooks);
|
|
388
|
+
reportProgress(40, "pull", "开始合并远端变更");
|
|
389
|
+
hooks.onLog?.(`[sync] git -C ${resolvedRepoDir} pull --ff-only`);
|
|
390
|
+
await streamCommand("git", ["-C", resolvedRepoDir, "pull", "--ff-only"], {}, hooks);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!looksLikeRepoRoot(resolvedRepoDir)) {
|
|
394
|
+
throw new Error(`所选目录不是控制面仓库根目录:${resolvedRepoDir}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
ensureBaseDistDirs(resolvedRepoDir);
|
|
398
|
+
reportProgress(62, "validate", "开始校验控制面");
|
|
399
|
+
const validateCommand = platformScriptCommand("validate-control-plane", resolvedRepoDir);
|
|
400
|
+
hooks.onLog?.(`[sync] ${validateCommand.label}`);
|
|
401
|
+
await streamCommand(validateCommand.command, validateCommand.args, { cwd: resolvedRepoDir, ...(validateCommand.options || {}) }, hooks);
|
|
402
|
+
reportProgress(82, "build", "开始构建分发产物");
|
|
403
|
+
const buildCommand = platformScriptCommand("build-distributions", resolvedRepoDir);
|
|
404
|
+
hooks.onLog?.(`[sync] ${buildCommand.label}`);
|
|
405
|
+
await streamCommand(buildCommand.command, buildCommand.args, { cwd: resolvedRepoDir, ...(buildCommand.options || {}) }, hooks);
|
|
406
|
+
reportProgress(100, "done", "同步完成");
|
|
407
|
+
return getAppState({ repoDir: resolvedRepoDir });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function parseSimpleYaml(filePath) {
|
|
411
|
+
const raw = fs.readFileSync(filePath, "utf8").replace(/\r/g, "");
|
|
412
|
+
const lines = raw.split("\n");
|
|
413
|
+
const result = {};
|
|
414
|
+
let currentArrayKey = null;
|
|
415
|
+
for (const line of lines) {
|
|
416
|
+
if (!line.trim() || line.trim().startsWith("#")) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const topMatch = line.match(/^([a-zA-Z0-9_-]+):(?:\s+(.*))?$/);
|
|
420
|
+
if (topMatch) {
|
|
421
|
+
currentArrayKey = null;
|
|
422
|
+
const key = topMatch[1];
|
|
423
|
+
const value = (topMatch[2] || "").trim();
|
|
424
|
+
if (value) {
|
|
425
|
+
result[key] = value;
|
|
426
|
+
} else {
|
|
427
|
+
result[key] = [];
|
|
428
|
+
currentArrayKey = key;
|
|
429
|
+
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const listMatch = line.match(/^\s{2}-\s+(.*)$/);
|
|
433
|
+
if (listMatch && currentArrayKey) {
|
|
434
|
+
result[currentArrayKey].push(listMatch[1].trim());
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function parseAssetIndex(repoRoot) {
|
|
441
|
+
const filePath = path.join(repoRoot, "registry", "assets", "index.yaml");
|
|
442
|
+
const lines = fs.readFileSync(filePath, "utf8").replace(/\r/g, "").split("\n");
|
|
443
|
+
const items = [];
|
|
444
|
+
let current = null;
|
|
445
|
+
for (const line of lines) {
|
|
446
|
+
const idMatch = line.match(/^ - id: (.+)$/);
|
|
447
|
+
if (idMatch) {
|
|
448
|
+
current = { id: idMatch[1].trim() };
|
|
449
|
+
items.push(current);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const fieldMatch = line.match(/^ ([a-z_]+): (.+)$/);
|
|
453
|
+
if (fieldMatch && current) {
|
|
454
|
+
current[fieldMatch[1]] = fieldMatch[2].trim();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return items;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function parseProfiles(repoRoot) {
|
|
461
|
+
const filePath = path.join(repoRoot, "registry", "distributions", "profiles.yaml");
|
|
462
|
+
const lines = fs.readFileSync(filePath, "utf8").replace(/\r/g, "").split("\n");
|
|
463
|
+
const profiles = [];
|
|
464
|
+
let current = null;
|
|
465
|
+
let section = null;
|
|
466
|
+
for (const line of lines) {
|
|
467
|
+
const idMatch = line.match(/^ - id: (.+)$/);
|
|
468
|
+
if (idMatch) {
|
|
469
|
+
current = { id: idMatch[1].trim(), supported_tools: [], asset_ids: [] };
|
|
470
|
+
profiles.push(current);
|
|
471
|
+
section = null;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (!current) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const fieldMatch = line.match(/^ ([a-z_]+): (.+)$/);
|
|
478
|
+
if (fieldMatch) {
|
|
479
|
+
current[fieldMatch[1]] = fieldMatch[2].trim();
|
|
480
|
+
section = null;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const sectionMatch = line.match(/^ ([a-z_]+):$/);
|
|
484
|
+
if (sectionMatch) {
|
|
485
|
+
section = sectionMatch[1];
|
|
486
|
+
if (!Array.isArray(current[section])) {
|
|
487
|
+
current[section] = [];
|
|
488
|
+
}
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const itemMatch = line.match(/^ - (.+)$/);
|
|
492
|
+
if (itemMatch && section && Array.isArray(current[section])) {
|
|
493
|
+
current[section].push(itemMatch[1].trim());
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return profiles;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function readContentPreview(assetDir, manifest) {
|
|
500
|
+
const contentFile = manifest.content_file;
|
|
501
|
+
if (!contentFile) {
|
|
502
|
+
return "";
|
|
503
|
+
}
|
|
504
|
+
const targetFile = path.join(assetDir, contentFile);
|
|
505
|
+
if (!pathExists(targetFile)) {
|
|
506
|
+
return "";
|
|
507
|
+
}
|
|
508
|
+
return fs.readFileSync(targetFile, "utf8")
|
|
509
|
+
.replace(/\r/g, "")
|
|
510
|
+
.split("\n")
|
|
511
|
+
.filter((line) => line.trim() && !line.trim().startsWith("---"))
|
|
512
|
+
.slice(0, 20)
|
|
513
|
+
.join("\n");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function readAssetCatalog(repoRoot) {
|
|
517
|
+
const indexItems = parseAssetIndex(repoRoot);
|
|
518
|
+
return indexItems.map((item) => {
|
|
519
|
+
const assetDir = path.join(repoRoot, item.path);
|
|
520
|
+
const manifest = parseSimpleYaml(path.join(assetDir, "manifest.yaml"));
|
|
521
|
+
const exposure = manifest.exposure || "public";
|
|
522
|
+
const installMode = manifest.install_mode || (exposure === "internal" ? "dependency-only" : "direct");
|
|
523
|
+
const status = normalizeAssetStatus(item.status || manifest.status);
|
|
524
|
+
return {
|
|
525
|
+
id: item.id,
|
|
526
|
+
type: item.type,
|
|
527
|
+
layer: item.layer,
|
|
528
|
+
domain: item.domain,
|
|
529
|
+
status,
|
|
530
|
+
risk_level: item.risk_level,
|
|
531
|
+
exposure,
|
|
532
|
+
install_mode: installMode,
|
|
533
|
+
installable: exposure !== "internal" && installMode !== "dependency-only",
|
|
534
|
+
path: item.path,
|
|
535
|
+
name: manifest.name || item.id,
|
|
536
|
+
version: manifest.version || "",
|
|
537
|
+
description: manifest.description || "",
|
|
538
|
+
owner: manifest.owner || "",
|
|
539
|
+
audience: normalizeArray(manifest.audience),
|
|
540
|
+
dependencies: normalizeArray(manifest.dependencies),
|
|
541
|
+
supported_tools: normalizeArray(manifest.supported_tools),
|
|
542
|
+
content_file: manifest.content_file || "",
|
|
543
|
+
content_preview: readContentPreview(assetDir, manifest)
|
|
544
|
+
};
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function resolveAssetDependencySet(assets, assetIds) {
|
|
549
|
+
const assetMap = new Map(assets.map((asset) => [asset.id, asset]));
|
|
550
|
+
const resolved = new Set();
|
|
551
|
+
const queue = [...new Set(assetIds)];
|
|
552
|
+
while (queue.length > 0) {
|
|
553
|
+
const assetId = queue.shift();
|
|
554
|
+
if (resolved.has(assetId)) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
resolved.add(assetId);
|
|
558
|
+
for (const dependencyId of normalizeArray(assetMap.get(assetId)?.dependencies)) {
|
|
559
|
+
if (!resolved.has(dependencyId)) {
|
|
560
|
+
queue.push(dependencyId);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return [...resolved].sort();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function defaultInstallRoot(tool) {
|
|
568
|
+
if (tool === "codex") {
|
|
569
|
+
return path.join(os.homedir(), ".codex", "skills");
|
|
570
|
+
}
|
|
571
|
+
if (tool === "claude-code") {
|
|
572
|
+
return path.join(os.homedir(), ".claude", "skills");
|
|
573
|
+
}
|
|
574
|
+
return "";
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function defaultProjectInstallRoot(projectRoot, tool) {
|
|
578
|
+
if (!projectRoot) {
|
|
579
|
+
return "";
|
|
580
|
+
}
|
|
581
|
+
if (tool === "claude-code") {
|
|
582
|
+
return path.resolve(projectRoot);
|
|
583
|
+
}
|
|
584
|
+
if (tool === "codex") {
|
|
585
|
+
return path.join(path.resolve(projectRoot), ".codex");
|
|
586
|
+
}
|
|
587
|
+
return "";
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function usesNativeProjectLayout(tool) {
|
|
591
|
+
return tool === "claude-code" || tool === "codex";
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function defaultLinkName() {
|
|
595
|
+
return "sswl-ai-coding-platform";
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function loadToolInstallState(tool) {
|
|
599
|
+
const primary = readJson(path.join(GUI_INSTALL_DIR, `${tool}.json`), null);
|
|
600
|
+
if (primary) {
|
|
601
|
+
return primary;
|
|
602
|
+
}
|
|
603
|
+
return readJson(path.join(LEGACY_GUI_STATE_DIR, "installations", `${tool}.json`), null);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function loadActivationPlan(tool) {
|
|
607
|
+
const primary = readJson(path.join(PLAN_DIR, `${tool}.json`), null);
|
|
608
|
+
if (primary) {
|
|
609
|
+
return primary;
|
|
610
|
+
}
|
|
611
|
+
return readJson(path.join(LEGACY_GUI_STATE_DIR, "activation-plans", `${tool}.json`), null);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function computePlanId(tool, assetIds) {
|
|
615
|
+
return `custom-${tool}-${crypto.createHash("sha1").update(`${tool}:${assetIds.slice().sort().join(",")}`).digest("hex").slice(0, 10)}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function projectIdForRoot(projectRoot) {
|
|
619
|
+
return crypto.createHash("sha1").update(path.resolve(projectRoot)).digest("hex").slice(0, 12);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function getProjectStateFile(projectRoot) {
|
|
623
|
+
const projectId = projectIdForRoot(projectRoot);
|
|
624
|
+
return {
|
|
625
|
+
projectId,
|
|
626
|
+
filePath: path.join(PROJECTS_DIR, projectId, "project.json"),
|
|
627
|
+
legacyFilePath: path.join(LEGACY_GUI_STATE_DIR, "projects", projectId, "project.json")
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function loadProjectState(projectRoot) {
|
|
632
|
+
if (!projectRoot) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
const resolvedRoot = path.resolve(projectRoot);
|
|
636
|
+
const { projectId, filePath, legacyFilePath } = getProjectStateFile(resolvedRoot);
|
|
637
|
+
return readJson(filePath, null) || readJson(legacyFilePath, {
|
|
638
|
+
project_id: projectId,
|
|
639
|
+
project_root: resolvedRoot,
|
|
640
|
+
tools: {}
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function saveProjectState(projectRoot, data) {
|
|
645
|
+
const resolvedRoot = path.resolve(projectRoot);
|
|
646
|
+
const { filePath } = getProjectStateFile(resolvedRoot);
|
|
647
|
+
writeJson(filePath, data);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function listChildDirectories(dirPath) {
|
|
651
|
+
if (!pathExists(dirPath)) {
|
|
652
|
+
return [];
|
|
653
|
+
}
|
|
654
|
+
return fs.readdirSync(dirPath, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function listChildFiles(dirPath, extension = ".md") {
|
|
658
|
+
if (!pathExists(dirPath)) {
|
|
659
|
+
return [];
|
|
660
|
+
}
|
|
661
|
+
return fs.readdirSync(dirPath, { withFileTypes: true })
|
|
662
|
+
.filter((entry) => entry.isFile() && (!extension || entry.name.endsWith(extension)))
|
|
663
|
+
.map((entry) => entry.name)
|
|
664
|
+
.sort();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function findPrimaryMarkdownFile(dirPath) {
|
|
668
|
+
const files = listChildFiles(dirPath, ".md").filter((name) => name !== "CLAUDE.md");
|
|
669
|
+
if (!files.length) {
|
|
670
|
+
return "";
|
|
671
|
+
}
|
|
672
|
+
const exactMatch = files.find((name) => path.basename(name, ".md") === path.basename(dirPath));
|
|
673
|
+
return path.join(dirPath, exactMatch || files[0]);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function copyOptionalRuntimeDirs(sourcePath, targetPath) {
|
|
677
|
+
for (const optionalName of ["assets", "references", "scripts"]) {
|
|
678
|
+
const optionalSource = path.join(sourcePath, optionalName);
|
|
679
|
+
if (pathExists(optionalSource)) {
|
|
680
|
+
fs.cpSync(optionalSource, path.join(targetPath, optionalName), { recursive: true });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function copySkillRuntime(sourcePath, targetPath) {
|
|
686
|
+
removePath(targetPath);
|
|
687
|
+
ensureDir(targetPath);
|
|
688
|
+
const skillFile = path.join(sourcePath, "SKILL.md");
|
|
689
|
+
if (!pathExists(skillFile)) {
|
|
690
|
+
throw new Error(`skill missing SKILL.md: ${sourcePath}`);
|
|
691
|
+
}
|
|
692
|
+
fs.copyFileSync(skillFile, path.join(targetPath, "SKILL.md"));
|
|
693
|
+
copyOptionalRuntimeDirs(sourcePath, targetPath);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function copyMarkdownAssetAsSkill(sourcePath, targetPath, assetType) {
|
|
697
|
+
const sourceFile = findPrimaryMarkdownFile(sourcePath);
|
|
698
|
+
if (!sourceFile) {
|
|
699
|
+
throw new Error(`${assetType} missing primary markdown file: ${sourcePath}`);
|
|
700
|
+
}
|
|
701
|
+
removePath(targetPath);
|
|
702
|
+
ensureDir(targetPath);
|
|
703
|
+
fs.copyFileSync(sourceFile, path.join(targetPath, "SKILL.md"));
|
|
704
|
+
copyOptionalRuntimeDirs(sourcePath, targetPath);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function renderCustomPackage({ repoDir, tool, assetIds, planId, runtimeRoot = "" }) {
|
|
708
|
+
const assets = readAssetCatalog(repoDir);
|
|
709
|
+
const assetMap = new Map(assets.map((asset) => [asset.id, asset]));
|
|
710
|
+
const packageDir = path.join(runtimeRoot || path.join(RUNTIME_DIR, tool), planId);
|
|
711
|
+
removePath(packageDir);
|
|
712
|
+
for (const dirName of ["skills", "commands", "agents", "plugins", "mcp-servers"]) {
|
|
713
|
+
ensureDir(path.join(packageDir, dirName));
|
|
714
|
+
}
|
|
715
|
+
for (const assetId of assetIds) {
|
|
716
|
+
const asset = assetMap.get(assetId);
|
|
717
|
+
if (!asset) {
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
const sourcePath = path.join(repoDir, asset.path);
|
|
721
|
+
let targetDir = "";
|
|
722
|
+
if (asset.type === "skill") {
|
|
723
|
+
targetDir = path.join(packageDir, "skills");
|
|
724
|
+
} else if (asset.type === "command") {
|
|
725
|
+
targetDir = tool === "codex" ? path.join(packageDir, "skills") : path.join(packageDir, "commands");
|
|
726
|
+
} else if (asset.type === "agent") {
|
|
727
|
+
targetDir = tool === "codex" ? path.join(packageDir, "skills") : path.join(packageDir, "agents");
|
|
728
|
+
} else if (asset.type === "plugin") {
|
|
729
|
+
targetDir = path.join(packageDir, "plugins");
|
|
730
|
+
} else if (asset.type === "mcp_server") {
|
|
731
|
+
targetDir = path.join(packageDir, "mcp-servers");
|
|
732
|
+
}
|
|
733
|
+
if (!targetDir) {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
const targetPath = path.join(targetDir, path.basename(sourcePath));
|
|
737
|
+
if (asset.type === "skill") {
|
|
738
|
+
copySkillRuntime(sourcePath, targetPath);
|
|
739
|
+
} else if (tool === "codex" && (asset.type === "command" || asset.type === "agent")) {
|
|
740
|
+
copyMarkdownAssetAsSkill(sourcePath, targetPath, asset.type);
|
|
741
|
+
} else {
|
|
742
|
+
fs.cpSync(sourcePath, targetPath, { recursive: true });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
writeJson(path.join(packageDir, "bundle-metadata.json"), {
|
|
746
|
+
tool,
|
|
747
|
+
plan_id: planId,
|
|
748
|
+
asset_ids: assetIds
|
|
749
|
+
});
|
|
750
|
+
return packageDir;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function applyLink({ targetLink, sourcePath }) {
|
|
754
|
+
ensureDir(path.dirname(targetLink));
|
|
755
|
+
removePath(targetLink);
|
|
756
|
+
try {
|
|
757
|
+
fs.symlinkSync(sourcePath, targetLink);
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "UNKNOWN"].includes(error.code)) {
|
|
760
|
+
throw error;
|
|
761
|
+
}
|
|
762
|
+
fs.cpSync(sourcePath, targetLink, { recursive: true });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function claudeGlobalRootFromSkillsRoot(skillsRoot) {
|
|
767
|
+
return path.basename(skillsRoot) === "skills" ? path.dirname(skillsRoot) : path.join(os.homedir(), ".claude");
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function installClaudeGlobalCommands({ packageDir, installRoot }, hooks = {}) {
|
|
771
|
+
const commandsSource = path.join(packageDir, "commands");
|
|
772
|
+
const commandNames = listChildDirectories(commandsSource);
|
|
773
|
+
if (!commandNames.length) {
|
|
774
|
+
return [];
|
|
775
|
+
}
|
|
776
|
+
const claudeRoot = claudeGlobalRootFromSkillsRoot(installRoot);
|
|
777
|
+
const commandsTarget = path.join(claudeRoot, "commands");
|
|
778
|
+
ensureDir(commandsTarget);
|
|
779
|
+
const createdLinks = [];
|
|
780
|
+
for (const commandName of commandNames) {
|
|
781
|
+
const sourceFile = findPrimaryMarkdownFile(path.join(commandsSource, commandName));
|
|
782
|
+
if (!sourceFile) {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const targetFile = path.join(commandsTarget, `${commandName}.md`);
|
|
786
|
+
hooks.onLog?.(`[apply] claude command ${targetFile} <- ${sourceFile}`);
|
|
787
|
+
removePath(targetFile);
|
|
788
|
+
fs.copyFileSync(sourceFile, targetFile);
|
|
789
|
+
createdLinks.push(targetFile);
|
|
790
|
+
}
|
|
791
|
+
return createdLinks;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function mcpNameFromManifest(manifest, serverDir) {
|
|
795
|
+
return manifest.mcp_name || manifest.server_name || manifest.id || path.basename(serverDir);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function buildMcpServerConfig(serverDir) {
|
|
799
|
+
const manifestPath = path.join(serverDir, "manifest.yaml");
|
|
800
|
+
if (!pathExists(manifestPath)) {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
const manifest = parseSimpleYaml(manifestPath);
|
|
804
|
+
const name = mcpNameFromManifest(manifest, serverDir);
|
|
805
|
+
const serverEntry = manifest.server_entry || "server.js";
|
|
806
|
+
return {
|
|
807
|
+
name,
|
|
808
|
+
config: {
|
|
809
|
+
command: manifest.command || "node",
|
|
810
|
+
args: [path.join(serverDir, serverEntry)]
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function updateProjectMcpConfig(projectRoot, mcpServerDirs) {
|
|
816
|
+
const mcpFile = path.join(projectRoot, ".mcp.json");
|
|
817
|
+
const current = readJson(mcpFile, {}) || {};
|
|
818
|
+
const next = { ...current, mcpServers: { ...(current.mcpServers || {}) } };
|
|
819
|
+
const managedNames = [];
|
|
820
|
+
for (const serverDir of mcpServerDirs) {
|
|
821
|
+
const serverConfig = buildMcpServerConfig(serverDir);
|
|
822
|
+
if (!serverConfig) {
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
next.mcpServers[serverConfig.name] = serverConfig.config;
|
|
826
|
+
managedNames.push(serverConfig.name);
|
|
827
|
+
}
|
|
828
|
+
if (!Object.keys(next.mcpServers).length) {
|
|
829
|
+
return { file: "", managedNames: [] };
|
|
830
|
+
}
|
|
831
|
+
writeJson(mcpFile, next);
|
|
832
|
+
return { file: mcpFile, managedNames: managedNames.sort() };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function removeManagedMcpConfig(projectRoot, managedNames = []) {
|
|
836
|
+
const mcpFile = path.join(projectRoot, ".mcp.json");
|
|
837
|
+
const current = readJson(mcpFile, null);
|
|
838
|
+
if (!current?.mcpServers) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
for (const serverName of managedNames) {
|
|
842
|
+
delete current.mcpServers[serverName];
|
|
843
|
+
}
|
|
844
|
+
if (!Object.keys(current.mcpServers).length) {
|
|
845
|
+
delete current.mcpServers;
|
|
846
|
+
}
|
|
847
|
+
if (!Object.keys(current).length) {
|
|
848
|
+
removePath(mcpFile);
|
|
849
|
+
} else {
|
|
850
|
+
writeJson(mcpFile, current);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function copyMcpRuntime(packageDir, projectRoot, managedRoot, hooks = {}) {
|
|
855
|
+
const mcpServersSource = path.join(packageDir, "mcp-servers");
|
|
856
|
+
const mcpServerTargets = [];
|
|
857
|
+
if (pathExists(mcpServersSource)) {
|
|
858
|
+
const mcpServersTarget = path.join(managedRoot, "mcp-servers");
|
|
859
|
+
removePath(mcpServersTarget);
|
|
860
|
+
ensureDir(mcpServersTarget);
|
|
861
|
+
for (const serverName of listChildDirectories(mcpServersSource)) {
|
|
862
|
+
const sourceDir = path.join(mcpServersSource, serverName);
|
|
863
|
+
const targetDir = path.join(mcpServersTarget, serverName);
|
|
864
|
+
fs.cpSync(sourceDir, targetDir, { recursive: true });
|
|
865
|
+
mcpServerTargets.push(targetDir);
|
|
866
|
+
}
|
|
867
|
+
hooks.onLog?.(`[apply] project mcp-servers -> ${mcpServersTarget}`);
|
|
868
|
+
}
|
|
869
|
+
const mcpConfig = updateProjectMcpConfig(projectRoot, mcpServerTargets);
|
|
870
|
+
if (mcpConfig.file) {
|
|
871
|
+
hooks.onLog?.(`[apply] project mcp config -> ${mcpConfig.file}`);
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
mcpConfigFile: mcpConfig.file,
|
|
875
|
+
mcpServerTargets,
|
|
876
|
+
managedMcpNames: mcpConfig.managedNames
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function replaceManagedBlock(filePath, blockId, content) {
|
|
881
|
+
const startMarker = `<!-- ${blockId}:START -->`;
|
|
882
|
+
const endMarker = `<!-- ${blockId}:END -->`;
|
|
883
|
+
const existing = pathExists(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
884
|
+
const block = content.trim() ? `${startMarker}\n${content.trim()}\n${endMarker}` : "";
|
|
885
|
+
const pattern = new RegExp(`\\n?${startMarker}[\\s\\S]*?${endMarker}\\n?`, "g");
|
|
886
|
+
let next = existing.replace(pattern, "").trimEnd();
|
|
887
|
+
if (block) {
|
|
888
|
+
next = next ? `${next}\n\n${block}\n` : `${block}\n`;
|
|
889
|
+
} else if (next) {
|
|
890
|
+
next = `${next}\n`;
|
|
891
|
+
}
|
|
892
|
+
if (next) {
|
|
893
|
+
fs.writeFileSync(filePath, next);
|
|
894
|
+
} else {
|
|
895
|
+
removePath(filePath);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function buildCodexProjectLayout(packageDir, projectRoot, assetIds, previousInstallState = null, hooks = {}) {
|
|
900
|
+
const managedRoot = path.join(projectRoot, ".codex", "sswl");
|
|
901
|
+
const skillsTarget = path.join(projectRoot, ".codex", "skills");
|
|
902
|
+
const commandsTarget = path.join(projectRoot, ".codex", "commands");
|
|
903
|
+
const agentsTarget = path.join(projectRoot, ".codex", "agents");
|
|
904
|
+
for (const targetSkillLink of previousInstallState?.target_skill_links || []) {
|
|
905
|
+
removePath(targetSkillLink);
|
|
906
|
+
}
|
|
907
|
+
removePath(skillsTarget);
|
|
908
|
+
ensureDir(skillsTarget);
|
|
909
|
+
const skillLinks = [];
|
|
910
|
+
for (const skillName of listChildDirectories(path.join(packageDir, "skills"))) {
|
|
911
|
+
const sourceDir = path.join(packageDir, "skills", skillName);
|
|
912
|
+
const targetDir = path.join(skillsTarget, skillName);
|
|
913
|
+
copySkillRuntime(sourceDir, targetDir);
|
|
914
|
+
skillLinks.push(targetDir);
|
|
915
|
+
}
|
|
916
|
+
hooks.onLog?.(`[apply] codex project skills -> ${skillsTarget}`);
|
|
917
|
+
removePath(path.join(projectRoot, ".codex", "prompts"));
|
|
918
|
+
removePath(commandsTarget);
|
|
919
|
+
removePath(agentsTarget);
|
|
920
|
+
const runtime = copyMcpRuntime(packageDir, projectRoot, managedRoot, hooks);
|
|
921
|
+
return {
|
|
922
|
+
target_links: [...skillLinks, managedRoot, ...(runtime.mcpConfigFile ? [runtime.mcpConfigFile] : [])],
|
|
923
|
+
target_skill_links: skillLinks,
|
|
924
|
+
managed_mcp_names: runtime.managedMcpNames || [],
|
|
925
|
+
target_link: path.join(projectRoot, ".codex"),
|
|
926
|
+
source_path: packageDir,
|
|
927
|
+
asset_ids: assetIds
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function buildClaudeProjectMemory(packageDir, projectRoot, assetIds, previousInstallState = null, hooks = {}) {
|
|
932
|
+
const managedRoot = path.join(projectRoot, ".claude", "sswl");
|
|
933
|
+
const skillsTarget = path.join(projectRoot, ".claude", "skills");
|
|
934
|
+
const commandsTarget = path.join(projectRoot, ".claude", "commands");
|
|
935
|
+
const agentsTarget = path.join(projectRoot, ".claude", "agents");
|
|
936
|
+
const memoryDir = path.join(managedRoot, "memory");
|
|
937
|
+
for (const targetSkillLink of previousInstallState?.target_skill_links || []) {
|
|
938
|
+
removePath(targetSkillLink);
|
|
939
|
+
}
|
|
940
|
+
ensureDir(skillsTarget);
|
|
941
|
+
const skillLinks = [];
|
|
942
|
+
for (const skillName of listChildDirectories(path.join(packageDir, "skills"))) {
|
|
943
|
+
const sourceDir = path.join(packageDir, "skills", skillName);
|
|
944
|
+
const targetDir = path.join(skillsTarget, skillName);
|
|
945
|
+
hooks.onLog?.(`[apply] claude project skill ${targetDir} <- ${sourceDir}`);
|
|
946
|
+
copySkillRuntime(sourceDir, targetDir);
|
|
947
|
+
skillLinks.push(targetDir);
|
|
948
|
+
}
|
|
949
|
+
removePath(commandsTarget);
|
|
950
|
+
ensureDir(commandsTarget);
|
|
951
|
+
for (const commandName of listChildDirectories(path.join(packageDir, "commands"))) {
|
|
952
|
+
const sourceFile = findPrimaryMarkdownFile(path.join(packageDir, "commands", commandName));
|
|
953
|
+
if (sourceFile) {
|
|
954
|
+
fs.copyFileSync(sourceFile, path.join(commandsTarget, `${commandName}.md`));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
hooks.onLog?.(`[apply] claude project commands -> ${commandsTarget}`);
|
|
958
|
+
removePath(agentsTarget);
|
|
959
|
+
ensureDir(agentsTarget);
|
|
960
|
+
for (const agentName of listChildDirectories(path.join(packageDir, "agents"))) {
|
|
961
|
+
const sourceFile = findPrimaryMarkdownFile(path.join(packageDir, "agents", agentName));
|
|
962
|
+
if (sourceFile) {
|
|
963
|
+
fs.copyFileSync(sourceFile, path.join(agentsTarget, `${agentName}.md`));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
hooks.onLog?.(`[apply] claude project agents -> ${agentsTarget}`);
|
|
967
|
+
removePath(memoryDir);
|
|
968
|
+
const sections = [];
|
|
969
|
+
for (const pluginName of listChildDirectories(path.join(packageDir, "plugins"))) {
|
|
970
|
+
const sourceFile = path.join(packageDir, "plugins", pluginName, "CLAUDE.md");
|
|
971
|
+
if (!pathExists(sourceFile)) {
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
ensureDir(memoryDir);
|
|
975
|
+
fs.copyFileSync(sourceFile, path.join(memoryDir, `${pluginName}.md`));
|
|
976
|
+
sections.push(`- @.claude/sswl/memory/${pluginName}.md`);
|
|
977
|
+
}
|
|
978
|
+
const runtime = copyMcpRuntime(packageDir, projectRoot, managedRoot, hooks);
|
|
979
|
+
const memoryBlock = sections.length
|
|
980
|
+
? ["# SSWL Managed Imports", "", "以下内容由 SSWL AI Manager 自动维护,请勿手工编辑此区块内的导入列表。", "", ...sections].join("\n")
|
|
981
|
+
: "";
|
|
982
|
+
replaceManagedBlock(path.join(projectRoot, "CLAUDE.md"), "SSWL-MANAGED-IMPORTS", memoryBlock);
|
|
983
|
+
if (!sections.length && !pathExists(path.join(managedRoot, "mcp-servers"))) {
|
|
984
|
+
removePath(managedRoot);
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
target_links: [
|
|
988
|
+
...skillLinks,
|
|
989
|
+
commandsTarget,
|
|
990
|
+
agentsTarget,
|
|
991
|
+
...(runtime.mcpConfigFile ? [runtime.mcpConfigFile] : []),
|
|
992
|
+
path.join(projectRoot, "CLAUDE.md"),
|
|
993
|
+
managedRoot
|
|
994
|
+
],
|
|
995
|
+
target_skill_links: skillLinks,
|
|
996
|
+
managed_mcp_names: runtime.managedMcpNames || [],
|
|
997
|
+
target_link: path.join(projectRoot, ".claude"),
|
|
998
|
+
source_path: packageDir,
|
|
999
|
+
asset_ids: assetIds
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function removeClaudeProjectLayout(projectRoot, installState = null) {
|
|
1004
|
+
for (const targetSkillLink of installState?.target_skill_links || []) {
|
|
1005
|
+
removePath(targetSkillLink);
|
|
1006
|
+
}
|
|
1007
|
+
removePath(path.join(projectRoot, ".claude", "commands"));
|
|
1008
|
+
removePath(path.join(projectRoot, ".claude", "agents"));
|
|
1009
|
+
removePath(path.join(projectRoot, ".claude", "sswl"));
|
|
1010
|
+
removeManagedMcpConfig(projectRoot, installState?.managed_mcp_names || []);
|
|
1011
|
+
replaceManagedBlock(path.join(projectRoot, "CLAUDE.md"), "SSWL-MANAGED-IMPORTS", "");
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function removeCodexProjectLayout(projectRoot, installState = null) {
|
|
1015
|
+
for (const targetSkillLink of installState?.target_skill_links || []) {
|
|
1016
|
+
removePath(targetSkillLink);
|
|
1017
|
+
}
|
|
1018
|
+
removePath(path.join(projectRoot, ".codex"));
|
|
1019
|
+
removeManagedMcpConfig(projectRoot, installState?.managed_mcp_names || []);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function applyInstallLayout({ tool, installRoot, linkName, linkTarget, packageDir, sourcePath }, hooks = {}) {
|
|
1023
|
+
if ((tool === "claude-code" || tool === "codex") && linkTarget === "skills") {
|
|
1024
|
+
const skillsRoot = path.join(packageDir, "skills");
|
|
1025
|
+
const skillNames = listChildDirectories(skillsRoot);
|
|
1026
|
+
if (!skillNames.length) {
|
|
1027
|
+
throw new Error(`${tool} 安装包中没有可链接的 skill 目录`);
|
|
1028
|
+
}
|
|
1029
|
+
const createdLinks = [];
|
|
1030
|
+
ensureDir(installRoot);
|
|
1031
|
+
const legacyBundleLink = path.join(installRoot, linkName);
|
|
1032
|
+
if (pathExists(legacyBundleLink)) {
|
|
1033
|
+
hooks.onLog?.(`[apply] remove legacy ${tool} bundle link ${legacyBundleLink}`);
|
|
1034
|
+
removePath(legacyBundleLink);
|
|
1035
|
+
}
|
|
1036
|
+
for (const skillName of skillNames) {
|
|
1037
|
+
const targetLink = path.join(installRoot, skillName);
|
|
1038
|
+
const skillSource = path.join(skillsRoot, skillName);
|
|
1039
|
+
hooks.onLog?.(`[apply] ${tool} skill link ${targetLink} -> ${skillSource}`);
|
|
1040
|
+
applyLink({ targetLink, sourcePath: skillSource });
|
|
1041
|
+
createdLinks.push(targetLink);
|
|
1042
|
+
}
|
|
1043
|
+
if (tool === "claude-code") {
|
|
1044
|
+
createdLinks.push(...installClaudeGlobalCommands({ packageDir, installRoot }, hooks));
|
|
1045
|
+
}
|
|
1046
|
+
return {
|
|
1047
|
+
target_links: createdLinks,
|
|
1048
|
+
target_link: "",
|
|
1049
|
+
source_path: packageDir
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
const targetLink = path.join(installRoot, linkName);
|
|
1053
|
+
hooks.onLog?.(`[apply] link ${targetLink} -> ${sourcePath}`);
|
|
1054
|
+
applyLink({ targetLink, sourcePath });
|
|
1055
|
+
return {
|
|
1056
|
+
target_links: [targetLink],
|
|
1057
|
+
target_link: targetLink,
|
|
1058
|
+
source_path: sourcePath
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function buildGlobalToolState(tool) {
|
|
1063
|
+
const installState = loadToolInstallState(tool);
|
|
1064
|
+
const plan = loadActivationPlan(tool);
|
|
1065
|
+
const detected = detectInstalledTool(tool);
|
|
1066
|
+
return {
|
|
1067
|
+
tool,
|
|
1068
|
+
scope: "global",
|
|
1069
|
+
manageable: true,
|
|
1070
|
+
detected_installed: detected.installed,
|
|
1071
|
+
detected_command_path: detected.command_path,
|
|
1072
|
+
detected_path: detected.detected_path,
|
|
1073
|
+
install_root: installState?.install_root || defaultInstallRoot(tool),
|
|
1074
|
+
link_name: installState?.link_name || defaultLinkName(),
|
|
1075
|
+
link_target: installState?.link_target || "skills",
|
|
1076
|
+
active_plan_id: installState?.active_plan_id || "",
|
|
1077
|
+
enabled: Boolean(installState),
|
|
1078
|
+
mode: installState?.mode || "managed",
|
|
1079
|
+
last_applied_at: installState?.last_applied_at || "",
|
|
1080
|
+
applied_repo_commit: installState?.applied_repo_commit || "",
|
|
1081
|
+
installed_asset_ids: installState?.installed_asset_ids || plan?.asset_ids || [],
|
|
1082
|
+
asset_snapshots: installState?.asset_snapshots || {},
|
|
1083
|
+
selected_asset_ids: plan?.selected_asset_ids || installState?.selected_asset_ids || installState?.installed_asset_ids || []
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function buildProjectToolState(tool, projectState) {
|
|
1088
|
+
const record = projectState?.tools?.[tool] || {};
|
|
1089
|
+
const installState = record.install || null;
|
|
1090
|
+
const plan = record.plan || null;
|
|
1091
|
+
const detected = detectInstalledTool(tool);
|
|
1092
|
+
const defaultInstallRootForScope = defaultProjectInstallRoot(projectState?.project_root || "", tool);
|
|
1093
|
+
return {
|
|
1094
|
+
tool,
|
|
1095
|
+
scope: "project",
|
|
1096
|
+
manageable: true,
|
|
1097
|
+
detected_installed: detected.installed,
|
|
1098
|
+
detected_command_path: detected.command_path,
|
|
1099
|
+
detected_path: detected.detected_path,
|
|
1100
|
+
install_root: usesNativeProjectLayout(tool) ? defaultInstallRootForScope : (installState?.install_root || defaultInstallRootForScope),
|
|
1101
|
+
link_name: installState?.link_name || defaultLinkName(),
|
|
1102
|
+
link_target: installState?.link_target || "skills",
|
|
1103
|
+
active_plan_id: installState?.active_plan_id || "",
|
|
1104
|
+
enabled: Boolean(installState),
|
|
1105
|
+
mode: installState?.mode || "project-config",
|
|
1106
|
+
last_applied_at: installState?.last_applied_at || "",
|
|
1107
|
+
applied_repo_commit: installState?.applied_repo_commit || "",
|
|
1108
|
+
installed_asset_ids: installState?.installed_asset_ids || plan?.asset_ids || [],
|
|
1109
|
+
asset_snapshots: installState?.asset_snapshots || {},
|
|
1110
|
+
selected_asset_ids: plan?.selected_asset_ids || installState?.selected_asset_ids || installState?.installed_asset_ids || [],
|
|
1111
|
+
inherit_global: Boolean(plan?.inherit_global)
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function getAppState({ repoDir = "", projectRoot = "" } = {}) {
|
|
1116
|
+
ensureRuntimeDirs();
|
|
1117
|
+
const settings = loadSettings();
|
|
1118
|
+
const resolvedRepoDir = resolveRepoRoot(repoDir || settings.repo_dir || REPO_DIR);
|
|
1119
|
+
const resolvedProjectRoot = projectRoot || settings.last_project_root || "";
|
|
1120
|
+
const repoStatus = getRepoStatus(resolvedRepoDir);
|
|
1121
|
+
const assets = repoStatus.exists && looksLikeRepoRoot(resolvedRepoDir) ? readAssetCatalog(resolvedRepoDir) : [];
|
|
1122
|
+
const profiles = repoStatus.exists && looksLikeRepoRoot(resolvedRepoDir) ? parseProfiles(resolvedRepoDir) : [];
|
|
1123
|
+
const projectState = resolvedProjectRoot ? loadProjectState(resolvedProjectRoot) : null;
|
|
1124
|
+
return {
|
|
1125
|
+
repo: repoStatus,
|
|
1126
|
+
settings: {
|
|
1127
|
+
repo_url: settings.repo_url || DEFAULT_REPO_URL,
|
|
1128
|
+
repo_dir: resolvedRepoDir,
|
|
1129
|
+
asset_repo_dir: resolvedRepoDir,
|
|
1130
|
+
asset_source_mode: "git",
|
|
1131
|
+
current_scope: settings.current_scope || "project",
|
|
1132
|
+
last_project_root: resolvedProjectRoot
|
|
1133
|
+
},
|
|
1134
|
+
paths: {
|
|
1135
|
+
app_root: APP_ROOT,
|
|
1136
|
+
repo_dir: resolvedRepoDir,
|
|
1137
|
+
runtime_dir: RUNTIME_DIR,
|
|
1138
|
+
state_dir: CLI_STATE_DIR
|
|
1139
|
+
},
|
|
1140
|
+
tools: SUPPORTED_TOOLS.map(buildGlobalToolState),
|
|
1141
|
+
project: resolvedProjectRoot ? {
|
|
1142
|
+
project_id: projectState?.project_id || projectIdForRoot(resolvedProjectRoot),
|
|
1143
|
+
project_root: resolvedProjectRoot,
|
|
1144
|
+
exists: pathExists(resolvedProjectRoot),
|
|
1145
|
+
tools: SUPPORTED_TOOLS.map((tool) => buildProjectToolState(tool, projectState))
|
|
1146
|
+
} : null,
|
|
1147
|
+
profiles,
|
|
1148
|
+
assets
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function applyActivationPlan({
|
|
1153
|
+
tool,
|
|
1154
|
+
assetIds = [],
|
|
1155
|
+
selectedAssetIds = [],
|
|
1156
|
+
installRoot = "",
|
|
1157
|
+
linkName = defaultLinkName(),
|
|
1158
|
+
linkTarget = "skills",
|
|
1159
|
+
repoDir = "",
|
|
1160
|
+
repoUrl = "",
|
|
1161
|
+
planName = "",
|
|
1162
|
+
profileId = "",
|
|
1163
|
+
selectionMode = "",
|
|
1164
|
+
scope = "global",
|
|
1165
|
+
projectRoot = "",
|
|
1166
|
+
inheritGlobal = true
|
|
1167
|
+
} = {}, hooks = {}) {
|
|
1168
|
+
ensureRuntimeDirs();
|
|
1169
|
+
const currentSettings = loadSettings();
|
|
1170
|
+
const requestedRepoUrl = repoUrl ? String(repoUrl).trim() : currentSettings.repo_url || DEFAULT_REPO_URL;
|
|
1171
|
+
await assertEnterpriseReachable({ repoUrl: requestedRepoUrl });
|
|
1172
|
+
const requestedRepoDir = resolveRepoRoot(repoDir || currentSettings.repo_dir || REPO_DIR);
|
|
1173
|
+
const settings = saveSettings({
|
|
1174
|
+
repo_url: requestedRepoUrl,
|
|
1175
|
+
repo_dir: requestedRepoDir
|
|
1176
|
+
});
|
|
1177
|
+
const resolvedRepoDir = settings.repo_dir;
|
|
1178
|
+
const resolvedRepoUrl = settings.repo_url;
|
|
1179
|
+
if (!SUPPORTED_TOOLS.includes(tool)) {
|
|
1180
|
+
throw new Error(`unsupported tool: ${tool}`);
|
|
1181
|
+
}
|
|
1182
|
+
if (!["global", "project"].includes(scope)) {
|
|
1183
|
+
throw new Error(`unsupported scope: ${scope}`);
|
|
1184
|
+
}
|
|
1185
|
+
if (!assetIds.length) {
|
|
1186
|
+
throw new Error("至少选择一个资产后再应用");
|
|
1187
|
+
}
|
|
1188
|
+
let resolvedProjectRoot = "";
|
|
1189
|
+
let projectState = null;
|
|
1190
|
+
if (scope === "project") {
|
|
1191
|
+
if (!projectRoot) {
|
|
1192
|
+
throw new Error("项目级安装必须提供项目目录");
|
|
1193
|
+
}
|
|
1194
|
+
resolvedProjectRoot = path.resolve(projectRoot);
|
|
1195
|
+
ensureDir(resolvedProjectRoot);
|
|
1196
|
+
projectState = loadProjectState(resolvedProjectRoot);
|
|
1197
|
+
saveSettings({ last_project_root: resolvedProjectRoot, current_scope: "project" });
|
|
1198
|
+
} else {
|
|
1199
|
+
saveSettings({ current_scope: "global" });
|
|
1200
|
+
}
|
|
1201
|
+
if (!looksLikeRepoRoot(resolvedRepoDir)) {
|
|
1202
|
+
if (!fs.existsSync(path.join(resolvedRepoDir, ".git"))) {
|
|
1203
|
+
hooks.onLog?.("[apply] 本地仓库不存在,先执行同步");
|
|
1204
|
+
await syncRepo({ repoDir: resolvedRepoDir, repoUrl: resolvedRepoUrl }, hooks);
|
|
1205
|
+
return applyActivationPlan({
|
|
1206
|
+
tool,
|
|
1207
|
+
assetIds,
|
|
1208
|
+
selectedAssetIds,
|
|
1209
|
+
installRoot,
|
|
1210
|
+
linkName,
|
|
1211
|
+
linkTarget,
|
|
1212
|
+
repoDir: resolvedRepoDir,
|
|
1213
|
+
repoUrl: resolvedRepoUrl,
|
|
1214
|
+
planName,
|
|
1215
|
+
profileId,
|
|
1216
|
+
selectionMode,
|
|
1217
|
+
scope,
|
|
1218
|
+
projectRoot: resolvedProjectRoot,
|
|
1219
|
+
inheritGlobal
|
|
1220
|
+
}, hooks);
|
|
1221
|
+
}
|
|
1222
|
+
throw new Error(`资产源目录不是控制面仓库根目录:${resolvedRepoDir}`);
|
|
1223
|
+
}
|
|
1224
|
+
const resolvedInstallRoot = scope === "project" && usesNativeProjectLayout(tool)
|
|
1225
|
+
? defaultProjectInstallRoot(resolvedProjectRoot, tool)
|
|
1226
|
+
: (installRoot || (scope === "project" ? defaultProjectInstallRoot(resolvedProjectRoot, tool) : defaultInstallRoot(tool)));
|
|
1227
|
+
if (!resolvedInstallRoot) {
|
|
1228
|
+
throw new Error(`tool '${tool}' 缺少安装目录,请手工指定`);
|
|
1229
|
+
}
|
|
1230
|
+
hooks.onProgress?.({ percent: 18, stage: "build", detail: "读取资产并生成组合包" });
|
|
1231
|
+
const assets = readAssetCatalog(resolvedRepoDir);
|
|
1232
|
+
const globalToolState = buildGlobalToolState(tool);
|
|
1233
|
+
const requestedAssetIdSet = [...new Set(selectedAssetIds.length ? selectedAssetIds : assetIds)].sort();
|
|
1234
|
+
const scopedInputIds = scope === "project" && inheritGlobal
|
|
1235
|
+
? [...new Set([...(globalToolState.installed_asset_ids || []), ...requestedAssetIdSet])].sort()
|
|
1236
|
+
: requestedAssetIdSet;
|
|
1237
|
+
const uniqueAssetIds = resolveAssetDependencySet(assets, scopedInputIds);
|
|
1238
|
+
const assetSnapshots = Object.fromEntries(
|
|
1239
|
+
assets
|
|
1240
|
+
.filter((asset) => uniqueAssetIds.includes(asset.id))
|
|
1241
|
+
.map((asset) => [asset.id, {
|
|
1242
|
+
id: asset.id,
|
|
1243
|
+
name: asset.name,
|
|
1244
|
+
version: asset.version || "",
|
|
1245
|
+
type: asset.type,
|
|
1246
|
+
domain: asset.domain
|
|
1247
|
+
}])
|
|
1248
|
+
);
|
|
1249
|
+
const planId = computePlanId(tool, uniqueAssetIds);
|
|
1250
|
+
const packageBaseDir = scope === "project"
|
|
1251
|
+
? path.join(RUNTIME_DIR, "projects", projectIdForRoot(resolvedProjectRoot), tool)
|
|
1252
|
+
: path.join(RUNTIME_DIR, tool);
|
|
1253
|
+
ensureDir(packageBaseDir);
|
|
1254
|
+
const packageDir = renderCustomPackage({
|
|
1255
|
+
repoDir: resolvedRepoDir,
|
|
1256
|
+
tool,
|
|
1257
|
+
assetIds: uniqueAssetIds,
|
|
1258
|
+
planId,
|
|
1259
|
+
runtimeRoot: packageBaseDir
|
|
1260
|
+
});
|
|
1261
|
+
const sourcePath = linkTarget === "package" ? packageDir : path.join(packageDir, "skills");
|
|
1262
|
+
if (!fs.existsSync(sourcePath)) {
|
|
1263
|
+
throw new Error(`source path not found: ${sourcePath}`);
|
|
1264
|
+
}
|
|
1265
|
+
hooks.onProgress?.({ percent: 58, stage: "link", detail: "写入安装链接" });
|
|
1266
|
+
let linkResult;
|
|
1267
|
+
if (tool === "claude-code" && scope === "project") {
|
|
1268
|
+
linkResult = buildClaudeProjectMemory(packageDir, resolvedProjectRoot, uniqueAssetIds, projectState?.tools?.[tool]?.install || null, hooks);
|
|
1269
|
+
} else if (tool === "codex" && scope === "project") {
|
|
1270
|
+
linkResult = buildCodexProjectLayout(packageDir, resolvedProjectRoot, uniqueAssetIds, projectState?.tools?.[tool]?.install || null, hooks);
|
|
1271
|
+
} else {
|
|
1272
|
+
linkResult = applyInstallLayout({
|
|
1273
|
+
tool,
|
|
1274
|
+
installRoot: resolvedInstallRoot,
|
|
1275
|
+
linkName,
|
|
1276
|
+
linkTarget,
|
|
1277
|
+
packageDir,
|
|
1278
|
+
sourcePath
|
|
1279
|
+
}, hooks);
|
|
1280
|
+
}
|
|
1281
|
+
const now = new Date().toISOString();
|
|
1282
|
+
const plan = {
|
|
1283
|
+
id: planId,
|
|
1284
|
+
tool,
|
|
1285
|
+
scope,
|
|
1286
|
+
name: planName || planId,
|
|
1287
|
+
profile_id: profileId || "",
|
|
1288
|
+
selection_mode: selectionMode || (profileId ? "profile" : "custom"),
|
|
1289
|
+
selected_asset_ids: requestedAssetIdSet,
|
|
1290
|
+
source_type: "git",
|
|
1291
|
+
source_ref: getRepoStatus(resolvedRepoDir).commit,
|
|
1292
|
+
asset_ids: uniqueAssetIds,
|
|
1293
|
+
inherit_global: Boolean(scope === "project" && inheritGlobal),
|
|
1294
|
+
project_root: resolvedProjectRoot,
|
|
1295
|
+
created_at: now,
|
|
1296
|
+
updated_at: now
|
|
1297
|
+
};
|
|
1298
|
+
const installState = {
|
|
1299
|
+
tool,
|
|
1300
|
+
scope,
|
|
1301
|
+
mode: "git",
|
|
1302
|
+
active_plan_id: planId,
|
|
1303
|
+
install_root: resolvedInstallRoot,
|
|
1304
|
+
link_name: linkName,
|
|
1305
|
+
link_target: linkTarget,
|
|
1306
|
+
target_link: linkResult.target_link,
|
|
1307
|
+
target_links: linkResult.target_links,
|
|
1308
|
+
target_skill_links: linkResult.target_skill_links || [],
|
|
1309
|
+
managed_mcp_names: linkResult.managed_mcp_names || [],
|
|
1310
|
+
source_path: linkResult.source_path,
|
|
1311
|
+
repo_dir: resolvedRepoDir,
|
|
1312
|
+
repo_url: resolvedRepoUrl,
|
|
1313
|
+
applied_repo_commit: getRepoStatus(resolvedRepoDir).commit,
|
|
1314
|
+
installed_asset_ids: uniqueAssetIds,
|
|
1315
|
+
selected_asset_ids: requestedAssetIdSet,
|
|
1316
|
+
asset_snapshots: assetSnapshots,
|
|
1317
|
+
project_root: resolvedProjectRoot,
|
|
1318
|
+
inherit_global: Boolean(scope === "project" && inheritGlobal),
|
|
1319
|
+
last_applied_at: now
|
|
1320
|
+
};
|
|
1321
|
+
if (scope === "project") {
|
|
1322
|
+
projectState.tools[tool] = { plan, install: installState };
|
|
1323
|
+
saveProjectState(resolvedProjectRoot, projectState);
|
|
1324
|
+
} else {
|
|
1325
|
+
writeJson(path.join(PLAN_DIR, `${tool}.json`), plan);
|
|
1326
|
+
writeJson(path.join(GUI_INSTALL_DIR, `${tool}.json`), installState);
|
|
1327
|
+
}
|
|
1328
|
+
hooks.onProgress?.({ percent: 84, stage: "state", detail: "写入本地状态" });
|
|
1329
|
+
hooks.onProgress?.({ percent: 100, stage: "done", detail: `${tool} 安装完成` });
|
|
1330
|
+
hooks.onLog?.(`[apply] completed ${tool}`);
|
|
1331
|
+
return getAppState({ repoDir: resolvedRepoDir, projectRoot: resolvedProjectRoot });
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function disableTool({ tool, scope = "global", projectRoot = "" } = {}, hooks = {}) {
|
|
1335
|
+
if (!SUPPORTED_TOOLS.includes(tool)) {
|
|
1336
|
+
throw new Error(`unsupported tool: ${tool}`);
|
|
1337
|
+
}
|
|
1338
|
+
hooks.onProgress?.({ percent: 10, stage: "prepare", detail: `准备卸载 ${tool}` });
|
|
1339
|
+
const installState = scope === "project"
|
|
1340
|
+
? loadProjectState(projectRoot)?.tools?.[tool]?.install || null
|
|
1341
|
+
: loadToolInstallState(tool);
|
|
1342
|
+
if (!installState) {
|
|
1343
|
+
hooks.onProgress?.({ percent: 100, stage: "done", detail: `${tool} 无安装记录` });
|
|
1344
|
+
return getAppState({ projectRoot });
|
|
1345
|
+
}
|
|
1346
|
+
for (const targetLink of installState.target_links || (installState.target_link ? [installState.target_link] : [])) {
|
|
1347
|
+
if (!(scope === "project" && ["claude-code", "codex"].includes(tool))) {
|
|
1348
|
+
removePath(targetLink);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
if (scope === "project") {
|
|
1352
|
+
const projectState = loadProjectState(projectRoot);
|
|
1353
|
+
if (projectState?.tools) {
|
|
1354
|
+
delete projectState.tools[tool];
|
|
1355
|
+
saveProjectState(projectRoot, projectState);
|
|
1356
|
+
}
|
|
1357
|
+
if (tool === "claude-code") {
|
|
1358
|
+
removeClaudeProjectLayout(projectRoot, installState);
|
|
1359
|
+
} else {
|
|
1360
|
+
removeCodexProjectLayout(projectRoot, installState);
|
|
1361
|
+
}
|
|
1362
|
+
removePath(path.join(RUNTIME_DIR, "projects", projectIdForRoot(projectRoot), tool));
|
|
1363
|
+
} else {
|
|
1364
|
+
fs.rmSync(path.join(PLAN_DIR, `${tool}.json`), { force: true });
|
|
1365
|
+
fs.rmSync(path.join(GUI_INSTALL_DIR, `${tool}.json`), { force: true });
|
|
1366
|
+
removePath(path.join(RUNTIME_DIR, tool));
|
|
1367
|
+
}
|
|
1368
|
+
hooks.onProgress?.({ percent: 100, stage: "done", detail: `${tool} 已卸载` });
|
|
1369
|
+
return getAppState({ projectRoot });
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async function updateProjectInstallations({ projectRoot = "", repoDir = "", repoUrl = "", tool = "" } = {}, hooks = {}) {
|
|
1373
|
+
const resolvedProjectRoot = projectRoot ? path.resolve(projectRoot) : "";
|
|
1374
|
+
if (!resolvedProjectRoot) {
|
|
1375
|
+
throw new Error("项目更新必须提供项目目录");
|
|
1376
|
+
}
|
|
1377
|
+
const projectState = loadProjectState(resolvedProjectRoot);
|
|
1378
|
+
const targetTools = tool ? [tool] : Object.keys(projectState?.tools || {});
|
|
1379
|
+
if (!targetTools.length) {
|
|
1380
|
+
throw new Error("当前项目没有可更新的安装记录,请先执行项目安装");
|
|
1381
|
+
}
|
|
1382
|
+
const currentSettings = loadSettings();
|
|
1383
|
+
const requestedRepoUrl = repoUrl ? String(repoUrl).trim() : currentSettings.repo_url || DEFAULT_REPO_URL;
|
|
1384
|
+
const requestedRepoDir = resolveRepoRoot(repoDir || currentSettings.repo_dir || REPO_DIR);
|
|
1385
|
+
await syncRepo({ repoDir: requestedRepoDir, repoUrl: requestedRepoUrl }, hooks);
|
|
1386
|
+
let nextState = getAppState({ repoDir: requestedRepoDir, projectRoot: resolvedProjectRoot });
|
|
1387
|
+
for (const currentTool of targetTools) {
|
|
1388
|
+
const record = loadProjectState(resolvedProjectRoot)?.tools?.[currentTool] || null;
|
|
1389
|
+
const plan = record?.plan || null;
|
|
1390
|
+
const install = record?.install || null;
|
|
1391
|
+
if (!plan || !install) {
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
const liveProfiles = (nextState.profiles || []).filter((profile) => profile.supported_tools.includes(currentTool));
|
|
1395
|
+
const profile = plan.profile_id ? liveProfiles.find((item) => item.id === plan.profile_id) : null;
|
|
1396
|
+
const selectedAssetIds = profile?.asset_ids?.length
|
|
1397
|
+
? profile.asset_ids
|
|
1398
|
+
: Array.isArray(plan.selected_asset_ids) && plan.selected_asset_ids.length
|
|
1399
|
+
? plan.selected_asset_ids
|
|
1400
|
+
: Array.isArray(install.selected_asset_ids) && install.selected_asset_ids.length
|
|
1401
|
+
? install.selected_asset_ids
|
|
1402
|
+
: Array.isArray(plan.asset_ids)
|
|
1403
|
+
? plan.asset_ids
|
|
1404
|
+
: install.installed_asset_ids || [];
|
|
1405
|
+
nextState = await applyActivationPlan({
|
|
1406
|
+
tool: currentTool,
|
|
1407
|
+
assetIds: selectedAssetIds,
|
|
1408
|
+
selectedAssetIds,
|
|
1409
|
+
installRoot: install.install_root || "",
|
|
1410
|
+
linkName: install.link_name || defaultLinkName(),
|
|
1411
|
+
linkTarget: install.link_target || "skills",
|
|
1412
|
+
repoDir: requestedRepoDir,
|
|
1413
|
+
repoUrl: requestedRepoUrl,
|
|
1414
|
+
planName: plan.name || install.active_plan_id || "project-update",
|
|
1415
|
+
profileId: plan.profile_id || "",
|
|
1416
|
+
selectionMode: plan.selection_mode || "",
|
|
1417
|
+
scope: "project",
|
|
1418
|
+
projectRoot: resolvedProjectRoot,
|
|
1419
|
+
inheritGlobal: Boolean(plan.inherit_global ?? install.inherit_global)
|
|
1420
|
+
}, hooks);
|
|
1421
|
+
}
|
|
1422
|
+
return nextState;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
async function getEnterpriseNetworkStatus() {
|
|
1426
|
+
const settings = loadSettings();
|
|
1427
|
+
return checkEnterpriseReachability({ repoUrl: settings.repo_url || DEFAULT_REPO_URL });
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
module.exports = {
|
|
1431
|
+
DEFAULT_REPO_URL,
|
|
1432
|
+
SUPPORTED_TOOLS,
|
|
1433
|
+
resolveRepoRoot,
|
|
1434
|
+
updateSettings,
|
|
1435
|
+
syncRepo,
|
|
1436
|
+
getAppState,
|
|
1437
|
+
applyActivationPlan,
|
|
1438
|
+
updateProjectInstallations,
|
|
1439
|
+
disableTool,
|
|
1440
|
+
getEnterpriseNetworkStatus
|
|
1441
|
+
};
|