@zhangheteng/skill-hub 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +32 -2
- package/apps/cli/dist/args.js +111 -0
- package/apps/cli/dist/commands/audit.js +38 -0
- package/apps/cli/dist/commands/backup.js +34 -0
- package/apps/cli/dist/commands/doctor.js +86 -0
- package/apps/cli/dist/commands/index-cmd.js +122 -0
- package/apps/cli/dist/commands/ingest.js +198 -0
- package/apps/cli/dist/commands/init.js +110 -0
- package/apps/cli/dist/commands/inspect.js +68 -0
- package/apps/cli/dist/commands/inventory.js +259 -0
- package/apps/cli/dist/commands/manage.js +149 -0
- package/apps/cli/dist/commands/manifest.js +127 -0
- package/apps/cli/dist/commands/restore.js +37 -0
- package/apps/cli/dist/commands/route.js +92 -0
- package/apps/cli/dist/commands/runtime.js +89 -0
- package/apps/cli/dist/commands/sync.js +195 -0
- package/apps/cli/dist/commands/takeover.js +45 -0
- package/apps/cli/dist/commands/usage.js +55 -0
- package/apps/cli/dist/commands/verify.js +48 -0
- package/apps/cli/dist/index.js +468 -0
- package/apps/cli/dist/output.js +7 -0
- package/apps/mcp-server/dist/http.js +265 -0
- package/apps/mcp-server/dist/index.js +42 -0
- package/apps/mcp-server/dist/server.js +541 -0
- package/apps/web/dist/index.js +355 -0
- package/apps/web/public/app.js +927 -0
- package/apps/web/public/index.html +109 -0
- package/apps/web/public/styles.css +1796 -0
- package/configs/agents/default.yaml +44 -0
- package/configs/models.yaml +55 -0
- package/configs/profiles/coding.yaml +29 -0
- package/configs/profiles/video.yaml +18 -0
- package/configs/taxonomy.yaml +24 -0
- package/package.json +42 -4
- package/packages/core/dist/agent-sources.js +38 -0
- package/packages/core/dist/audit.js +275 -0
- package/packages/core/dist/catalog.js +173 -0
- package/packages/core/dist/config.js +295 -0
- package/packages/core/dist/conflict.js +95 -0
- package/packages/core/dist/context-cost.js +19 -0
- package/packages/core/dist/frontmatter.js +118 -0
- package/packages/core/dist/hash.js +7 -0
- package/packages/core/dist/history.js +60 -0
- package/packages/core/dist/index.js +23 -0
- package/packages/core/dist/managed.js +1774 -0
- package/packages/core/dist/materialize.js +149 -0
- package/packages/core/dist/parse.js +74 -0
- package/packages/core/dist/scan.js +112 -0
- package/packages/core/dist/takeover.js +96 -0
- package/packages/core/dist/tree.js +61 -0
- package/packages/core/dist/types.js +4 -0
- package/packages/core/dist/usage.js +267 -0
- package/packages/router/dist/bm25.js +144 -0
- package/packages/router/dist/context-cost.js +82 -0
- package/packages/router/dist/external.js +107 -0
- package/packages/router/dist/factory.js +144 -0
- package/packages/router/dist/hub-service.js +589 -0
- package/packages/router/dist/index-store.js +153 -0
- package/packages/router/dist/index.js +22 -0
- package/packages/router/dist/llm/config.js +311 -0
- package/packages/router/dist/llm/http.js +102 -0
- package/packages/router/dist/llm/index.js +6 -0
- package/packages/router/dist/llm/ollama.js +68 -0
- package/packages/router/dist/llm/openai-compatible.js +66 -0
- package/packages/router/dist/llm/registry.js +103 -0
- package/packages/router/dist/llm/types.js +31 -0
- package/packages/router/dist/profile.js +124 -0
- package/packages/router/dist/runtime.js +270 -0
- package/packages/router/dist/scope.js +13 -0
- package/packages/router/dist/taxonomy.js +172 -0
- package/packages/router/dist/tokenize.js +38 -0
- package/packages/router/dist/types.js +1 -0
- package/packages/router/dist/typesafe.js +331 -0
- package/packages/router/dist/usage-overview.js +143 -0
- package/packages/router/dist/usage.js +76 -0
- package/packages/shared/dist/constants.js +14 -0
- package/packages/shared/dist/errors.js +90 -0
- package/packages/shared/dist/index.js +14 -0
- package/packages/shared/dist/logger.js +95 -0
- package/packages/shared/dist/paths.js +118 -0
- package/packages/shared/dist/types.js +12 -0
- package/packages/sync/dist/apply.js +227 -0
- package/packages/sync/dist/backup.js +193 -0
- package/packages/sync/dist/fs-utils.js +188 -0
- package/packages/sync/dist/index.js +18 -0
- package/packages/sync/dist/integrity.js +238 -0
- package/packages/sync/dist/plan.js +211 -0
- package/packages/sync/dist/restore.js +69 -0
- package/packages/sync/dist/scope.js +22 -0
- package/packages/sync/dist/types.js +1 -0
- package/packages/sync/dist/verify.js +189 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { appendUsage, estimateContext, } from "#skill-hub/core";
|
|
3
|
+
import { SkillHubError } from "#skill-hub/shared";
|
|
4
|
+
const scope = new AsyncLocalStorage();
|
|
5
|
+
function enabled() {
|
|
6
|
+
return Boolean(process.env.SKILL_HUB_USAGE_LOG) && scope.getStore()?.record !== false;
|
|
7
|
+
}
|
|
8
|
+
export function withUsageScope(origin, call, record = true) {
|
|
9
|
+
return scope.run({ origin, record }, call);
|
|
10
|
+
}
|
|
11
|
+
async function record(event) {
|
|
12
|
+
const file = process.env.SKILL_HUB_USAGE_LOG;
|
|
13
|
+
if (!file || scope.getStore()?.record === false)
|
|
14
|
+
return;
|
|
15
|
+
try {
|
|
16
|
+
await appendUsage(file, {
|
|
17
|
+
...event,
|
|
18
|
+
version: 1,
|
|
19
|
+
at: new Date().toISOString(),
|
|
20
|
+
origin: scope.getStore()?.origin ?? "library",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Deliberately preserve the original operation; never print the private path or error detail.
|
|
25
|
+
console.error("[skill-hub usage] E_USAGE_WRITE: 本次记录失败,请检查日志路径、格式、权限或大小;原调用结果不受影响。");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function observeRead(name, call) {
|
|
29
|
+
if (!enabled())
|
|
30
|
+
return call();
|
|
31
|
+
let result;
|
|
32
|
+
try {
|
|
33
|
+
result = await call();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const safeName = typeof name === "string" && /^[a-zA-Z0-9._-]{1,256}$/.test(name) ? name : undefined;
|
|
37
|
+
await record({
|
|
38
|
+
operation: "read",
|
|
39
|
+
name: safeName,
|
|
40
|
+
success: false,
|
|
41
|
+
errorCode: error instanceof SkillHubError ? error.code : "E_INTERNAL",
|
|
42
|
+
});
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
await record({
|
|
46
|
+
operation: "read",
|
|
47
|
+
success: true,
|
|
48
|
+
name: result.name,
|
|
49
|
+
kind: result.file.toLowerCase() === "skill.md" ? "body" : "attachment",
|
|
50
|
+
phase: result.offset === 0 ? "initial" : "continuation",
|
|
51
|
+
content: estimateContext(result.body),
|
|
52
|
+
});
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
export async function observeSearch(call) {
|
|
56
|
+
if (!enabled())
|
|
57
|
+
return call();
|
|
58
|
+
let result;
|
|
59
|
+
try {
|
|
60
|
+
result = await call();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
await record({
|
|
64
|
+
operation: "search",
|
|
65
|
+
success: false,
|
|
66
|
+
errorCode: error instanceof SkillHubError ? error.code : "E_INTERNAL",
|
|
67
|
+
});
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
await record({
|
|
71
|
+
operation: "search",
|
|
72
|
+
success: true,
|
|
73
|
+
candidates: result.results.map((r) => r.name),
|
|
74
|
+
});
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** skill-hub 库/CLI 版本号(与根 package.json 对齐,发版时同步)。 */
|
|
2
|
+
export const SKILL_HUB_VERSION = "0.2.0";
|
|
3
|
+
/** 默认用户运行时目录名(位于 `$HOME` 下)。 */
|
|
4
|
+
export const DEFAULT_HUB_DIRNAME = ".skill-hub";
|
|
5
|
+
/** 环境变量:覆盖 hub 根目录。 */
|
|
6
|
+
export const ENV_HUB_HOME = "SKILL_HUB_HOME";
|
|
7
|
+
/** 环境变量:覆盖配置文件路径。 */
|
|
8
|
+
export const ENV_HUB_CONFIG = "SKILL_HUB_CONFIG";
|
|
9
|
+
/** 环境变量:日志级别。 */
|
|
10
|
+
export const ENV_LOG_LEVEL = "SKILL_HUB_LOG_LEVEL";
|
|
11
|
+
/** 默认 catalog 文件名。 */
|
|
12
|
+
export const CATALOG_FILENAME = "catalog.json";
|
|
13
|
+
/** 默认配置文件名。 */
|
|
14
|
+
export const CONFIG_FILENAME = "config.yaml";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一错误码与 SkillHubError。
|
|
3
|
+
* 对齐 `docs/architecture/API.md` §5。
|
|
4
|
+
*/
|
|
5
|
+
/** API 约定的错误码集合。 */
|
|
6
|
+
export const ERROR_CODES = [
|
|
7
|
+
"E_CONFIG",
|
|
8
|
+
"E_PATH",
|
|
9
|
+
"E_PARSE",
|
|
10
|
+
"E_CONFLICT",
|
|
11
|
+
"E_INDEX",
|
|
12
|
+
"E_SYNC",
|
|
13
|
+
"E_BACKUP",
|
|
14
|
+
"E_NOT_FOUND",
|
|
15
|
+
"E_INTERNAL",
|
|
16
|
+
];
|
|
17
|
+
const EXIT_BY_CODE = {
|
|
18
|
+
E_CONFIG: 3,
|
|
19
|
+
E_PATH: 3,
|
|
20
|
+
E_PARSE: 1,
|
|
21
|
+
E_CONFLICT: 2,
|
|
22
|
+
E_INDEX: 1,
|
|
23
|
+
E_SYNC: 1,
|
|
24
|
+
E_BACKUP: 1,
|
|
25
|
+
E_NOT_FOUND: 1,
|
|
26
|
+
E_INTERNAL: 1,
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* 可分类业务错误:禁止静默吞掉,CLI/MCP 应打印 `code` + 可操作信息。
|
|
30
|
+
*/
|
|
31
|
+
export class SkillHubError extends Error {
|
|
32
|
+
code;
|
|
33
|
+
details;
|
|
34
|
+
exitCode;
|
|
35
|
+
cause;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
super(options.message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
38
|
+
this.name = "SkillHubError";
|
|
39
|
+
this.code = options.code;
|
|
40
|
+
this.details = options.details;
|
|
41
|
+
this.exitCode = options.exitCode ?? EXIT_BY_CODE[options.code];
|
|
42
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
43
|
+
}
|
|
44
|
+
/** 人类可读:一行摘要。 */
|
|
45
|
+
toDisplayString() {
|
|
46
|
+
const detail = this.details && Object.keys(this.details).length > 0
|
|
47
|
+
? ` ${JSON.stringify(this.details)}`
|
|
48
|
+
: "";
|
|
49
|
+
return `[${this.code}] ${this.message}${detail}`;
|
|
50
|
+
}
|
|
51
|
+
/** 结构化序列化,便于 `--json`。 */
|
|
52
|
+
toJSON() {
|
|
53
|
+
return {
|
|
54
|
+
name: this.name,
|
|
55
|
+
code: this.code,
|
|
56
|
+
message: this.message,
|
|
57
|
+
exitCode: this.exitCode,
|
|
58
|
+
details: this.details ?? null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** 按错误码创建错误的快捷工厂。 */
|
|
63
|
+
export function createError(code, message, details, cause) {
|
|
64
|
+
return new SkillHubError({ code, message, details, cause });
|
|
65
|
+
}
|
|
66
|
+
/** 将任意抛出值规范为 SkillHubError(未知错误 → E_INTERNAL)。 */
|
|
67
|
+
export function toSkillHubError(err, fallbackMessage = "内部错误") {
|
|
68
|
+
if (err instanceof SkillHubError) {
|
|
69
|
+
return err;
|
|
70
|
+
}
|
|
71
|
+
if (err instanceof Error) {
|
|
72
|
+
return new SkillHubError({
|
|
73
|
+
code: "E_INTERNAL",
|
|
74
|
+
message: err.message || fallbackMessage,
|
|
75
|
+
cause: err,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return new SkillHubError({
|
|
79
|
+
code: "E_INTERNAL",
|
|
80
|
+
message: fallbackMessage,
|
|
81
|
+
details: { value: String(err) },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** 错误码 → CLI 退出码。 */
|
|
85
|
+
export function exitCodeFor(code) {
|
|
86
|
+
return EXIT_BY_CODE[code];
|
|
87
|
+
}
|
|
88
|
+
export function isErrorCode(value) {
|
|
89
|
+
return ERROR_CODES.includes(value);
|
|
90
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @skill-hub/shared — 公共常量、错误码、路径、日志与共享类型。
|
|
3
|
+
*/
|
|
4
|
+
export * from "./constants.js";
|
|
5
|
+
export * from "./errors.js";
|
|
6
|
+
export * from "./paths.js";
|
|
7
|
+
export * from "./logger.js";
|
|
8
|
+
export * from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* @deprecated 脚手架占位,保留以免破坏早期烟雾引用;新代码请勿依赖。
|
|
11
|
+
*/
|
|
12
|
+
export function sharedPlaceholder() {
|
|
13
|
+
return "skill-hub/shared";
|
|
14
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量日志:人类可读 + 可选 JSON 行。
|
|
3
|
+
* 级别可由 `SKILL_HUB_LOG_LEVEL` 或构造参数控制。
|
|
4
|
+
*/
|
|
5
|
+
import { ENV_LOG_LEVEL } from "./constants.js";
|
|
6
|
+
const LEVEL_ORDER = {
|
|
7
|
+
debug: 10,
|
|
8
|
+
info: 20,
|
|
9
|
+
warn: 30,
|
|
10
|
+
error: 40,
|
|
11
|
+
};
|
|
12
|
+
function parseLogLevel(raw, fallback) {
|
|
13
|
+
if (!raw)
|
|
14
|
+
return fallback;
|
|
15
|
+
const v = raw.trim().toLowerCase();
|
|
16
|
+
if (v === "debug" || v === "info" || v === "warn" || v === "error") {
|
|
17
|
+
return v;
|
|
18
|
+
}
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
function formatHuman(level, name, message, fields) {
|
|
22
|
+
const base = `${level.toUpperCase().padEnd(5)} [${name}] ${message}`;
|
|
23
|
+
if (!fields || Object.keys(fields).length === 0) {
|
|
24
|
+
return `${base}\n`;
|
|
25
|
+
}
|
|
26
|
+
return `${base} ${JSON.stringify(fields)}\n`;
|
|
27
|
+
}
|
|
28
|
+
function formatJson(level, name, message, fields, ts) {
|
|
29
|
+
return `${JSON.stringify({
|
|
30
|
+
ts,
|
|
31
|
+
level,
|
|
32
|
+
name,
|
|
33
|
+
message,
|
|
34
|
+
...(fields ?? {}),
|
|
35
|
+
})}\n`;
|
|
36
|
+
}
|
|
37
|
+
class LoggerImpl {
|
|
38
|
+
level;
|
|
39
|
+
json;
|
|
40
|
+
name;
|
|
41
|
+
baseFields;
|
|
42
|
+
stdout;
|
|
43
|
+
stderr;
|
|
44
|
+
now;
|
|
45
|
+
constructor(options = {}, baseFields = {}) {
|
|
46
|
+
this.level = options.level ?? parseLogLevel(process.env[ENV_LOG_LEVEL], "info");
|
|
47
|
+
this.json = options.json ?? false;
|
|
48
|
+
this.name = options.name ?? "skill-hub";
|
|
49
|
+
this.baseFields = baseFields;
|
|
50
|
+
this.stdout = options.stdout ?? process.stdout;
|
|
51
|
+
this.stderr = options.stderr ?? process.stderr;
|
|
52
|
+
this.now = options.now ?? (() => new Date());
|
|
53
|
+
}
|
|
54
|
+
enabled(level) {
|
|
55
|
+
return LEVEL_ORDER[level] >= LEVEL_ORDER[this.level];
|
|
56
|
+
}
|
|
57
|
+
write(level, message, fields) {
|
|
58
|
+
if (!this.enabled(level))
|
|
59
|
+
return;
|
|
60
|
+
const merged = { ...this.baseFields, ...fields };
|
|
61
|
+
const stream = level === "error" || level === "warn" ? this.stderr : this.stdout;
|
|
62
|
+
const line = this.json
|
|
63
|
+
? formatJson(level, this.name, message, merged, this.now().toISOString())
|
|
64
|
+
: formatHuman(level, this.name, message, merged);
|
|
65
|
+
stream.write(line);
|
|
66
|
+
}
|
|
67
|
+
debug(message, fields) {
|
|
68
|
+
this.write("debug", message, fields);
|
|
69
|
+
}
|
|
70
|
+
info(message, fields) {
|
|
71
|
+
this.write("info", message, fields);
|
|
72
|
+
}
|
|
73
|
+
warn(message, fields) {
|
|
74
|
+
this.write("warn", message, fields);
|
|
75
|
+
}
|
|
76
|
+
error(message, fields) {
|
|
77
|
+
this.write("error", message, fields);
|
|
78
|
+
}
|
|
79
|
+
child(fields) {
|
|
80
|
+
return new LoggerImpl({
|
|
81
|
+
level: this.level,
|
|
82
|
+
json: this.json,
|
|
83
|
+
name: this.name,
|
|
84
|
+
stdout: this.stdout,
|
|
85
|
+
stderr: this.stderr,
|
|
86
|
+
now: this.now,
|
|
87
|
+
}, { ...this.baseFields, ...fields });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** 创建 logger 实例。 */
|
|
91
|
+
export function createLogger(options) {
|
|
92
|
+
return new LoggerImpl(options);
|
|
93
|
+
}
|
|
94
|
+
/** 进程级默认 logger(惰性按需可再建)。 */
|
|
95
|
+
export const defaultLogger = createLogger();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径展开与规范化。
|
|
3
|
+
* 原则:写 symlink / 配置落地前一律转为绝对路径。
|
|
4
|
+
*/
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { CATALOG_FILENAME, CONFIG_FILENAME, DEFAULT_HUB_DIRNAME, ENV_HUB_CONFIG, ENV_HUB_HOME, } from "./constants.js";
|
|
8
|
+
import { SkillHubError } from "./errors.js";
|
|
9
|
+
/**
|
|
10
|
+
* 展开 `~` / `~/...` 为绝对 home 路径,再 `path.resolve`。
|
|
11
|
+
* - 空串 → E_PATH
|
|
12
|
+
* - 不自动检查存在性(存在性由调用方 fs 层判断)
|
|
13
|
+
*/
|
|
14
|
+
export function expandUserPath(input, home = homedir()) {
|
|
15
|
+
const raw = input.trim();
|
|
16
|
+
if (!raw) {
|
|
17
|
+
throw new SkillHubError({
|
|
18
|
+
code: "E_PATH",
|
|
19
|
+
message: "路径不能为空",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
let expanded = raw;
|
|
23
|
+
if (raw === "~") {
|
|
24
|
+
expanded = home;
|
|
25
|
+
}
|
|
26
|
+
else if (raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
27
|
+
expanded = path.join(home, raw.slice(2));
|
|
28
|
+
}
|
|
29
|
+
return path.resolve(expanded);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 确保得到绝对路径:相对路径相对 `cwd`(默认 process.cwd())。
|
|
33
|
+
* 若含 `~`,先 expand。
|
|
34
|
+
*/
|
|
35
|
+
export function resolveAbsolutePath(input, options) {
|
|
36
|
+
const home = options?.home ?? homedir();
|
|
37
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
38
|
+
const trimmed = input.trim();
|
|
39
|
+
if (!trimmed) {
|
|
40
|
+
throw new SkillHubError({
|
|
41
|
+
code: "E_PATH",
|
|
42
|
+
message: "路径不能为空",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
46
|
+
return expandUserPath(trimmed, home);
|
|
47
|
+
}
|
|
48
|
+
if (path.isAbsolute(trimmed)) {
|
|
49
|
+
return path.normalize(trimmed);
|
|
50
|
+
}
|
|
51
|
+
return path.resolve(cwd, trimmed);
|
|
52
|
+
}
|
|
53
|
+
/** 默认 `~/.skill-hub`,可被 `SKILL_HUB_HOME` 覆盖。 */
|
|
54
|
+
export function getDefaultHubHome(env = process.env) {
|
|
55
|
+
const override = env[ENV_HUB_HOME]?.trim();
|
|
56
|
+
if (override) {
|
|
57
|
+
return expandUserPath(override);
|
|
58
|
+
}
|
|
59
|
+
return path.join(homedir(), DEFAULT_HUB_DIRNAME);
|
|
60
|
+
}
|
|
61
|
+
/** 默认配置路径:`SKILL_HUB_CONFIG` 或 `$HUB/config.yaml`。 */
|
|
62
|
+
export function getDefaultConfigPath(env = process.env) {
|
|
63
|
+
const override = env[ENV_HUB_CONFIG]?.trim();
|
|
64
|
+
if (override) {
|
|
65
|
+
return expandUserPath(override);
|
|
66
|
+
}
|
|
67
|
+
return path.join(getDefaultHubHome(env), CONFIG_FILENAME);
|
|
68
|
+
}
|
|
69
|
+
/** hub 子路径约定。 */
|
|
70
|
+
export function hubLayout(hubHome = getDefaultHubHome()) {
|
|
71
|
+
const home = expandUserPath(hubHome);
|
|
72
|
+
return {
|
|
73
|
+
home,
|
|
74
|
+
skills: path.join(home, "skills"),
|
|
75
|
+
index: path.join(home, "index"),
|
|
76
|
+
backups: path.join(home, "backups"),
|
|
77
|
+
config: path.join(home, CONFIG_FILENAME),
|
|
78
|
+
catalog: path.join(home, CATALOG_FILENAME),
|
|
79
|
+
history: path.join(home, "history.jsonl"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Skill 名称规范化(DATA_MODEL §3):
|
|
84
|
+
* 1. trim + lower case
|
|
85
|
+
* 2. 空白 → `-`
|
|
86
|
+
* 3. 仅保留 `[a-z0-9._-]`,其余剔除
|
|
87
|
+
* 4. 折叠连续 `-`,去掉首尾 `-`
|
|
88
|
+
* 5. 空名 → E_PARSE
|
|
89
|
+
*/
|
|
90
|
+
export function normalizeSkillName(raw) {
|
|
91
|
+
const lowered = raw.trim().toLowerCase();
|
|
92
|
+
if (!lowered) {
|
|
93
|
+
throw new SkillHubError({
|
|
94
|
+
code: "E_PARSE",
|
|
95
|
+
message: "skill 名称不能为空",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const spaced = lowered.replace(/[\s_]+/g, "-");
|
|
99
|
+
const cleaned = spaced
|
|
100
|
+
.replace(/[^a-z0-9._-]+/g, "")
|
|
101
|
+
.replace(/-+/g, "-")
|
|
102
|
+
.replace(/^-+|-+$/g, "");
|
|
103
|
+
if (!cleaned || cleaned === "." || cleaned === "..") {
|
|
104
|
+
throw new SkillHubError({
|
|
105
|
+
code: "E_PARSE",
|
|
106
|
+
message: "skill 名称规范化后为空",
|
|
107
|
+
details: { raw },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return cleaned;
|
|
111
|
+
}
|
|
112
|
+
/** 判断路径是否落在 `root` 之下(解析后的绝对路径)。 */
|
|
113
|
+
export function isPathInside(child, root) {
|
|
114
|
+
const absChild = resolveAbsolutePath(child);
|
|
115
|
+
const absRoot = resolveAbsolutePath(root);
|
|
116
|
+
const rel = path.relative(absRoot, absChild);
|
|
117
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
118
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨包共享的基础类型(配置摘要、结果外壳等)。
|
|
3
|
+
* 领域实体(SkillMeta 等)以 core 为主;此处放配置与 IO 契约碎片。
|
|
4
|
+
*/
|
|
5
|
+
export function ok(data) {
|
|
6
|
+
return { ok: true, data };
|
|
7
|
+
}
|
|
8
|
+
export function err(code, message, details) {
|
|
9
|
+
return details === undefined
|
|
10
|
+
? { ok: false, code, message }
|
|
11
|
+
: { ok: false, code, message, details };
|
|
12
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { lstat, rename, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SkillHubError } from "#skill-hub/shared";
|
|
4
|
+
import { assertPathAbsent, atomicSymlink, copyPathToEmpty, movePathToEmpty, uniqueSiblingPath, } from "./fs-utils.js";
|
|
5
|
+
import { assertSafeSkillName, inspectProjectionIntegrity } from "./integrity.js";
|
|
6
|
+
/**
|
|
7
|
+
* 执行 sync plan:symlink 或 copy 模式的 create / update / replace。
|
|
8
|
+
* conflict / noop / skipped 跳过;create 动作只接受仍不存在的目标。
|
|
9
|
+
*/
|
|
10
|
+
export async function applySync(plan, options = {}) {
|
|
11
|
+
const dryRun = options.dryRun === true;
|
|
12
|
+
const createOnly = options.createOnly === true;
|
|
13
|
+
const results = [];
|
|
14
|
+
let applied = 0;
|
|
15
|
+
let skipped = 0;
|
|
16
|
+
let failed = 0;
|
|
17
|
+
for (const item of plan.items) {
|
|
18
|
+
if (item.action === "noop" || item.action === "conflict" || item.action === "skipped") {
|
|
19
|
+
results.push({ ...item, result: "skipped" });
|
|
20
|
+
skipped += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (createOnly &&
|
|
24
|
+
(item.action === "update_symlink" ||
|
|
25
|
+
item.action === "replace_real" ||
|
|
26
|
+
item.action === "update_copy" ||
|
|
27
|
+
item.action === "replace_real_copy")) {
|
|
28
|
+
results.push({
|
|
29
|
+
...item,
|
|
30
|
+
result: "skipped",
|
|
31
|
+
detail: item.detail ?? `create-only: 跳过 ${item.action}`,
|
|
32
|
+
});
|
|
33
|
+
skipped += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!isWritableAction(item.action)) {
|
|
37
|
+
results.push({ ...item, result: "skipped" });
|
|
38
|
+
skipped += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (isReplaceAction(item.action) && !dryRun && !options.replaceStashDir) {
|
|
42
|
+
results.push({
|
|
43
|
+
...item,
|
|
44
|
+
result: "failed",
|
|
45
|
+
error: "replace_real/replace_real_copy 需要 replaceStashDir(CLI 由 --backup-dir/replaced-real 提供)",
|
|
46
|
+
});
|
|
47
|
+
failed += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
results.push({ ...item, result: "applied", detail: item.detail ?? "dry-run apply" });
|
|
52
|
+
applied += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
await applyOne(item, options.replaceStashDir, plan.canonicalDir);
|
|
57
|
+
results.push({ ...item, result: "applied" });
|
|
58
|
+
applied += 1;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
results.push({
|
|
62
|
+
...item,
|
|
63
|
+
result: "failed",
|
|
64
|
+
error: err instanceof Error ? err.message : String(err),
|
|
65
|
+
});
|
|
66
|
+
failed += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { dryRun, applied, skipped, failed, items: results };
|
|
70
|
+
}
|
|
71
|
+
async function applyOne(item, replaceStashDir, canonicalDir) {
|
|
72
|
+
const copyMode = item.action === "create_copy" ||
|
|
73
|
+
item.action === "update_copy" ||
|
|
74
|
+
item.action === "replace_real_copy";
|
|
75
|
+
const integrity = await inspectProjectionIntegrity({
|
|
76
|
+
canonicalDir,
|
|
77
|
+
source: item.source,
|
|
78
|
+
target: item.target,
|
|
79
|
+
skillName: item.skillName,
|
|
80
|
+
expectedSkillHash: item.expectedSkillHash,
|
|
81
|
+
requireTreeHash: copyMode,
|
|
82
|
+
});
|
|
83
|
+
const safeItem = { ...item, source: integrity.source, target: integrity.target };
|
|
84
|
+
if (isReplaceAction(safeItem.action)) {
|
|
85
|
+
if (!replaceStashDir) {
|
|
86
|
+
throw new SkillHubError({
|
|
87
|
+
code: "E_SYNC",
|
|
88
|
+
message: "replace_real/replace_real_copy 缺少 stash 目录",
|
|
89
|
+
details: { target: safeItem.target },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await replaceRealProjection(safeItem, replaceStashDir);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (safeItem.action === "create_copy" || safeItem.action === "create_symlink") {
|
|
96
|
+
// 两个 helper 都会二次 lstat,并且只把 ENOENT 视作可创建。
|
|
97
|
+
await createProjection(safeItem);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (safeItem.action === "update_copy" || safeItem.action === "update_symlink") {
|
|
101
|
+
await replaceSymlinkProjection(safeItem);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw new SkillHubError({
|
|
105
|
+
code: "E_SYNC",
|
|
106
|
+
message: `未知同步 action: ${safeItem.action}`,
|
|
107
|
+
details: { action: safeItem.action, target: safeItem.target },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async function replaceRealProjection(item, replaceStashDir) {
|
|
111
|
+
const targetInfo = await lstat(item.target);
|
|
112
|
+
if (targetInfo.isSymbolicLink()) {
|
|
113
|
+
throw new SkillHubError({
|
|
114
|
+
code: "E_SYNC",
|
|
115
|
+
message: `replace_real 目标是 symlink,请使用 update action: ${item.target}`,
|
|
116
|
+
details: { target: item.target },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (!targetInfo.isDirectory() && !targetInfo.isFile()) {
|
|
120
|
+
throw new SkillHubError({
|
|
121
|
+
code: "E_SYNC",
|
|
122
|
+
message: `replace_real 目标类型不支持: ${item.target}`,
|
|
123
|
+
details: { target: item.target },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
assertSafeStashSegment(item.agentId, "agent id");
|
|
127
|
+
assertSafeSkillName(item.skillName);
|
|
128
|
+
const stashPath = path.join(replaceStashDir, item.agentId, item.skillName);
|
|
129
|
+
// 不覆盖任何既存 stash(包括坏链);保留可追溯历史备份。
|
|
130
|
+
await assertPathAbsent(stashPath);
|
|
131
|
+
await movePathToEmpty(item.target, stashPath);
|
|
132
|
+
try {
|
|
133
|
+
await createProjection(item);
|
|
134
|
+
}
|
|
135
|
+
catch (projectionError) {
|
|
136
|
+
try {
|
|
137
|
+
// 投影未创建时原目标必须回到原路径;若此时有第三方新建 target,宁可失败也不覆盖。
|
|
138
|
+
await movePathToEmpty(stashPath, item.target);
|
|
139
|
+
}
|
|
140
|
+
catch (restoreError) {
|
|
141
|
+
throw new SkillHubError({
|
|
142
|
+
code: "E_SYNC",
|
|
143
|
+
message: `创建投影失败,且无法恢复原目标: ${item.target}`,
|
|
144
|
+
details: { target: item.target, stashPath },
|
|
145
|
+
cause: new AggregateError([projectionError, restoreError]),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
throw projectionError;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** update 只替换计划中已观察到的 symlink;失败时恢复旧链。 */
|
|
152
|
+
async function replaceSymlinkProjection(item) {
|
|
153
|
+
const targetInfo = await lstat(item.target);
|
|
154
|
+
if (!targetInfo.isSymbolicLink()) {
|
|
155
|
+
throw new SkillHubError({
|
|
156
|
+
code: "E_SYNC",
|
|
157
|
+
message: `目标不再是 symlink,拒绝 update: ${item.target}`,
|
|
158
|
+
details: { target: item.target },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const rollbackPath = uniqueSiblingPath(item.target, "rollback");
|
|
162
|
+
await assertPathAbsent(rollbackPath);
|
|
163
|
+
await rename(item.target, rollbackPath);
|
|
164
|
+
try {
|
|
165
|
+
await createProjection(item);
|
|
166
|
+
}
|
|
167
|
+
catch (projectionError) {
|
|
168
|
+
try {
|
|
169
|
+
await assertPathAbsent(item.target);
|
|
170
|
+
await rename(rollbackPath, item.target);
|
|
171
|
+
}
|
|
172
|
+
catch (restoreError) {
|
|
173
|
+
throw new SkillHubError({
|
|
174
|
+
code: "E_SYNC",
|
|
175
|
+
message: `更新投影失败,且无法恢复旧 symlink: ${item.target}`,
|
|
176
|
+
details: { target: item.target, rollbackPath },
|
|
177
|
+
cause: new AggregateError([projectionError, restoreError]),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
throw projectionError;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
await rm(rollbackPath, { force: false });
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
throw new SkillHubError({
|
|
187
|
+
code: "E_SYNC",
|
|
188
|
+
message: `投影已更新,但无法清理旧 symlink: ${rollbackPath}`,
|
|
189
|
+
details: { target: item.target, rollbackPath },
|
|
190
|
+
cause: err,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function createProjection(item) {
|
|
195
|
+
if (item.action === "create_copy" ||
|
|
196
|
+
item.action === "update_copy" ||
|
|
197
|
+
item.action === "replace_real_copy") {
|
|
198
|
+
await copyPathToEmpty(item.source, item.target);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
await atomicSymlink(item.source, item.target);
|
|
202
|
+
}
|
|
203
|
+
function isWritableAction(action) {
|
|
204
|
+
return (action === "create_symlink" ||
|
|
205
|
+
action === "update_symlink" ||
|
|
206
|
+
action === "replace_real" ||
|
|
207
|
+
action === "create_copy" ||
|
|
208
|
+
action === "update_copy" ||
|
|
209
|
+
action === "replace_real_copy");
|
|
210
|
+
}
|
|
211
|
+
function isReplaceAction(action) {
|
|
212
|
+
return action === "replace_real" || action === "replace_real_copy";
|
|
213
|
+
}
|
|
214
|
+
function assertSafeStashSegment(value, label) {
|
|
215
|
+
if (!value ||
|
|
216
|
+
value === "." ||
|
|
217
|
+
value === ".." ||
|
|
218
|
+
value.includes("/") ||
|
|
219
|
+
value.includes("\\") ||
|
|
220
|
+
value.includes("\0")) {
|
|
221
|
+
throw new SkillHubError({
|
|
222
|
+
code: "E_SYNC",
|
|
223
|
+
message: `非法 ${label},拒绝构造 stash 路径: ${JSON.stringify(value)}`,
|
|
224
|
+
details: { value, label },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|