dsh-skill-hub 0.3.20 → 0.3.22

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.
Files changed (42) hide show
  1. package/CONTRIBUTING.md +2 -2
  2. package/README.md +5 -4
  3. package/README.zh.md +5 -4
  4. package/lib/client.js +2 -2
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +362 -74
  7. package/lib/types/client/locales/settings.d.ts +1 -1
  8. package/lib/types/client/locales.d.ts +1 -1
  9. package/lib/types/protocol/config.d.ts +1 -1
  10. package/lib/types/protocol/sources.d.ts +5 -0
  11. package/lib/types/reconcile.d.ts +24 -0
  12. package/lib/types/routes/deps.d.ts +8 -1
  13. package/lib/types/routes/helpers.d.ts +11 -6
  14. package/lib/types/routes/http.d.ts +27 -2
  15. package/lib/types/routes/route-state.d.ts +22 -2
  16. package/lib/types/routes.d.ts +29 -4
  17. package/lib/types/skillfs/scan.d.ts +26 -0
  18. package/lib/types/skillfs.d.ts +9 -1
  19. package/lib/types/store/migrate.d.ts +1 -1
  20. package/lib/types/store/store.d.ts +10 -5
  21. package/package.json +1 -1
  22. package/src/client/locales/settings.ts +2 -2
  23. package/src/index.ts +26 -4
  24. package/src/protocol/config.ts +1 -1
  25. package/src/protocol/sources.ts +5 -0
  26. package/src/reconcile.test.ts +74 -1
  27. package/src/reconcile.ts +50 -1
  28. package/src/routes/catalog.ts +50 -22
  29. package/src/routes/deps.ts +8 -2
  30. package/src/routes/helpers.ts +18 -7
  31. package/src/routes/http.ts +31 -2
  32. package/src/routes/repo-import.ts +16 -26
  33. package/src/routes/route-state.test.ts +4 -4
  34. package/src/routes/route-state.ts +45 -10
  35. package/src/routes/sources.ts +79 -23
  36. package/src/routes.test.ts +302 -4
  37. package/src/routes.ts +49 -5
  38. package/src/skillfs/scan.ts +91 -2
  39. package/src/skillfs.ts +14 -2
  40. package/src/store/migrate.ts +6 -4
  41. package/src/store/store.ts +21 -11
  42. package/src/store.test.ts +17 -1
