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.
- package/CONTRIBUTING.md +2 -2
- package/README.md +5 -4
- package/README.zh.md +5 -4
- package/lib/client.js +2 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +362 -74
- package/lib/types/client/locales/settings.d.ts +1 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/protocol/config.d.ts +1 -1
- package/lib/types/protocol/sources.d.ts +5 -0
- package/lib/types/reconcile.d.ts +24 -0
- package/lib/types/routes/deps.d.ts +8 -1
- package/lib/types/routes/helpers.d.ts +11 -6
- package/lib/types/routes/http.d.ts +27 -2
- package/lib/types/routes/route-state.d.ts +22 -2
- package/lib/types/routes.d.ts +29 -4
- package/lib/types/skillfs/scan.d.ts +26 -0
- package/lib/types/skillfs.d.ts +9 -1
- package/lib/types/store/migrate.d.ts +1 -1
- package/lib/types/store/store.d.ts +10 -5
- package/package.json +1 -1
- package/src/client/locales/settings.ts +2 -2
- package/src/index.ts +26 -4
- package/src/protocol/config.ts +1 -1
- package/src/protocol/sources.ts +5 -0
- package/src/reconcile.test.ts +74 -1
- package/src/reconcile.ts +50 -1
- package/src/routes/catalog.ts +50 -22
- package/src/routes/deps.ts +8 -2
- package/src/routes/helpers.ts +18 -7
- package/src/routes/http.ts +31 -2
- package/src/routes/repo-import.ts +16 -26
- package/src/routes/route-state.test.ts +4 -4
- package/src/routes/route-state.ts +45 -10
- package/src/routes/sources.ts +79 -23
- package/src/routes.test.ts +302 -4
- package/src/routes.ts +49 -5
- package/src/skillfs/scan.ts +91 -2
- package/src/skillfs.ts +14 -2
- package/src/store/migrate.ts +6 -4
- package/src/store/store.ts +21 -11
- 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
|
|
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
|
|
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;
|
package/lib/types/reconcile.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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:
|
|
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
|
|
3
|
-
*
|
|
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
|
-
/**
|
|
58
|
-
|
|
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>;
|
package/lib/types/routes.d.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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>;
|
package/lib/types/skillfs.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
122
|
+
/** One trash entry by skill name — the newest when a name has several. */
|
|
123
123
|
getTrash(name: string): Promise<TrashEntry | undefined>;
|
|
124
|
-
/**
|
|
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(
|
|
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.
|
|
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
|
|
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-
|
|
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
|
|
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 () => {
|
package/src/protocol/config.ts
CHANGED
package/src/protocol/sources.ts
CHANGED
|
@@ -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 归属,不属于「个人」组)。 */
|
package/src/reconcile.test.ts
CHANGED
|
@@ -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
|
})
|