mioku-service-webui 4.11.3 → 4.13.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/index.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  createDataManagementRoutes,
38
38
  createStrangerRoutes,
39
39
  createAccessControlRoutes,
40
+ createMcpRoutes,
40
41
  } from "./routes";
41
42
  import { freePort } from "./port-utils";
42
43
 
@@ -132,6 +133,7 @@ class WebUIRuntime {
132
133
  this.app.route("/api/data-management", createDataManagementRoutes());
133
134
  this.app.route("/api/stranger", createStrangerRoutes());
134
135
  this.app.route("/api/access-control", createAccessControlRoutes());
136
+ this.app.route("/api/mcp", createMcpRoutes());
135
137
 
136
138
  this.app.get(
137
139
  "/api/ws/logs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-service-webui",
3
- "version": "4.11.3",
3
+ "version": "4.13.0",
4
4
  "description": "Mioku WebUI 服务",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -26,11 +26,18 @@
26
26
  "systeminformation": "^5.31.6"
27
27
  },
28
28
  "peerDependencies": {
29
- "mioku-service-ai": "^4.0.0"
29
+ "mioku-service-ai": "^4.0.0",
30
+ "mioku-service-mcp": "^0.1.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/bun": "^1.3.14",
33
34
  "mioku": "workspace:*",
34
- "mioku-service-ai": "workspace:*"
35
+ "mioku-service-ai": "workspace:*",
36
+ "mioku-service-mcp": "workspace:*"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "mioku-service-mcp": {
40
+ "optional": true
41
+ }
35
42
  }
36
43
  }
@@ -0,0 +1,137 @@
1
+ import { Hono } from "hono";
2
+ import { logger } from "mioku";
3
+ import { probeSkillSource } from "../skill-sources";
4
+ import {
5
+ getSkillOrigin,
6
+ installAgentSkill,
7
+ listAgentSkillRoots,
8
+ listAgentSkills,
9
+ readAgentSkill,
10
+ setSkillEnabled,
11
+ uninstallAgentSkill,
12
+ } from "../skill-store";
13
+
14
+ function badRequest(c: { json: (body: unknown, status: 400) => Response }, error: string): Response {
15
+ return c.json({ ok: false, error }, 400);
16
+ }
17
+
18
+ export function createAgentSkillRoutes() {
19
+ const app = new Hono();
20
+
21
+ app.get("/", async (c) => c.json({ ok: true, data: await listAgentSkills() }));
22
+
23
+ app.get("/roots", async (c) =>
24
+ c.json({ ok: true, data: await listAgentSkillRoots() }),
25
+ );
26
+
27
+ app.post("/probe", async (c) => {
28
+ const body = await c.req.json().catch(() => ({}));
29
+ const input = String(body?.input ?? "").trim();
30
+ if (!input) return badRequest(c, "请填写技能来源");
31
+
32
+ const result = await probeSkillSource(input);
33
+ if ("error" in result) return badRequest(c, result.error);
34
+ logger.info(`[webui-action] skill.probe`, {
35
+ input,
36
+ candidates: result.candidates.length,
37
+ });
38
+ return c.json({ ok: true, data: result });
39
+ });
40
+
41
+ app.post("/install", async (c) => {
42
+ const body = await c.req.json().catch(() => ({}));
43
+ const rootId = String(body?.rootId ?? "").trim();
44
+ if (!rootId) return badRequest(c, "请选择安装到哪个技能目录");
45
+
46
+ const input = String(body?.input ?? "").trim();
47
+ let stagingId = String(body?.stagingId ?? "").trim();
48
+ let rel = String(body?.rel ?? "").trim();
49
+
50
+ if (!stagingId) {
51
+ if (!input) return badRequest(c, "请先检测来源");
52
+ const probed = await probeSkillSource(input);
53
+ if ("error" in probed) return badRequest(c, probed.error);
54
+ stagingId = probed.stagingId;
55
+ if (!rel) {
56
+ if (probed.candidates.length > 1) {
57
+ return c.json({
58
+ ok: true,
59
+ data: { needSelection: true, ...probed },
60
+ });
61
+ }
62
+ rel = probed.candidates[0]!.rel;
63
+ }
64
+ }
65
+
66
+ const result = await installAgentSkill({
67
+ rootId,
68
+ stagingId,
69
+ rel,
70
+ overwrite: Boolean(body?.overwrite),
71
+ ...(body?.name ? { name: String(body.name) } : {}),
72
+ ...(input ? { originInput: input } : {}),
73
+ });
74
+ if ("error" in result) return badRequest(c, result.error);
75
+
76
+ logger.info(`[webui-action] skill.install`, {
77
+ name: result.name,
78
+ rootId: result.rootId,
79
+ });
80
+ return c.json({ ok: true, data: result });
81
+ });
82
+
83
+ app.put("/state", async (c) => {
84
+ const body = await c.req.json().catch(() => ({}));
85
+ const name = String(body?.name ?? "").trim();
86
+ if (!name) return badRequest(c, "缺少技能名");
87
+ const enabled = body?.enabled !== false;
88
+ const state = setSkillEnabled(name, enabled);
89
+ logger.info(`[webui-action] skill.${enabled ? "enable" : "disable"}`, { name });
90
+ return c.json({ ok: true, data: state });
91
+ });
92
+
93
+ app.get("/:name", async (c) => {
94
+ const name = c.req.param("name");
95
+ const rootId = c.req.query("rootId") ?? "";
96
+ const detail = await readAgentSkill(rootId, name);
97
+ if (!detail) return c.json({ ok: false, error: "技能不存在" }, 404);
98
+ return c.json({ ok: true, data: detail });
99
+ });
100
+
101
+ app.delete("/:name", async (c) => {
102
+ const name = c.req.param("name");
103
+ const rootId = c.req.query("rootId") ?? "";
104
+ const result = await uninstallAgentSkill(rootId, name);
105
+ if ("error" in result) return badRequest(c, result.error);
106
+ logger.info(`[webui-action] skill.uninstall`, { name, rootId });
107
+ return c.json({ ok: true, data: result });
108
+ });
109
+
110
+ app.post("/:name/update", async (c) => {
111
+ const name = c.req.param("name");
112
+ const rootId = c.req.query("rootId") ?? "";
113
+ const origin = getSkillOrigin(rootId, name);
114
+ if (!origin) return badRequest(c, "该技能不是从来源安装的,无法自动更新");
115
+
116
+ const probed = await probeSkillSource(origin.input);
117
+ if ("error" in probed) return badRequest(c, probed.error);
118
+ const match =
119
+ probed.candidates.find((item) => item.name === name) ??
120
+ probed.candidates[0]!;
121
+
122
+ const result = await installAgentSkill({
123
+ rootId,
124
+ stagingId: probed.stagingId,
125
+ rel: match.rel,
126
+ name,
127
+ overwrite: true,
128
+ originInput: origin.input,
129
+ });
130
+ if ("error" in result) return badRequest(c, result.error);
131
+
132
+ logger.info(`[webui-action] skill.update`, { name, rootId });
133
+ return c.json({ ok: true, data: result });
134
+ });
135
+
136
+ return app;
137
+ }
package/routes/agent.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { logger } from "mioku";
3
3
  import { getAgentConfig, updateAgentConfig } from "../system";