@@ -21,7 +21,7 @@ export declare const zhSettings: {
21
21
  readonly 'settings.statsScanMinutes': "自动统计间隔(分钟)";
22
22
  readonly 'settings.statsScanMinutesHint': "后台扫描会话日志的间隔,最小 1 分钟;扫描耗时会自动拉长间隔。";
23
23
  readonly 'settings.githubToken': "GitHub Token";
24
- readonly 'settings.githubTokenHint': "市场/来源走 GitHub API,匿名每小时 60 次,填 token 后 5000 次。细粒度 token 无需任何权限。明文存本地设置,仅回环可读;留空即匿名(或跟随 GITHUB_TOKEN 环境变量)。";
24
+ readonly 'settings.githubTokenHint': "市场/来源走 GitHub API,匿名每小时 60 次,填 token 后 5000 次。细粒度 token 无需任何权限。明文存本地设置,仅经 dsh 的 /api 信任栅栏可读(回环 / 绑定地址 / --trusted-host);留空即匿名(或跟随 GITHUB_TOKEN 环境变量)。";
25
25
  readonly 'settings.githubTokenPlaceholder': "ghp_… / github_pat_…(留空为匿名)";
26
26
  readonly 'settings.inherit': "继承";
27
27
  readonly 'settings.overridden': "已覆盖";
@@ -26,7 +26,7 @@ export declare const zh: {
26
26
  readonly 'settings.statsScanMinutes': "自动统计间隔(分钟)";
27
27
  readonly 'settings.statsScanMinutesHint': "后台扫描会话日志的间隔,最小 1 分钟;扫描耗时会自动拉长间隔。";
28
28
  readonly 'settings.githubToken': "GitHub Token";
29
- readonly 'settings.githubTokenHint': "市场/来源走 GitHub API,匿名每小时 60 次,填 token 后 5000 次。细粒度 token 无需任何权限。明文存本地设置,仅回环可读;留空即匿名(或跟随 GITHUB_TOKEN 环境变量)。";
29
+ readonly 'settings.githubTokenHint': "市场/来源走 GitHub API,匿名每小时 60 次,填 token 后 5000 次。细粒度 token 无需任何权限。明文存本地设置,仅经 dsh 的 /api 信任栅栏可读(回环 / 绑定地址 / --trusted-host);留空即匿名(或跟随 GITHUB_TOKEN 环境变量)。";
30
30
  readonly 'settings.githubTokenPlaceholder': "ghp_… / github_pat_…(留空为匿名)";
31
31
  readonly 'settings.inherit': "继承";
32
32
  readonly 'settings.overridden': "已覆盖";
@@ -57,7 +57,7 @@ export type HubSettingsValue = {
57
57
  statsWindowDays?: number;
58
58
  /** 自动统计扫描间隔(分钟)。 */
59
59
  statsScanMinutes?: number;
60
- /** GitHub token(明文存本地设置文档,仅回环可读;缺省为匿名)。 */
60
+ /** GitHub token(明文存本地设置文档,仅经 dsh 的 /api 信任栅栏可读;缺省为匿名)。 */
61
61
  githubToken?: string;
62
62
  };
63
63
  /**
@@ -134,6 +134,11 @@ export interface SourceTrashClearResponse {
134
134
  name: string;
135
135
  error: string;
136
136
  }>;
137
+ /**
138
+ * `.trash` 里没有对应记录的残留目录(同名技能先后两次入站时,旧记录被覆盖,
139
+ * 目录却留了下来)。清空回收站时一并回收,这里上报它们的路径;没有则省略。
140
+ */
141
+ orphans?: string[];
137
142
  }
138
143
  /** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
139
144
  export declare function isProjectSource(source: string): boolean;
@@ -15,6 +15,10 @@ export interface DisabledReconcileStore {
15
15
  listDisabled(): Promise<DisabledSkill[]>;
16
16
  addDisabled(entry: DisabledSkill): Promise<void>;
17
17
  }
18
+ /** Narrow store view used by the reverse reconcile (adds record removal). */
19
+ export interface DisabledPruneStore extends DisabledReconcileStore {
20
+ removeDisabled(name: string): Promise<void>;
21
+ }
18
22
  /**
19
23
  * Add sidecar disabled records for every `.disabled` discovery file that has
20
24
  * none yet. Existing records (matched by path or by name) win, so this never
@@ -22,3 +26,23 @@ export interface DisabledReconcileStore {
22
26
  * in the diagnostics scan instead). Returns the records added.
23
27
  */
24
28
  export declare function reconcileDisabledSkills(store: DisabledReconcileStore, home?: string): Promise<DisabledSkill[]>;
29
+ /** 反向对账的结果:修好路径的记录 + 被清掉的死记录。 */
30
+ export interface DisabledPruneResult {
31
+ /** 记录还在、但 path 指向的文件已不在:已改指向现存的 `.disabled` 文件。 */
32
+ repaired: DisabledSkill[];
33
+ /** 文件与禁用占位都已不存在:记录已删除,名字被释放。 */
34
+ removed: string[];
35
+ }
36
+ /**
37
+ * 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
38
+ * 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
39
+ * 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
40
+ * 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
41
+ *
42
+ * 两种处理,都不动启用态技能:
43
+ * - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
44
+ * - 一个都没有 → 删记录,把名字还给用户。
45
+ *
46
+ * 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
47
+ */
48
+ export declare function pruneStaleDisabledSkills(store: DisabledPruneStore, home?: string): Promise<DisabledPruneResult>;
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * routes 共享层 · 依赖视图:路由依赖接口、配置解析与可写技能解析(HTTP
3
- * 围栏在 ./http.ts)。从 helpers.ts 原样搬出,行为不变。
3
+ * 围栏在 ./http.ts)。
4
4
  */
5
5
  import type { ServerResponse } from 'node:http';
6
6
  import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill';
7
7
  import { type HubConfig, type WritableRoot } from '../protocol.ts';
8
8
  import { type SkillHubStore } from '../store.ts';
9
+ import { type RouteFence } from './http.ts';
9
10
  /** Sources the hub may toggle (the user-level filesystem roots). */
10
11
  export declare function isWritableSource(source: string): source is WritableRoot;
11
12
  /** Lookup options the hub forwards to the registry (cwd selects project roots). */
@@ -22,6 +23,12 @@ export interface SkillHubRouteDeps {
22
23
  get(name: string, options?: SkillLookupLike): Promise<SkillDefinition | undefined>;
23
24
  };
24
25
  store: SkillHubStore;
26
+ /**
27
+ * Request fence for the whole family: returns the rejection status (401/403)
28
+ * or `undefined` to proceed. Production passes the host's own `/api` fence
29
+ * (`ctx.connection.requestRejection`); absent falls back to literal loopback.
30
+ */
31
+ fence?: RouteFence;
25
32
  /** DSH home override (tests isolate the writable roots; defaults to ~/.dsh). */
26
33
  home?: string;
27
34
  /** Invalidate the registry catalog cache after hub-driven mutations. */
@@ -1,15 +1,15 @@
1
1
  /**
2
- * routes 共享层:请求围栏(回环信任/方法/总开关/JSON 体)、统一错误映射、
3
- * 可写技能解析、目录/分组数据装配。handler 只写业务,从这里 import。
4
- * 从 routes.ts 原样搬出,行为不变。
5
- * 本文件是聚合出口(barrel):实现按域拆到 ./http.ts(围栏/错误映射/读体)、
2
+ * routes 共享层:请求栅栏(宿主 /api 信任模型 → 方法 → 总开关 → JSON 体)、
3
+ * 统一错误映射、可写技能解析、目录/分组数据装配。handler 只写业务,从这里 import。
4
+ * 本文件是聚合出口(barrel):实现按域拆到 ./http.ts(栅栏/错误映射/读体)、
6
5
  * ./deps.ts(依赖视图/可写解析)、./collection.ts(集合装配)、
7
6
  * ./catalog-data.ts(目录装配);对外导出面保持不变。
8
7
  */
9
8
  import type { IncomingMessage, ServerResponse } from 'node:http';
10
9
  import type { WebRoute, WebRouteKind } from '@deepseek-ai/dsh-host-webserver';
11
10
  import { type SkillHubRouteDeps } from './deps.ts';
12
- export { MAX_JSON_BODY_BYTES, STORE_ERROR_STATUS, isLoopbackRequest, pathExists, queryParam, readJsonBody, readString, readStrings, writeError, writeJson, writeRouteError, } from './http.ts';
11
+ export { MAX_JSON_BODY_BYTES, STORE_ERROR_STATUS, isLoopbackRequest, loopbackOnlyFence, pathExists, queryParam, readJsonBody, readString, readStrings, writeError, writeJson, writeRouteError, } from './http.ts';
12
+ export type { RouteFence } from './http.ts';
13
13
  export { configOf, disabledGate, homeOf, isWritableSource, resolveWritableSkill, savedOf, } from './deps.ts';
14
14
  export type { SkillHubRouteDeps, SkillLookupLike, WritableSkill, WritableSkillRefusal, WritableSkillResult, } from './deps.ts';
15
15
  export { buildCollections, buildGroups } from './collection.ts';
@@ -45,8 +45,13 @@ export interface RouteSpec {
45
45
  }
46
46
  /**
47
47
  * Wrap one handler in the shared request fences. Order matches the
48
- * original per-handler prologue: loopback trust → HTTP method → master
48
+ * original per-handler prologue: trust fence → HTTP method → master
49
49
  * switch → JSON body. The outer catch maps errors onto the JSON error
50
50
  * body (preserving RepoFetchError status codes).
51
+ *
52
+ * The trust fence is the host's `/api` model (`deps.fence`) whenever the
53
+ * composition mounts Connection, so a reverse-proxied deployment is judged by
54
+ * the same authorities dsh already trusts; without that service the historical
55
+ * literal-loopback check stands in.
51
56
  */
52
57
  export declare function createRoute(deps: SkillHubRouteDeps, spec: RouteSpec): WebRoute;
@@ -1,12 +1,37 @@
1
1
  /**
2
- * routes 共享层 · HTTP 围栏:回环信任检查、JSON 响应与错误映射、请求体读取、
3
- * 查询/字段读取与文件存在性检查。从 helpers.ts 原样搬出,行为不变。
2
+ * routes 共享层 · HTTP 围栏:请求信任栅栏(复用宿主 `/api` 模型,见 RouteFence)、
3
+ * JSON 响应与错误映射、请求体读取、查询/字段读取与文件存在性检查。
4
4
  */
5
5
  import type { IncomingMessage, ServerResponse } from 'node:http';
6
6
  /** Cap on JSON request bodies (toggle/create payloads are tiny). */
7
7
  export declare const MAX_JSON_BODY_BYTES: number;
8
8
  /** Loopback literal check plus browser same-origin markers. */
9
9
  export declare function isLoopbackRequest(request: IncomingMessage): boolean;
10
+ /**
11
+ * The route family's request fence: the HTTP status a request is rejected
12
+ * with (`401` unauthenticated / `403` untrusted authority), or `undefined`
13
+ * when it may reach the handler.
14
+ *
15
+ * dsh owns **one** browser-trust model for the whole `/api` tree — loopback,
16
+ * the listener's own bind address, every `--trusted-host` authority, then the
17
+ * browser session — and publishes it as `ctx.connection.requestRejection`,
18
+ * whose contract is written for exactly this case ("apply Connection's
19
+ * Host/Origin checks ... to another Web route"). Our paths are exact routes
20
+ * registered beside the shared `/api` prefix, so the host's own fence never
21
+ * sees them; asking its entry point instead of re-deriving the rule keeps a
22
+ * single trust model rather than a second, stricter one. Issue #17 is what the
23
+ * second one cost: behind a reverse proxy the literals checkable in this file
24
+ * are the gateway address and the public domain, so every route answered 403
25
+ * while the rest of the GUI worked.
26
+ */
27
+ export type RouteFence = (request: IncomingMessage) => number | undefined;
28
+ /**
29
+ * Fallback fence for a host that mounts no Connection service (unit tests, and
30
+ * any composition serving the webserver alone): the historical literal-loopback
31
+ * check, so a missing service degrades to the previous behaviour instead of an
32
+ * open or a dead route family.
33
+ */
34
+ export declare function loopbackOnlyFence(request: IncomingMessage): number | undefined;
10
35
  /** One JSON response. */
11
36
  export declare function writeJson(res: ServerResponse, status: number, body: unknown): void;