4
+ import { createAgentSkillRoutes } from "./agent-skills";
4
5
  import aiService from "mioku-service-ai";
5
6
 
6
7
  const PERMISSION_LEVELS = new Set([
@@ -51,6 +52,10 @@ function sanitizeSettingsForSave(body: any): any {
51
52
  const num = Math.floor(Number(value));
52
53
  return Number.isFinite(num) && num > 0 ? num : fallback;
53
54
  };
55
+ const nonNegative = (value: unknown, fallback: number) => {
56
+ const num = Math.floor(Number(value));
57
+ return Number.isFinite(num) && num >= 0 ? num : fallback;
58
+ };
54
59
  delete next.temperature;
55
60
  delete next.enableMediaRecognition;
56
61
  next.maxIterations = positive(next.maxIterations, 500);
@@ -79,7 +84,24 @@ function sanitizeSettingsForSave(body: any): any {
79
84
  timeoutMs: positive(next.bash?.timeoutMs, 120000),
80
85
  approvalTimeoutMs: positive(next.bash?.approvalTimeoutMs, 300000),
81
86
  };
87
+ next.skills = {
88
+ enabled: next.skills?.enabled !== false,
89
+ extraRoots: Array.isArray(next.skills?.extraRoots)
90
+ ? next.skills.extraRoots
91
+ .map((item: unknown) => String(item ?? "").trim())
92
+ .filter(Boolean)
93
+ : [],
94
+ maxInPrompt: positive(next.skills?.maxInPrompt, 50),
95
+ descriptionMaxLength: positive(next.skills?.descriptionMaxLength, 500),
96
+ activationTtlMs: positive(next.skills?.activationTtlMs, 3_600_000),
97
+ rescanIntervalMs: nonNegative(next.skills?.rescanIntervalMs, 2000),
98
+ maxResourceFiles: positive(next.skills?.maxResourceFiles, 200),
99
+ };
82
100
  next.dataCollection = { enabled: next.dataCollection?.enabled !== false };
101
+ next.mcp = {
102
+ enabled: next.mcp?.enabled !== false,
103
+ maxTools: nonNegative(next.mcp?.maxTools, 48),
104
+ };
83
105
  next.debug = Boolean(next.debug);
84
106
  return next;
85
107
  }
@@ -91,6 +113,8 @@ function getAIApi() {
91
113
  export function createAgentRoutes() {
92
114
  const app = new Hono();
93
115
 
116
+ app.route("/skills", createAgentSkillRoutes());
117
+
94
118
  app.get("/base", (c) =>
95
119
  c.json({ ok: true, data: getAgentConfig("base.json") }),
96
120
  );
package/routes/index.ts CHANGED
@@ -9,3 +9,4 @@ export { createStoreRoutes } from "./store";
9
9
  export { createDataManagementRoutes } from "./data-management";
10
10
  export { createStrangerRoutes } from "./stranger";
11
11
  export { createAccessControlRoutes } from "./access-control";
12
+ export { createMcpRoutes } from "./mcp";
package/routes/mcp.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { Hono } from "hono";
2
+ import { logger, servicesRegistry } from "mioku";
3
+ import type { McpServerInput, McpServiceApi } from "mioku-service-mcp";
4
+
5
+ /** 服务可能没装或没起来,统一走注册表按名查,缺失时返回 503 */
6
+ function mcp(): McpServiceApi | undefined {
7
+ return servicesRegistry.mcp as McpServiceApi | undefined;
8
+ }
9
+
10
+ function unavailable(c: { json: (body: unknown, status: 503) => Response }): Response {
11
+ return c.json({ ok: false, error: "MCP_SERVICE_UNAVAILABLE" }, 503);
12
+ }
13
+
14
+ function badRequest(
15
+ c: { json: (body: unknown, status: 400) => Response },
16
+ error: string,
17
+ ): Response {
18
+ return c.json({ ok: false, error }, 400);
19
+ }
20
+
21
+ function normalizeLimit(value: string | undefined, fallback: number): number {
22
+ const num = Math.floor(Number(value));
23
+ if (!Number.isFinite(num) || num <= 0) return fallback;
24
+ return Math.min(num, 500);
25
+ }
26
+
27
+ export function createMcpRoutes() {
28
+ const app = new Hono();
29
+
30
+ app.get("/overview", (c) => {
31
+ const api = mcp();
32
+ if (!api) return unavailable(c);
33
+ const config = api.getConfig();
34
+ return c.json({
35
+ ok: true,
36
+ data: {
37
+ autoConnect: config.autoConnect,
38
+ reconnectDelayMs: config.reconnectDelayMs,
39
+ maxConcurrency: config.maxConcurrency,
40
+ maxTools: config.maxTools,
41
+ maxResultChars: config.maxResultChars,
42
+ maxResultImages: config.maxResultImages,
43
+ revision: api.revision(),
44
+ toolCount: api.listTools().length,
45
+ catalogOnlyCount: api.listAllTools().filter((tool) => tool.catalogOnly).length,
46
+ servers: api.listServers(),
47
+ configs: api.listServerConfigs(),
48
+ },
49
+ });
50
+ });
51
+
52
+ app.post("/servers", async (c) => {
53
+ const api = mcp();
54
+ if (!api) return unavailable(c);
55
+ const body = (await c.req.json().catch(() => ({}))) as McpServerInput;
56
+ const name = String(body?.name ?? "").trim().toLowerCase();
57
+ if (!name) return badRequest(c, "请填写服务器名称");
58
+ logger.info("[webui-action] mcp.server.upsert", { name });
59
+ const result = await api.upsertServer({ ...body, name });
60
+ if ("error" in result) return badRequest(c, result.error);
61
+ return c.json({ ok: true, data: result });
62
+ });
63
+
64
+ app.delete("/servers/:name", async (c) => {
65
+ const api = mcp();
66
+ if (!api) return unavailable(c);
67
+ const name = c.req.param("name");
68
+ const removed = await api.removeServer(name);
69
+ if (!removed) return badRequest(c, `服务器 "${name}" 不存在`);
70
+ logger.info("[webui-action] mcp.server.remove", { name });
71
+ return c.json({ ok: true, data: { name } });
72
+ });
73
+
74
+ app.put("/servers/:name/enabled", async (c) => {
75
+ const api = mcp();
76
+ if (!api) return unavailable(c);
77
+ const name = c.req.param("name");
78
+ const body = (await c.req.json().catch(() => ({}))) as { enabled?: unknown };
79
+ const enabled = body?.enabled !== false;
80
+ const result = await api.setServerEnabled(name, enabled);
81
+ if ("error" in result) return badRequest(c, result.error);
82
+ logger.info(`[webui-action] mcp.server.${enabled ? "enable" : "disable"}`, { name });
83
+ return c.json({ ok: true, data: result });
84
+ });
85
+
86
+ for (const action of ["connect", "disconnect", "reconnect"] as const) {
87
+ app.post(`/servers/:name/${action}`, async (c) => {
88
+ const api = mcp();
89
+ if (!api) return unavailable(c);
90
+ const name = c.req.param("name");
91
+ logger.info(`[webui-action] mcp.server.${action}`, { name });
92
+ const result = await api[action](name);
93
+ if ("error" in result) return badRequest(c, result.error);
94
+ return c.json({ ok: true, data: result });
95
+ });
96
+ }
97
+
98
+ app.get("/servers/:name/tools", (c) => {
99
+ const api = mcp();
100
+ if (!api) return unavailable(c);
101
+ const name = c.req.param("name");
102
+ if (!api.getServer(name)) return badRequest(c, `服务器 "${name}" 不存在`);
103
+ const tools = api
104
+ .listAllTools()
105
+ .filter((tool) => tool.server === name)
106
+ .map((tool) => ({
107
+ publicName: tool.publicName,
108
+ tool: tool.tool,
109
+ description: tool.description,
110
+ catalogOnly: tool.catalogOnly,
111
+ parameters: tool.parameters,
112
+ }));
113
+ return c.json({ ok: true, data: tools });
114
+ });
115
+
116
+ app.post("/probe", async (c) => {
117
+ const api = mcp();
118
+ if (!api) return unavailable(c);
119
+ const body = (await c.req.json().catch(() => ({}))) as McpServerInput;
120
+ const name = String(body?.name ?? "").trim().toLowerCase();
121
+ if (!name) return badRequest(c, "请填写服务器名称");
122
+ logger.info("[webui-action] mcp.probe", { name });
123
+ const result = await api.probe({ ...body, name });
124
+ return c.json({ ok: true, data: result });
125
+ });
126
+
127
+ app.get("/logs", (c) => {
128
+ const api = mcp();
129
+ if (!api) return unavailable(c);
130
+ const server = c.req.query("server") || undefined;
131
+ const limit = normalizeLimit(c.req.query("limit"), 100);
132
+ return c.json({ ok: true, data: api.recentLogs(server, limit) });
133
+ });
134
+
135
+ return app;
136
+ }
@@ -0,0 +1,290 @@
1
+ import AdmZip from "adm-zip";
2
+ import * as fs from "node:fs";
3
+ import * as fsp from "node:fs/promises";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { locateSkillRoots, type SkillLocation } from "mioku";
7
+ import { ensureDir, isValidRepoUrl, runCommand, TEMP_DIR } from "./utils";
8
+
9
+ const STAGING_ROOT = path.join(TEMP_DIR, "webui-skills");
10
+ const STAGING_TTL_MS = 30 * 60_000;
11
+ const GIT_TIMEOUT_MS = 90_000;
12
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
13
+ const MAX_ARCHIVE_BYTES = 40 * 1024 * 1024;
14
+ const MAX_EXTRACTED_FILES = 2000;
15
+
16
+ export type SkillSourceKind = "git" | "file" | "archive" | "local";
17
+
18
+ export interface ResolvedSkillSource {
19
+ kind: SkillSourceKind;
20
+ /** git 仓库地址(kind=git) */
21
+ gitUrl?: string;
22
+ /** 分支 / tag(kind=git) */
23
+ ref?: string;
24
+ /** 集合仓库内的子路径 */
25
+ subPath?: string;
26
+ /** 直接下载地址(kind=file / archive) */
27
+ url?: string;
28
+ /** 本地目录(kind=local) */
29
+ localDir?: string;
30
+ /** 展示用 */
31
+ display: string;
32
+ }
33
+
34
+ export interface SkillStaging {
35
+ id: string;
36
+ dir: string;
37
+ display: string;
38
+ createdAt: number;
39
+ }
40
+
41
+ const staging = new Map<string, SkillStaging>();
42
+
43
+ function randomId(): string {
44
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
45
+ }
46
+
47
+ function pruneStaging(): void {
48
+ const now = Date.now();
49
+ for (const [id, item] of staging) {
50
+ if (now - item.createdAt < STAGING_TTL_MS) continue;
51
+ staging.delete(id);
52
+ fs.rmSync(item.dir, { recursive: true, force: true });
53
+ }
54
+ }
55
+
56
+ export function getStaging(id: string): SkillStaging | undefined {
57
+ pruneStaging();
58
+ return staging.get(id);
59
+ }
60
+
61
+ export function releaseStaging(id: string): void {
62
+ const item = staging.get(id);
63
+ if (!item) return;
64
+ staging.delete(id);
65
+ fs.rmSync(item.dir, { recursive: true, force: true });
66
+ }
67
+
68
+ function expandHome(input: string): string {
69
+ const raw = String(input ?? "").trim();
70
+ if (raw === "~") return os.homedir();
71
+ if (raw.startsWith("~/")) return path.join(os.homedir(), raw.slice(2));
72
+ return raw;
73
+ }
74
+
75
+ /** 把用户粘进来的东西解析成可下载的来源 */
76
+ export function resolveSkillSource(
77
+ input: string,
78
+ ): ResolvedSkillSource | { error: string } {
79
+ const raw = String(input ?? "").trim();
80
+ if (!raw) return { error: "来源不能为空" };
81
+ if (/\s/.test(raw)) return { error: "来源不能包含空白字符" };
82
+
83
+ if (raw.startsWith("/") || raw.startsWith("~") || raw.startsWith("./") || raw.startsWith("../")) {
84
+ const dir = path.resolve(expandHome(raw));
85
+ if (!fs.existsSync(dir)) return { error: `本地路径不存在:${dir}` };
86
+ return { kind: "local", localDir: dir, display: dir };
87
+ }
88
+
89
+ if (!/^https?:\/\//.test(raw)) {
90
+ // `owner/repo`、`owner/repo#ref`、`owner/repo/sub/path`
91
+ const [repoPart, ref] = raw.split("#");
92
+ const segments = repoPart.split("/").filter(Boolean);
93
+ if (segments.length < 2) {
94
+ return { error: "简写需要 `owner/repo`,或直接粘贴完整 URL" };
95
+ }
96
+ const [owner, repo, ...rest] = segments;
97
+ return {
98
+ kind: "git",
99
+ gitUrl: `https://github.com/${owner}/${repo.replace(/\.git$/, "")}`,
100
+ ...(ref ? { ref } : {}),
101
+ ...(rest.length > 0 ? { subPath: rest.join("/") } : {}),
102
+ display: raw,
103
+ };
104
+ }
105
+
106
+ if (!isValidRepoUrl(raw)) return { error: "不支持的 URL" };
107
+
108
+ const github = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/(?:tree|blob)\/([^/]+)\/(.*))?$/.exec(
109
+ raw,
110
+ );
111
+ if (github) {
112
+ const [, owner, repo, ref, rest] = github;
113
+ const isBlob = raw.includes("/blob/");
114
+ if (isBlob) {
115
+ return { kind: "file", url: raw.replace("/blob/", "/raw/"), display: raw };
116
+ }
117
+ return {
118
+ kind: "git",
119
+ gitUrl: `https://github.com/${owner}/${repo.replace(/\.git$/, "")}`,
120
+ ...(ref ? { ref } : {}),
121
+ ...(rest ? { subPath: rest.replace(/\/$/, "") } : {}),
122
+ display: raw,
123
+ };
124
+ }
125
+
126
+ if (/\.git$/.test(raw) || /^https?:\/\/(?:[^/]+)\/[^/]+\/[^/]+$/.test(raw)) {
127
+ return { kind: "git", gitUrl: raw.replace(/\.git$/, ""), display: raw };
128
+ }
129
+ if (/\.zip$/i.test(raw)) return { kind: "archive", url: raw, display: raw };
130
+ return { kind: "file", url: raw, display: raw };
131
+ }
132
+
133
+ async function runWithTimeout(
134
+ command: string,
135
+ args: string[],
136
+ cwd: string,
137
+ timeoutMs: number,
138
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
139
+ let timer: ReturnType<typeof setTimeout> | undefined;
140
+ try {
141
+ return await Promise.race([
142
+ runCommand(command, args, cwd),
143
+ new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
144
+ timer = setTimeout(
145
+ () => resolve({ code: -1, stdout: "", stderr: `${command} 超时` }),
146
+ timeoutMs,
147
+ );
148
+ }),
149
+ ]);
150
+ } finally {
151
+ if (timer) clearTimeout(timer);
152
+ }
153
+ }
154
+
155
+ async function download(url: string, target: string): Promise<void> {
156
+ const controller = new AbortController();
157
+ const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
158
+ try {
159
+ const res = await fetch(url, {
160
+ signal: controller.signal,
161
+ redirect: "follow",
162
+ headers: { "User-Agent": "mioku-skills" },
163
+ });
164
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
165
+ const length = Number(res.headers.get("content-length") ?? 0);
166
+ if (length > MAX_ARCHIVE_BYTES) throw new Error("文件过大");
167
+ const buffer = Buffer.from(await res.arrayBuffer());
168
+ if (buffer.byteLength > MAX_ARCHIVE_BYTES) throw new Error("文件过大");
169
+ await fsp.writeFile(target, buffer);
170
+ } finally {
171
+ clearTimeout(timer);
172
+ }
173
+ }
174
+
175
+ function isInside(root: string, target: string): boolean {
176
+ const rel = path.relative(root, target);
177
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
178
+ }
179
+
180
+ /** zip-slip 防护:条目必须落在目标目录内,符号链接一律拒绝 */
181
+ function extractZip(zipPath: string, target: string): void {
182
+ const zip = new AdmZip(zipPath);
183
+ const entries = zip.getEntries();
184
+ if (entries.length > MAX_EXTRACTED_FILES) {
185
+ throw new Error(`压缩包条目过多(${entries.length})`);
186
+ }
187
+ for (const entry of entries) {
188
+ const full = path.resolve(target, entry.entryName);
189
+ if (!isInside(target, full)) throw new Error(`压缩包路径越界:${entry.entryName}`);
190
+ const attr = entry.attr >>> 16;
191
+ if ((attr & 0o170000) === 0o120000) {
192
+ throw new Error(`压缩包包含符号链接:${entry.entryName}`);
193
+ }
194
+ }
195
+ zip.extractAllTo(target, true);
196
+ }
197
+
198
+ export interface FetchResult {
199
+ stagingId: string;
200
+ /** 取件根目录(subPath 指向的子目录或仓库根) */
201
+ dir: string;
202
+ display: string;
203
+ }
204
+
205
+ /** 取件到 temp 下的 staging 目录,按 TTL 自动清理 */
206
+ export async function fetchSkillSource(
207
+ source: ResolvedSkillSource,
208
+ ): Promise<FetchResult | { error: string }> {
209
+ pruneStaging();
210
+ const id = randomId();
211
+ const dir = path.join(STAGING_ROOT, id);
212
+ ensureDir(dir);
213
+
214
+ try {
215
+ if (source.kind === "git") {
216
+ const args = ["clone", "--depth", "1"];
217
+ if (source.ref) args.push("--branch", source.ref);
218
+ args.push(source.gitUrl!, dir);
219
+ const result = await runWithTimeout("git", args, STAGING_ROOT, GIT_TIMEOUT_MS);
220
+ if (result.code !== 0) {
221
+ throw new Error(`git clone 失败:${result.stderr || result.stdout}`);
222
+ }
223
+ fs.rmSync(path.join(dir, ".git"), { recursive: true, force: true });
224
+ const target = source.subPath ? path.join(dir, source.subPath) : dir;
225
+ if (!isInside(dir, target) || !fs.existsSync(target)) {
226
+ throw new Error(`仓库里没有路径 ${source.subPath}`);
227
+ }
228
+ staging.set(id, { id, dir: target, display: source.display, createdAt: Date.now() });
229
+ return { stagingId: id, dir: target, display: source.display };
230
+ }
231
+
232
+ if (source.kind === "local") {
233
+ staging.set(id, {
234
+ id,
235
+ dir: source.localDir!,
236
+ display: source.display,
237
+ createdAt: Date.now(),
238
+ });
239
+ return { stagingId: id, dir: source.localDir!, display: source.display };
240
+ }
241
+
242
+ const fileName = source.kind === "archive" ? "archive.zip" : "SKILL.md";
243
+ const downloadPath = path.join(dir, fileName);
244
+ await download(source.url!, downloadPath);
245
+ if (source.kind === "archive") {
246
+ const extracted = path.join(dir, "extracted");
247
+ ensureDir(extracted);
248
+ extractZip(downloadPath, extracted);
249
+ fs.rmSync(downloadPath, { force: true });
250
+ staging.set(id, { id, dir: extracted, display: source.display, createdAt: Date.now() });
251
+ return { stagingId: id, dir: extracted, display: source.display };
252
+ }
253
+ staging.set(id, { id, dir, display: source.display, createdAt: Date.now() });
254
+ return { stagingId: id, dir, display: source.display };
255
+ } catch (err) {
256
+ fs.rmSync(dir, { recursive: true, force: true });
257
+ return { error: String(err instanceof Error ? err.message : err) };
258
+ }
259
+ }
260
+
261
+ export interface ProbeResult {
262
+ stagingId: string;
263
+ display: string;
264
+ candidates: Array<SkillLocation & { rel: string }>;
265
+ }
266
+
267
+ /** 取件并列出可安装的技能候选 */
268
+ export async function probeSkillSource(
269
+ input: string,
270
+ ): Promise<ProbeResult | { error: string }> {
271
+ const resolved = resolveSkillSource(input);
272
+ if ("error" in resolved) return resolved;
273
+
274
+ const fetched = await fetchSkillSource(resolved);
275
+ if ("error" in fetched) return fetched;
276
+
277
+ const located = await locateSkillRoots(fetched.dir);
278
+ if (located.length === 0) {
279
+ releaseStaging(fetched.stagingId);
280
+ return { error: "没找到 SKILL.md,确认这是技能仓库或技能目录" };
281
+ }
282
+ return {
283
+ stagingId: fetched.stagingId,
284
+ display: fetched.display,
285
+ candidates: located.map((item) => ({
286
+ ...item,
287
+ rel: path.relative(fetched.dir, item.dir).split(path.sep).join("/") || ".",
288
+ })),
289
+ };
290
+ }
package/skill-store.ts ADDED
@@ -0,0 +1,373 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import {
5
+ discoverSkills,
6
+ parseSkillFile,
7
+ resolveSkillRoots,
8
+ SKILL_FILE_NAME,
9
+ SKILL_NAME_PATTERN,
10
+ type SkillDiagnostic,
11
+ type SkillRoot,
12
+ } from "mioku";
13
+ import { getAgentConfig, updateAgentConfig } from "./system";
14
+ import {
15
+ getStaging,
16
+ resolveSkillSource,
17
+ type SkillSourceKind,
18
+ } from "./skill-sources";
19
+ import { DATA_DIR, ensureDir, readJsonFile, writeJsonFile } from "./utils";
20
+
21
+ const REGISTRY_PATH = path.join(DATA_DIR, "webui", "skills.json");
22
+ const INSTALL_TMP_PREFIX = ".mioku-install-";
23
+
24
+ export interface SkillOrigin {
25
+ kind: SkillSourceKind;
26
+ /** 原始来源输入,更新时重新取件 */
27
+ input: string;
28
+ display: string;
29
+ installedAt: number;
30
+ }
31
+
32
+ interface SkillsRegistry {
33
+ version: 1;
34
+ origins: Record<string, SkillOrigin>;
35
+ }
36
+
37
+ export interface SkillRootInfo extends SkillRoot {
38
+ exists: boolean;
39
+ skills: number;
40
+ }
41
+
42
+ export interface SkillListItem {
43
+ name: string;
44
+ description: string;
45
+ rootId: string;
46
+ rootLabel: string;
47
+ rootPath: string;
48
+ dir: string;
49
+ path: string;
50
+ resources: number;
51
+ modelInvocable: boolean;
52
+ userInvocable: boolean;
53
+ enabled: boolean;
54
+ canUninstall: boolean;
55
+ origin?: SkillOrigin;
56
+ diagnostics: SkillDiagnostic[];
57
+ }
58
+
59
+ export interface SkillDetail extends SkillListItem {
60
+ content: string;
61
+ license?: string;
62
+ compatibility?: string;
63
+ metadata?: Record<string, string>;
64
+ allowedTools: string[];
65
+ resourceList: string[];
66
+ }
67
+
68
+ function readRegistry(): SkillsRegistry {
69
+ const raw = readJsonFile<SkillsRegistry>(REGISTRY_PATH, {
70
+ version: 1,
71
+ origins: {},
72
+ });
73
+ return {
74
+ version: 1,
75
+ origins: raw && typeof raw.origins === "object" && raw.origins ? raw.origins : {},
76
+ };
77
+ }
78
+
79
+ function writeRegistry(registry: SkillsRegistry): void {
80
+ ensureDir(path.dirname(REGISTRY_PATH));
81
+ writeJsonFile(REGISTRY_PATH, registry);
82
+ }
83
+
84
+ function registryKey(rootId: string, name: string): string {
85
+ return `${rootId}/${name}`;
86
+ }
87
+
88
+ export function agentSkillRoots(): SkillRoot[] {
89
+ const settings = getAgentConfig("settings.json") as {
90
+ skills?: { extraRoots?: unknown };
91
+ };
92
+ const extraRoots = Array.isArray(settings?.skills?.extraRoots)
93
+ ? settings.skills.extraRoots.map((item) => String(item ?? "")).filter(Boolean)
94
+ : [];
95
+ return resolveSkillRoots({ extraRoots });
96
+ }
97
+
98
+ export function readSkillsState(): { disabled: string[] } {
99
+ const raw = getAgentConfig("skills.json") as { disabled?: unknown };
100
+ const disabled = Array.isArray(raw?.disabled)
101
+ ? raw.disabled.map((item) => String(item ?? "")).filter(Boolean)
102
+ : [];
103
+ return { disabled };
104
+ }
105
+
106
+ export function setSkillEnabled(name: string, enabled: boolean): { disabled: string[] } {
107
+ const current = readSkillsState();
108
+ const next = new Set(current.disabled);
109
+ if (enabled) next.delete(name);
110
+ else next.add(name);
111
+ const value = { disabled: [...next].sort() };
112
+ updateAgentConfig("skills.json", value);
113
+ return value;
114
+ }
115
+
116
+ function isInside(root: string, target: string): boolean {
117
+ const rel = path.relative(root, target);
118
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
119
+ }
120
+
121
+ /** 清掉上次安装失败留下的临时目录 */
122
+ function cleanupStaleInstalls(root: SkillRoot): void {
123
+ let entries: string[];
124
+ try {
125
+ entries = fs.readdirSync(root.path);
126
+ } catch {
127
+ return;
128
+ }
129
+ const cutoff = Date.now() - 60 * 60_000;
130
+ for (const entry of entries) {
131
+ if (!entry.startsWith(INSTALL_TMP_PREFIX)) continue;
132
+ const full = path.join(root.path, entry);
133
+ try {
134
+ if (fs.statSync(full).mtimeMs < cutoff) {
135
+ fs.rmSync(full, { recursive: true, force: true });
136
+ }
137
+ } catch {
138
+ // 忽略
139
+ }
140
+ }
141
+ }
142
+
143
+ export async function listAgentSkillRoots(): Promise<SkillRootInfo[]> {
144
+ const roots = agentSkillRoots();
145
+ const { skills } = await discoverSkills(roots);
146
+ return roots.map((root) => ({
147
+ ...root,
148
+ exists: fs.existsSync(root.path),
149
+ skills: skills.filter((skill) => skill.rootId === root.id).length,
150
+ }));
151
+ }
152
+
153
+ export async function listAgentSkills(): Promise<{
154
+ skills: SkillListItem[];
155
+ diagnostics: SkillDiagnostic[];
156
+ roots: SkillRootInfo[];
157
+ }> {
158
+ const roots = agentSkillRoots();
159
+ for (const root of roots) cleanupStaleInstalls(root);
160
+
161
+ const { skills, diagnostics } = await discoverSkills(roots);
162
+ const registry = readRegistry();
163
+ const { disabled } = readSkillsState();
164
+ const disabledSet = new Set(disabled);
165
+ const rootById = new Map(roots.map((root) => [root.id, root]));
166
+
167
+ return {
168
+ skills: skills.map((skill) => {
169
+ const root = rootById.get(skill.rootId);
170
+ const origin = registry.origins[registryKey(skill.rootId, skill.name)];
171
+ return {
172
+ name: skill.name,
173
+ description: skill.description,
174
+ rootId: skill.rootId,
175
+ rootLabel: root?.label ?? skill.rootId,
176
+ rootPath: root?.path ?? "",
177
+ dir: skill.dir,
178
+ path: skill.path,
179
+ resources: skill.resources.length,
180
+ modelInvocable: skill.invoke.modelInvocable,
181
+ userInvocable: skill.invoke.userInvocable,
182
+ enabled: !disabledSet.has(skill.name),
183
+ canUninstall: Boolean(root?.writable),
184
+ ...(origin ? { origin } : {}),
185
+ diagnostics: diagnostics.filter((item) => item.path === skill.path),
186
+ };
187
+ }),
188
+ diagnostics,
189
+ roots: roots.map((root) => ({
190
+ ...root,
191
+ exists: fs.existsSync(root.path),
192
+ skills: skills.filter((skill) => skill.rootId === root.id).length,
193
+ })),
194
+ };
195
+ }
196
+
197
+ export async function readAgentSkill(
198
+ rootId: string,
199
+ name: string,
200
+ ): Promise<SkillDetail | undefined> {
201
+ const roots = agentSkillRoots();
202
+ const { skills, diagnostics } = await discoverSkills(roots);
203
+ const source = skills.find(
204
+ (skill) => skill.name === name && (!rootId || skill.rootId === rootId),
205
+ );
206
+ if (!source) return undefined;
207
+
208
+ const root = roots.find((item) => item.id === source.rootId);
209
+ const registry = readRegistry();
210
+ const origin = registry.origins[registryKey(source.rootId, source.name)];
211
+ const disabledSet = new Set(readSkillsState().disabled);
212
+
213
+ let raw: string;
214
+ try {
215
+ raw = await fsp.readFile(source.path, "utf-8");
216
+ } catch {
217
+ return undefined;
218
+ }
219
+ const parsed = parseSkillFile(source.path, raw);
220
+ if (!parsed.skill) return undefined;
221
+
222
+ return {
223
+ name: source.name,
224
+ description: source.description,
225
+ rootId: source.rootId,
226
+ rootLabel: root?.label ?? source.rootId,
227
+ rootPath: root?.path ?? "",
228
+ dir: source.dir,
229
+ path: source.path,
230
+ resources: source.resources.length,
231
+ resourceList: source.resources,
232
+ modelInvocable: source.invoke.modelInvocable,
233
+ userInvocable: source.invoke.userInvocable,
234
+ enabled: !disabledSet.has(source.name),
235
+ canUninstall: Boolean(root?.writable),
236
+ ...(origin ? { origin } : {}),
237
+ diagnostics: [
238
+ ...diagnostics.filter((item) => item.path === source.path),
239
+ ...parsed.diagnostics,
240
+ ],
241
+ content: parsed.skill.content,
242
+ allowedTools: parsed.skill.allowedTools,
243
+ ...(parsed.skill.license ? { license: parsed.skill.license } : {}),
244
+ ...(parsed.skill.compatibility
245
+ ? { compatibility: parsed.skill.compatibility }
246
+ : {}),
247
+ ...(parsed.skill.metadata ? { metadata: parsed.skill.metadata } : {}),
248
+ };
249
+ }
250
+
251
+ export interface InstallSkillInput {
252
+ rootId: string;
253
+ stagingId: string;
254
+ /** staging 内的相对子目录,空表示根 */
255
+ rel?: string;
256
+ /** 覆盖安装名,默认用 frontmatter 的 name */
257
+ name?: string;
258
+ overwrite?: boolean;
259
+ /** 原始来源输入,写进注册表供更新使用 */
260
+ originInput?: string;
261
+ }
262
+
263
+ export interface InstallSkillResult {
264
+ name: string;
265
+ rootId: string;
266
+ dir: string;
267
+ }
268
+
269
+ export async function installAgentSkill(
270
+ input: InstallSkillInput,
271
+ ): Promise<InstallSkillResult | { error: string }> {
272
+ const root = agentSkillRoots().find((item) => item.id === input.rootId);
273
+ if (!root) return { error: `未知的技能根:${input.rootId}` };
274
+ if (!root.writable) return { error: `技能根 ${root.label} 不允许安装` };
275
+
276
+ const staged = getStaging(input.stagingId);
277
+ if (!staged) return { error: "取件结果已过期,请重新检测来源" };
278
+
279
+ const sourceDir =
280
+ input.rel && input.rel !== "."
281
+ ? path.resolve(staged.dir, input.rel)
282
+ : staged.dir;
283
+ if (!isInside(staged.dir, sourceDir) && sourceDir !== staged.dir) {
284
+ return { error: "候选路径越界" };
285
+ }
286
+ if (!fs.existsSync(sourceDir)) return { error: "候选目录不存在" };
287
+
288
+ let raw: string;
289
+ try {
290
+ raw = await fsp.readFile(path.join(sourceDir, SKILL_FILE_NAME), "utf-8");
291
+ } catch {
292
+ return { error: "候选目录里没有 SKILL.md" };
293
+ }
294
+ const parsed = parseSkillFile(path.join(sourceDir, SKILL_FILE_NAME), raw);
295
+ if (!parsed.skill) {
296
+ return {
297
+ error: `SKILL.md 校验不通过:${parsed.diagnostics[0]?.message ?? "未知原因"}`,
298
+ };
299
+ }
300
+
301
+ const name = String(input.name ?? "").trim() || parsed.skill.name;
302
+ if (!SKILL_NAME_PATTERN.test(name)) {
303
+ return { error: `技能名 "${name}" 不合法(小写字母、数字与单个连字符)` };
304
+ }
305
+
306
+ ensureDir(root.path);
307
+ const target = path.join(root.path, name);
308
+ if (fs.existsSync(target) && !input.overwrite) {
309
+ return { error: `技能 ${name} 已存在于 ${root.label},确认覆盖后再试` };
310
+ }
311
+
312
+ const stagingDir = path.join(root.path, `${INSTALL_TMP_PREFIX}${Date.now()}`);
313
+ try {
314
+ await fsp.cp(sourceDir, stagingDir, { recursive: true, dereference: true });
315
+ if (fs.existsSync(target)) {
316
+ await fsp.rm(target, { recursive: true, force: true });
317
+ }
318
+ await fsp.rename(stagingDir, target);
319
+ } catch (err) {
320
+ await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => {});
321
+ return { error: `写入失败:${err instanceof Error ? err.message : err}` };
322
+ }
323
+
324
+ const registry = readRegistry();
325
+ const originInput = String(input.originInput ?? staged.display);
326
+ const resolved = resolveSkillSource(originInput);
327
+ registry.origins[registryKey(root.id, name)] = {
328
+ kind: "error" in resolved ? "local" : resolved.kind,
329
+ input: originInput,
330
+ display: staged.display,
331
+ installedAt: Date.now(),
332
+ };
333
+ writeRegistry(registry);
334
+
335
+ return { name, rootId: root.id, dir: target };
336
+ }
337
+
338
+ export async function uninstallAgentSkill(
339
+ rootId: string,
340
+ name: string,
341
+ ): Promise<{ ok: true } | { error: string }> {
342
+ if (!SKILL_NAME_PATTERN.test(name)) return { error: "技能名不合法" };
343
+ const root = agentSkillRoots().find((item) => item.id === rootId);
344
+ if (!root) return { error: `未知的技能根:${rootId}` };
345
+ if (!root.writable) return { error: `技能根 ${root.label} 不允许卸载` };
346
+
347
+ const target = path.join(root.path, name);
348
+ if (!isInside(root.path, target)) return { error: "路径越界" };
349
+ if (!fs.existsSync(path.join(target, SKILL_FILE_NAME))) {
350
+ return { error: `在 ${root.label} 下找不到技能 ${name}` };
351
+ }
352
+
353
+ await fsp.rm(target, { recursive: true, force: true });
354
+ const registry = readRegistry();
355
+ delete registry.origins[registryKey(rootId, name)];
356
+ writeRegistry(registry);
357
+ setSkillEnabled(name, true);
358
+ return { ok: true };
359
+ }
360
+
361
+ export function getSkillOrigin(rootId: string, name: string): SkillOrigin | undefined {
362
+ return readRegistry().origins[registryKey(rootId, name)];
363
+ }
364
+
365
+ export function recordSkillOrigin(
366
+ rootId: string,
367
+ name: string,
368
+ origin: SkillOrigin,
369
+ ): void {
370
+ const registry = readRegistry();
371
+ registry.origins[registryKey(rootId, name)] = origin;
372
+ writeRegistry(registry);
373
+ }