12
37
  /** One JSON error response. */
@@ -54,5 +54,25 @@ export declare const importJobs: Map<string, ImportJob>;
54
54
  export declare const IMPORT_JOB_TTL_MS: number;
55
55
  export declare const IMPORT_JOB_MAX = 100;
56
56
  export declare function gcImportJobs(): void;
57
- /** Replace one skill directory with a fresh download; restores the old dir on failure. */
58
- export declare function replaceSkillDir(targetDir: string, download: () => Promise<void>): Promise<void>;
57
+ /**
58
+ * Replace whatever currently occupies a skill slot with a fresh download;
59
+ * restores the previous state on failure.
60
+ *
61
+ * `target` is the entry to move aside — a bundle **directory** or a flat
62
+ * `<name>.md` file, whichever the download is replacing (the rename/rm below
63
+ * are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
64
+ * so even a failed cleanup can never register as a duplicate skill.
65
+ */
66
+ export declare function replaceInPlace(target: string, download: () => Promise<unknown>): Promise<void>;
67
+ /**
68
+ * 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
69
+ * 只动这两类,**启用的技能一律不碰**:
70
+ * - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
71
+ * - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
72
+ *
73
+ * 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
74
+ * 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
75
+ *
76
+ * @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
77
+ */
78
+ export declare function retireNamePlaceholders(deps: SkillHubRouteDeps, base: string, name: string): Promise<string[] | undefined>;
@@ -3,20 +3,45 @@
3
3
  * official registry + hub-disabled skills + discovery diagnostics), skill
4
4
  * detail, enable/disable toggle, new-skill scaffold, user groups (tags +
5
5
  * origin collections), and upstream source tracking (check/sync/follow
6
- * upstream deletion into a restorable trash). Every route carries a
7
- * loopback-only trust fence — these endpoints rename files under the user's
8
- * skill roots, so LAN-exposed dsh web deployments must not serve them.
6
+ * upstream deletion into a restorable trash). Every route carries the host's
7
+ * own `/api` trust fence (loopback, the listener's bind address, each
8
+ * `--trusted-host` authority, then the browser session) — these endpoints
9
+ * rename files under the user's skill roots, so they must never be laxer than
10
+ * the GUI around them (issue #17).
9
11
  *
10
12
  * 按域拆分后的聚合入口:各域 handler 在 ./routes/<domain>.ts,共享围栏在
11
13
  * ./routes/helpers.ts,节流/任务状态在 ./routes/route-state.ts。
12
14
  * 老 `from './routes.ts'`(makeRoutes + SkillHubRouteDeps)保持可用。
13
15
  */
16
+ import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection';
14
17
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
15
18
  import { type SkillHubRouteDeps } from './routes/helpers.ts';
19
+ import { type RouteFence } from './routes/http.ts';
16
20
  export type { SkillHubRouteDeps, SkillLookupLike } from './routes/helpers.ts';
21
+ export type { RouteFence } from './routes/http.ts';
22
+ /**
23
+ * Build the family's trust fence from the host's `/api` model.
24
+ *
25
+ * `ctx.connection.requestRejection` is the documented hook for fencing another
26
+ * Web route; with no Connection service (a webserver-only composition, or a
27
+ * unit test) the literal-loopback check stands in, so the fallback is the old
28
+ * behaviour rather than an open route family.
29
+ *
30
+ * The service is read **per request**, not once at mount: plugins start in
31
+ * composition order, so a one-shot read at `apply()` time would latch the
32
+ * fallback whenever this plugin happens to come up before Connection. A
33
+ * persistent absence is almost certainly an upstream composition change rather
34
+ * than a transient startup order, so the first miss fires `onMissing` once —
35
+ * without that signal the fallback would silently reproduce issue #17 after
36
+ * any future upstream rename of the service.
37
+ * @param read - resolves `ctx.connection`, or `undefined` when absent.
38
+ * @param onMissing - called once, the first time the service reads absent.
39
+ * @returns the fence every route in this family runs first.
40
+ */
41
+ export declare function hostTrustFence(read: () => HostConnectionHandle | undefined, onMissing?: () => void): RouteFence;
17
42
  /**
18
43
  * Build every /api/skill-hub route.
19
- * @param deps - skill registry view + sidecar store.
44
+ * @param deps - skill registry view + sidecar store + trust fence.
20
45
  * @returns the exact-path routes plus the family's 404 catch-all.
21
46
  */
22
47
  export declare function makeRoutes(deps: SkillHubRouteDeps): WebRoute[];
@@ -46,3 +46,29 @@ export declare function findProjectRoot(cwd: string): Promise<string>;
46
46
  * a defect.
47
47
  */
48
48
  export declare function scanDiagnostics(root: WritableRoot, home?: string): Promise<DiagnosticEntry[]>;
49
+ /**
50
+ * 某个名字在一个 root 下现存的禁用发现文件(两种形态都认),没有则 undefined。
51
+ * 目录包形态优先(`<name>/SKILL.md.disabled`),其次扁平 `<name>.md.disabled`。
52
+ * 单点定义「禁用态在磁盘上长什么样」,避免各处再拼一次路径(issue #16 类)。
53
+ */
54
+ export declare function disabledOccupancyIn(base: string, name: string): Promise<string | undefined>;
55
+ /**
56
+ * 一个名字在某个 root 下的全部磁盘占位。技能名是系统里唯一的身份,但同一个名字
57
+ * 可以被两种形态(目录包 `<name>/SKILL.md` / 扁平 `<name>.md`)× 两种状态
58
+ * (启用 / `.disabled` 改名)占据,而且两种形态可以**同时存在** —— 注册表只会
59
+ * 给出其中一个,另一个静默不可达。所有按名字操作文件的路径都应该从这里取值,
60
+ * 而不是各自拼一次路径(issue #16 类的根因)。
61
+ *
62
+ * 两个数组都按「目录包优先」排序:目录包是 hub 自己写入的形态(导入、创建、
63
+ * 同步都建目录包),冲突时它是更「权威」的那份。
64
+ */
65
+ export interface NameOccupancy {
66
+ /** 启用态发现文件(目录包 `<name>/SKILL.md` 在前,扁平 `<name>.md` 在后)。 */
67
+ enabled: string[];
68
+ /** 禁用态发现文件(`<name>/SKILL.md.disabled` 在前,`<name>.md.disabled` 在后)。 */
69
+ disabled: string[];
70
+ /** `<name>/` 存在,但既没有 `SKILL.md` 也没有 `SKILL.md.disabled`(空壳/残留目录)。 */
71
+ bareDir?: string;
72
+ }
73
+ /** 读一个名字在某个 skills root 下的全部占位。只读,不修改磁盘。 */
74
+ export declare function occupancyOf(base: string, name: string): Promise<NameOccupancy>;
@@ -45,11 +45,19 @@ export declare function moveIntoTrash(source: string): Promise<{
45
45
  path: string;
46
46
  source: string;
47
47
  }>;
48
+ /**
49
+ * The filesystem entry behind a discovery file: a directory bundle is addressed
50
+ * by its `SKILL.md` (or `SKILL.md.disabled`) and moved as a whole directory, a
51
+ * flat `<name>.md` file is moved as itself. Single definition of that mapping —
52
+ * delete, restore, sync-replace and placeholder retirement must all agree on it,
53
+ * or one of them moves a bundle's discovery file and leaves an empty directory.
54
+ */
55
+ export declare function trashSourceOf(discoveryPath: string): string;
48
56
  /**
49
57
  * Move a skill (directory bundle or flat .md file) into the root's trash.
50
58
  * The skill stays recoverable: the source is renamed into
51
59
  * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
52
- * SKILL.md path and moved as a whole directory.
60
+ * SKILL.md (or SKILL.md.disabled) path and moved as a whole directory.
53
61
  * @returns the trashed path plus the original path that was moved.
54
62
  */
55
63
  export declare function trashSkill(sourcePath: string): Promise<{
@@ -31,7 +31,7 @@ export interface HydratedState {
31
31
  tagsById: Map<string, SkillTag>;
32
32
  sourcesByRepo: Map<string, SourceRecord>;
33
33
  marketSources: MarketSourceRecord[];
34
- trashByName: Map<string, TrashEntry>;
34
+ trashByPath: Map<string, TrashEntry>;
35
35
  skillStats: SkillStatsCheckpoint | undefined;
36
36
  marketStats: MarketStatsSnapshot | undefined;
37
37
  collectionOrder: string[];
@@ -7,7 +7,7 @@ export declare class SkillHubStore {
7
7
  private tagsById;
8
8
  private sourcesByRepo;
9
9
  private marketSources;
10
- private trashByName;
10
+ private trashByPath;
11
11
  private skillStats;
12
12
  private marketStats;
13
13
  private collectionOrder;
@@ -119,12 +119,17 @@ export declare class SkillHubStore {
119
119
  setMarketSourceCommit(repo: string, commitSha: string): Promise<void>;
120
120
  /** All trashed skills, newest first. */
121
121
  listTrash(): Promise<TrashEntry[]>;
122
- /** One trash entry by skill name (undefined when absent). */
122
+ /** One trash entry by skill name — the newest when a name has several. */
123
123
  getTrash(name: string): Promise<TrashEntry | undefined>;
124
- /** Record a trashed skill. */
124
+ /**
125
+ * Record a trashed skill, keyed by its trashed path. Keying by name used to
126
+ * silently drop the previous record when the same name landed in the trash
127
+ * twice (a re-delete, or the two on-disk discovery forms), orphaning its
128
+ * directory with nothing left pointing at it.
129
+ */
125
130
  addTrash(entry: TrashEntry): Promise<void>;
126
- /** Remove a trash record (after restore). */
127
- removeTrash(name: string): Promise<void>;
131
+ /** Remove a trash record by its trashed path (after restore or permanent clear). */
132
+ removeTrash(path: string): Promise<void>;
128
133
  /** The persisted usage-statistics checkpoint (undefined until first saved). */
129
134
  getSkillStatsState(): Promise<SkillStatsCheckpoint | undefined>;
130
135
  /** Persist a usage-statistics checkpoint (after every completed scan; cadence follows the scan TTL). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -21,7 +21,7 @@ export const zhSettings = {
21
21
  'settings.statsScanMinutes': '自动统计间隔(分钟)',
22
22
  'settings.statsScanMinutesHint': '后台扫描会话日志的间隔,最小 1 分钟;扫描耗时会自动拉长间隔。',
23
23
  'settings.githubToken': 'GitHub Token',
24
- 'settings.githubTokenHint': '市场/来源走 GitHub API,匿名每小时 60 次,填 token 后 5000 次。细粒度 token 无需任何权限。明文存本地设置,仅回环可读;留空即匿名(或跟随 GITHUB_TOKEN 环境变量)。',
24
+ 'settings.githubTokenHint': '市场/来源走 GitHub API,匿名每小时 60 次,填 token 后 5000 次。细粒度 token 无需任何权限。明文存本地设置,仅经 dsh 的 /api 信任栅栏可读(回环 / 绑定地址 / --trusted-host);留空即匿名(或跟随 GITHUB_TOKEN 环境变量)。',
25
25
  'settings.githubTokenPlaceholder': 'ghp_… / github_pat_…(留空为匿名)',
26
26
  'settings.inherit': '继承',
27
27
  'settings.overridden': '已覆盖',
@@ -59,7 +59,7 @@ export const enSettings: Record<keyof typeof zhSettings, string> = {
59
59
  'settings.statsScanMinutes': 'Auto stats interval (minutes)',
60
60
  'settings.statsScanMinutesHint': 'How often the background session-log scan runs (min 1); heavy scans automatically stretch it.',
61
61
  'settings.githubToken': 'GitHub token',
62
- 'settings.githubTokenHint': 'Market/sources use the GitHub API: 60 req/h anonymous, 5000/hr with a token. A fine-grained token needs no permissions. Stored plaintext in local settings, loopback-only; empty means anonymous (or GITHUB_TOKEN env).',
62
+ 'settings.githubTokenHint': 'Market/sources use the GitHub API: 60 req/h anonymous, 5000/hr with a token. A fine-grained token needs no permissions. Stored plaintext in local settings, reachable only through dsh\'s /api trust fence (loopback / bind address / --trusted-host); empty means anonymous (or GITHUB_TOKEN env).',
63
63
  'settings.githubTokenPlaceholder': 'ghp_… / github_pat_… (empty = anonymous)',
64
64
  'settings.inherit': 'Inherit',
65
65
  'settings.overridden': 'Overridden',
package/src/index.ts CHANGED
@@ -14,6 +14,8 @@ import type { SettingsForms, SettingsNamespace, SettingsPathOp } from '@deepseek
14
14
  // loader's `fiber.runtime.Config` handling) only exist here. Every official
15
15
  // plugin imports it under this name.
16
16
  import z from '@deepseek-ai/schemastery'
17
+ // Type-only, for the `ctx.connection` augmentation (the `/api` trust fence).
18
+ import type {} from '@deepseek-ai/dsh-client-connection'
17
19
  import type {} from '@deepseek-ai/dsh-host-webserver'
18
20
  import type {} from '@deepseek-ai/dsh-skill'
19
21
  import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -21,10 +23,10 @@ import type {} from '@deepseek-ai/dsh-session-query'
21
23
  import type {} from '@deepseek-ai/dsh-settings'
22
24
  import { HUB_CONFIG_DEFAULTS, HUB_ENTRY_ID, HEX_COLOR_RE, resolveHubConfig, type HubConfig } from './protocol.ts'
23
25
  import { SkillHubProvider } from './provider.ts'
24
- import { makeRoutes } from './routes.ts'
26
+ import { hostTrustFence, makeRoutes } from './routes.ts'
25
27
  import { createSkillStatsReader, asPersistenceSeam, type SessionPersistenceLike, type SessionQueryLike, type SkillStatsReader } from './stats.ts'
26
28
  import { SkillHubStore } from './store.ts'
27
- import { reconcileDisabledSkills } from './reconcile.ts'
29
+ import { reconcileDisabledSkills, pruneStaleDisabledSkills } from './reconcile.ts'
28
30
  import { cleanupLeftoverImportDirs, setGithubToken } from './repo.ts'
29
31
  import { dshHome } from './store.ts'
30
32
  import { join } from 'node:path'
@@ -132,8 +134,8 @@ const SECTION_ORDER = 152
132
134
 
133
135
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
134
136
  export const SKILL_HUB_GUIDANCE = [
135
- '本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由仅回环可访问。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。',
136
- 'The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → "Skills" is the management page; the plugin\'s configuration page (enable / announcement toggles, dot colors, GitHub token, stats window) is registered by the plugin itself on its own page in the Plugins manager (sidebar → 插件 → the plugin; collapsed until you expand the title). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with "check all" and "update all" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics; new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; routes are loopback-only. When the user mentions "skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill", this plugin is what they mean — collaborate accordingly.'
137
+ '本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由受 dsh 自身的 /api 信任栅栏保护(回环、监听器绑定地址、--trusted-host 声明的 authority,外加浏览器会话认证),因此反代部署下与 GUI 同一套放行规则。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。',
138
+ 'The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → "Skills" is the management page; the plugin\'s configuration page (enable / announcement toggles, dot colors, GitHub token, stats window) is registered by the plugin itself on its own page in the Plugins manager (sidebar → 插件 → the plugin; collapsed until you expand the title). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with "check all" and "update all" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics; new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; the routes sit behind dsh\'s own /api trust fence (loopback, the listener\'s bind address, any --trusted-host authority, plus browser-session authentication), so a reverse-proxied deployment is judged exactly like the GUI around it. When the user mentions "skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill", this plugin is what they mean — collaborate accordingly.'
137
139
  ].join('\n\n')
138
140
 
139
141
  /**
@@ -262,6 +264,16 @@ export function apply(ctx: Context, config?: Config): void {
262
264
  const disposers = makeRoutes({
263
265
  skills: ctx.skills,
264
266
  store,
267
+ // The host's own `/api` trust fence (loopback ∪ bind address ∪
268
+ // --trusted-host ∪ browser session). Read per request: Connection
269
+ // may be provided after this plugin starts, and a one-shot read here
270
+ // would latch the literal-loopback fallback for the whole process.
271
+ // A *persistent* absence means the upstream composition changed —
272
+ // say so once instead of silently re-creating issue #17.
273
+ fence: hostTrustFence(
274
+ () => ctx.get('connection'),
275
+ () => ctx.logger.warn('[dsh-skill-hub] Connection service absent: routes fall back to literal-loopback; reverse-proxy deployments will 403 until it mounts'),
276
+ ),
265
277
  invalidate: () => { providerControl?.invalidate() },
266
278
  stats,
267
279
  config: current,
@@ -307,6 +319,16 @@ export function apply(ctx: Context, config?: Config): void {
307
319
  } catch (error) {
308
320
  ctx.logger.warn('[dsh-skill-hub] startup disabled-skill reconcile failed', error)
309
321
  }
322
+ // 反向对账:记录还在、文件已不在 → 修路径或删记录。不做这一步,那条记录会
323
+ // 把名字永久锁死(启用/新建/删除三条路都失败)。
324
+ try {
325
+ const pruned = await pruneStaleDisabledSkills(store, home)
326
+ if (pruned.repaired.length > 0 || pruned.removed.length > 0) {
327
+ ctx.logger.info(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(', ') || '-'}), removed ${pruned.removed.length} (${pruned.removed.join(', ') || '-'})`)
328
+ }
329
+ } catch (error) {
330
+ ctx.logger.warn('[dsh-skill-hub] startup disabled-record prune failed', error)
331
+ }
310
332
  })()
311
333
 
312
334
  void (async () => {
@@ -60,7 +60,7 @@ export type HubSettingsValue = {
60
60
  statsWindowDays?: number
61
61
  /** 自动统计扫描间隔(分钟)。 */
62
62
  statsScanMinutes?: number
63
- /** GitHub token(明文存本地设置文档,仅回环可读;缺省为匿名)。 */
63
+ /** GitHub token(明文存本地设置文档,仅经 dsh 的 /api 信任栅栏可读;缺省为匿名)。 */
64
64
  githubToken?: string
65
65
  }
66
66
 
@@ -138,6 +138,11 @@ export interface SourceTrashClearResponse {
138
138
  deleted: string[]
139
139
  /** 未能删除的技能(保留在回收站中)。 */
140
140
  failed: Array<{ name: string; error: string }>
141
+ /**
142
+ * `.trash` 里没有对应记录的残留目录(同名技能先后两次入站时,旧记录被覆盖,
143
+ * 目录却留了下来)。清空回收站时一并回收,这里上报它们的路径;没有则省略。
144
+ */
145
+ orphans?: string[]
141
146
  }
142
147
 
143
148
  /** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
- import { reconcileDisabledSkills } from './reconcile.ts'
5
+ import { pruneStaleDisabledSkills, reconcileDisabledSkills } from './reconcile.ts'
6
6
  import { SkillHubStore, statePath } from './store.ts'
7
7
 
8
8
  describe('reconcileDisabledSkills', () => {
@@ -73,4 +73,77 @@ describe('reconcileDisabledSkills', () => {
73
73
  expect(await reconcileDisabledSkills(store, home)).toEqual([])
74
74
  expect(await store.listDisabled()).toHaveLength(1)
75
75
  })
76
+
77
+ it('reconciles a root-level SKILL.md.disabled (a flat skill literally named SKILL)', async () => {
78
+ // scanRoot 会把根级 SKILL.md 文件当一个扁平技能,它的禁用对应物就不能被漏掉。
79
+ await writeFile(join(home, 'skills', 'SKILL.md.disabled'), '---\nname: root-flat\ndescription: Root flat\n---', 'utf8')
80
+ const added = await reconcileDisabledSkills(store, home)
81
+ expect(added.map((entry) => entry.name)).toEqual(['root-flat'])
82
+ expect((await store.getDisabled('root-flat'))?.path).toBe(join(home, 'skills', 'SKILL.md.disabled'))
83
+ })
84
+ })
85
+
86
+ describe('pruneStaleDisabledSkills', () => {
87
+ let dir: string
88
+ let home: string
89
+ let agentsHome: string
90
+ let previousAgentsHome: string | undefined
91
+ let store: SkillHubStore
92
+
93
+ beforeEach(async () => {
94
+ dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-prune-'))
95
+ home = join(dir, 'home')
96
+ agentsHome = join(dir, 'agents')
97
+ await mkdir(join(home, 'skills'), { recursive: true })
98
+ await mkdir(join(agentsHome, 'skills'), { recursive: true })
99
+ previousAgentsHome = process.env.DSH_AGENTS_HOME
100
+ process.env.DSH_AGENTS_HOME = agentsHome
101
+ store = new SkillHubStore(statePath(home))
102
+ })
103
+
104
+ afterEach(async () => {
105
+ if (previousAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
106
+ else process.env.DSH_AGENTS_HOME = previousAgentsHome
107
+ await rm(dir, { recursive: true, force: true })
108
+ })
109
+
110
+ it('drops a record whose file is gone and frees the name', async () => {
111
+ await store.addDisabled({ name: 'ghost', description: 'x', path: join(home, 'skills', 'ghost', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
112
+ const result = await pruneStaleDisabledSkills(store, home)
113
+ expect(result).toEqual({ repaired: [], removed: ['ghost'] })
114
+ expect(await store.listDisabled()).toEqual([])
115
+ })
116
+
117
+ it('repoints a record to the surviving flat placeholder instead of dropping it', async () => {
118
+ // 记录指目录形态,磁盘上只剩扁平形态(导入让位过一次的典型残留)。
119
+ await writeFile(join(home, 'skills', 'code-review.md.disabled'), '---\nname: code-review\ndescription: stub\n---', 'utf8')
120
+ await store.addDisabled({ name: 'code-review', description: 'stub', path: join(home, 'skills', 'code-review', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
121
+ const result = await pruneStaleDisabledSkills(store, home)
122
+ expect(result.removed).toEqual([])
123
+ expect(result.repaired.map((entry) => entry.path)).toEqual([join(home, 'skills', 'code-review.md.disabled')])
124
+ expect((await store.getDisabled('code-review'))?.path).toBe(join(home, 'skills', 'code-review.md.disabled'))
125
+ })
126
+
127
+ it('repoints to the bundle placeholder when that is the surviving form', async () => {
128
+ await mkdir(join(home, 'skills', 'paused'))
129
+ await writeFile(join(home, 'skills', 'paused', 'SKILL.md.disabled'), '---\nname: paused\ndescription: b\n---', 'utf8')
130
+ await store.addDisabled({ name: 'paused', description: 'b', path: join(home, 'skills', 'paused.md.disabled'), root: 'user-dsh', disabledAt: 1 })
131
+ const result = await pruneStaleDisabledSkills(store, home)
132
+ expect(result.repaired.map((entry) => entry.path)).toEqual([join(home, 'skills', 'paused', 'SKILL.md.disabled')])
133
+ })
134
+
135
+ it('leaves records whose file still exists alone, and is idempotent', async () => {
136
+ await writeFile(join(home, 'skills', 'alive.md.disabled'), '---\nname: alive\ndescription: x\n---', 'utf8')
137
+ await store.addDisabled({ name: 'alive', description: 'x', path: join(home, 'skills', 'alive.md.disabled'), root: 'user-dsh', disabledAt: 1 })
138
+ expect(await pruneStaleDisabledSkills(store, home)).toEqual({ repaired: [], removed: [] })
139
+ expect(await pruneStaleDisabledSkills(store, home)).toEqual({ repaired: [], removed: [] })
140
+ expect(await store.listDisabled()).toHaveLength(1)
141
+ })
142
+
143
+ it('prunes records from the other writable root too', async () => {
144
+ await store.addDisabled({ name: 'agent-ghost', description: 'x', path: join(agentsHome, 'skills', 'agent-ghost', 'SKILL.md.disabled'), root: 'user-agents', disabledAt: 1 })
145
+ await writeFile(join(agentsHome, 'skills', 'agent-ghost.md.disabled'), '---\nname: agent-ghost\ndescription: x\n---', 'utf8')
146
+ const result = await pruneStaleDisabledSkills(store, home)
147
+ expect(result.repaired.map((entry) => entry.path)).toEqual([join(agentsHome, 'skills', 'agent-ghost.md.disabled')])
148
+ })
76
149
  })