skillcaddy 0.27.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/CONTEXT.md +149 -0
  3. package/LICENSE +21 -0
  4. package/README.md +499 -0
  5. package/README_CN.md +482 -0
  6. package/collection-metadata/github/mattpocock.json +16 -0
  7. package/lib/canonicalJson.js +11 -0
  8. package/lib/claudeStore.js +135 -0
  9. package/lib/collectionEnablement.js +115 -0
  10. package/lib/collectionSetup.js +199 -0
  11. package/lib/enablePlan.js +116 -0
  12. package/lib/enablementScope.js +8 -0
  13. package/lib/hermesStore.js +21 -0
  14. package/lib/libraryImage.js +1785 -0
  15. package/lib/managerInstall.js +109 -0
  16. package/lib/managerSourceWorkflow.js +234 -0
  17. package/lib/projectActions.js +24 -0
  18. package/lib/projectPath.js +51 -0
  19. package/lib/roots.js +99 -0
  20. package/lib/skillLinkStore.js +68 -0
  21. package/lib/skillMetadata.js +191 -0
  22. package/lib/skillStore.js +541 -0
  23. package/lib/sourceAcquisition.js +475 -0
  24. package/lib/sourceAcquisitionAdapter.js +47 -0
  25. package/lib/sourceAcquisitionError.js +18 -0
  26. package/lib/sourceAcquisitionPolicy.js +139 -0
  27. package/lib/sourceArchive.js +363 -0
  28. package/lib/sourceArchiveWorkspace.js +36 -0
  29. package/lib/sourceGit.js +95 -0
  30. package/lib/sourceGitAcquisitionAdapter.js +145 -0
  31. package/lib/sourceGitBatchUpdate.js +168 -0
  32. package/lib/sourceGitRepair.js +227 -0
  33. package/lib/sourceGitStaging.js +139 -0
  34. package/lib/sourceGitUpgradeAdapter.js +583 -0
  35. package/lib/sourceGitUrl.js +170 -0
  36. package/lib/sourceHttp.js +302 -0
  37. package/lib/sourceLocal.js +200 -0
  38. package/lib/sourceLocalAcquisitionAdapter.js +107 -0
  39. package/lib/sourceManager.js +242 -0
  40. package/lib/sourceMigration.js +172 -0
  41. package/lib/sourceMigrationIssue.js +6 -0
  42. package/lib/sourcePath.js +6 -0
  43. package/lib/sourcePolicy.js +53 -0
  44. package/lib/sourceRecord.js +295 -0
  45. package/lib/sourceRegistry.js +155 -0
  46. package/lib/sourceRemoteArchiveAcquisitionAdapter.js +147 -0
  47. package/lib/sourceRemoteFileAcquisitionAdapter.js +95 -0
  48. package/lib/sourceRemoteFilePolicy.js +62 -0
  49. package/lib/sourceRemoteFileWorkspace.js +55 -0
  50. package/lib/sourceSkillDiscovery.js +117 -0
  51. package/lib/sourceTree.js +185 -0
  52. package/lib/sourceUpgrade.js +596 -0
  53. package/lib/sourceUpgradeAdapter.js +41 -0
  54. package/lib/sourceUpgradePolicy.js +170 -0
  55. package/lib/sourceUpgradeTransaction.js +202 -0
  56. package/lib/sourceValidation.js +120 -0
  57. package/lib/sourceWorkspace.js +37 -0
  58. package/lib/specGate.js +235 -0
  59. package/lib/tuiActions.js +169 -0
  60. package/lib/tuiInstall.js +133 -0
  61. package/lib/tuiLayout.js +94 -0
  62. package/lib/version.js +17 -0
  63. package/lib/webManager.js +370 -0
  64. package/package.json +74 -0
  65. package/public/agentsUi.js +138 -0
  66. package/public/app.js +1431 -0
  67. package/public/claude.css +2 -0
  68. package/public/claudeUi.js +119 -0
  69. package/public/emptyState.js +12 -0
  70. package/public/enabled.css +303 -0
  71. package/public/favicon.svg +5 -0
  72. package/public/index.html +199 -0
  73. package/public/skillcaddy_CN.png +0 -0
  74. package/public/skillcaddy_EN.png +0 -0
  75. package/public/styles.css +1611 -0
  76. package/scripts/cli.js +647 -0
  77. package/scripts/cron-sync-update-git.sh +128 -0
  78. package/scripts/pull-github.sh +22 -0
  79. package/scripts/source.js +638 -0
  80. package/scripts/tui.js +1058 -0
  81. package/server.js +196 -0
  82. package/skills/skillcaddy-manager/SKILL.md +93 -0
  83. package/skills/skillcaddy-manager/agents/openai.yaml +7 -0
  84. package/skills/skillcaddy-manager/references/METADATA.md +75 -0
  85. package/skills/skillcaddy-manager/references/OPERATIONS.md +315 -0
  86. package/skills/skillcaddy-manager/references/RECOMMENDATION_GUIDE.md +51 -0
  87. package/skills/skillcaddy-manager/references/featured-skills.json +564 -0
  88. package/skills/skillcaddy-manager/references/skill-platforms.json +48 -0
  89. package/skills/skillcaddy-manager/scripts/check-conflicts.cjs +117 -0
  90. package/skills/skillcaddy-manager/scripts/check-global-skills.cjs +169 -0
  91. package/skills/skillcaddy-manager/scripts/translate-skill-notes.cjs +297 -0
  92. package/skills/skillcaddy-manager/scripts/version-manager.cjs +146 -0
  93. package/skills/skillcaddy-manager/scripts/view-recommendations.cjs +279 -0
  94. package/skills/skillcaddy-manager/skillcaddy.json +9 -0
  95. package/skills/spec-pipeline/spec-direction-gate/SKILL.md +119 -0
  96. package/skills/spec-pipeline/spec-direction-gate/check-approval.py +110 -0
  97. package/skills/spec-pipeline/spec-draft-intake/SKILL.md +116 -0
  98. package/skills/spec-pipeline/spec-role-contract/SKILL.md +113 -0
  99. package/skills/spec-pipeline/spec-role-contract/check-roles.py +108 -0
@@ -0,0 +1,370 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile, spawn as childSpawn } from 'node:child_process';
3
+ import { readFile as fsReadFile, rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { setTimeout as sleep } from 'node:timers/promises';
7
+ import { promisify } from 'node:util';
8
+ import http from 'node:http';
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ export const DEFAULT_WEB_HOST = '127.0.0.1';
13
+ export const DEFAULT_WEB_PORT = 4173;
14
+ export const DEFAULT_START_TIMEOUT_MS = 5000;
15
+ export const DEFAULT_STOP_TIMEOUT_MS = 2000;
16
+
17
+ function normalizePort(port) {
18
+ const value = Number(port);
19
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
20
+ throw new Error(`invalid Web port: ${port}`);
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function runtimeFileName(rootDir, port) {
26
+ const digest = createHash('sha256')
27
+ .update(`${path.resolve(rootDir)}:${port}`)
28
+ .digest('hex')
29
+ .slice(0, 16);
30
+ return `skillcaddy-web-${digest}.json`;
31
+ }
32
+
33
+ export function getRuntimePath(rootDir, port = DEFAULT_WEB_PORT, runtimeDir = tmpdir()) {
34
+ return path.join(runtimeDir, runtimeFileName(rootDir, normalizePort(port)));
35
+ }
36
+
37
+ export function buildWebUrl({ host = DEFAULT_WEB_HOST, port = DEFAULT_WEB_PORT, projectPath } = {}) {
38
+ const normalizedPort = normalizePort(port);
39
+ const url = new URL(`http://${host}:${normalizedPort}/`);
40
+ if (projectPath) {
41
+ url.searchParams.set('projectPath', path.resolve(projectPath));
42
+ }
43
+ return url.toString();
44
+ }
45
+
46
+ function defaultProbe({ host, port, timeoutMs = 500 } = {}) {
47
+ return new Promise((resolve) => {
48
+ const request = http.get(
49
+ {
50
+ host,
51
+ port,
52
+ path: '/api/version',
53
+ timeout: timeoutMs,
54
+ },
55
+ (response) => {
56
+ response.resume();
57
+ resolve(true);
58
+ },
59
+ );
60
+
61
+ request.on('timeout', () => {
62
+ request.destroy();
63
+ resolve(false);
64
+ });
65
+ request.on('error', () => resolve(false));
66
+ });
67
+ }
68
+
69
+ function defaultIsAlive(pid) {
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ } catch (error) {
74
+ if (error?.code === 'ESRCH') return false;
75
+ if (error?.code === 'EPERM') return true;
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ async function defaultIsOwned(record) {
81
+ if (process.platform === 'win32') {
82
+ return false;
83
+ }
84
+
85
+ try {
86
+ const { stdout } = await execFileAsync('ps', ['-p', String(record.pid), '-o', 'command=']);
87
+ const command = stdout.trim();
88
+ return command.includes(record.serverEntry) && command.includes(record.rootDir);
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ function defaultTerminate(pid) {
95
+ process.kill(pid, 'SIGTERM');
96
+ }
97
+
98
+ function defaultOpenBrowser(url) {
99
+ const command = process.platform === 'darwin'
100
+ ? ['open', [url]]
101
+ : process.platform === 'win32'
102
+ ? ['cmd', ['/c', 'start', '', url]]
103
+ : ['xdg-open', [url]];
104
+
105
+ return new Promise((resolve) => {
106
+ const child = childSpawn(command[0], command[1], { detached: true, stdio: 'ignore' });
107
+ child.once('error', () => resolve(false));
108
+ child.unref();
109
+ resolve(true);
110
+ });
111
+ }
112
+
113
+ function makeDependencies(overrides = {}) {
114
+ return {
115
+ readFile: fsReadFile,
116
+ writeFile: fsWriteFile,
117
+ rm: fsRm,
118
+ spawn: childSpawn,
119
+ probe: defaultProbe,
120
+ isAlive: defaultIsAlive,
121
+ isOwned: defaultIsOwned,
122
+ terminate: defaultTerminate,
123
+ wait: sleep,
124
+ openBrowser: defaultOpenBrowser,
125
+ now: () => new Date().toISOString(),
126
+ ...overrides,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * 子进程环境。原件库根通过 SKILLCADDY_ROOT 传给 server.js,
132
+ * 让 Web 侧的原件库与 CLI 侧解析到同一个目录。
133
+ */
134
+ function buildChildEnv(libraryRoot, port) {
135
+ const env = { ...process.env, PORT: String(port) };
136
+ if (libraryRoot) env.SKILLCADDY_ROOT = path.resolve(libraryRoot);
137
+ return env;
138
+ }
139
+
140
+ async function readRecord(runtimePath, dependencies) {
141
+ try {
142
+ const content = await dependencies.readFile(runtimePath, 'utf8');
143
+ const record = JSON.parse(content);
144
+ if (!Number.isInteger(record.pid) || record.pid < 1) {
145
+ throw new Error('invalid pid record');
146
+ }
147
+ return record;
148
+ } catch (error) {
149
+ if (error?.code === 'ENOENT') return null;
150
+ await dependencies.rm(runtimePath, { force: true }).catch(() => {});
151
+ return null;
152
+ }
153
+ }
154
+
155
+ async function removeRecord(runtimePath, dependencies, expectedPid) {
156
+ if (expectedPid !== undefined) {
157
+ const current = await readRecord(runtimePath, dependencies);
158
+ if (current && current.pid !== expectedPid) return false;
159
+ }
160
+ await dependencies.rm(runtimePath, { force: true });
161
+ return true;
162
+ }
163
+
164
+ async function waitForProbe(dependencies, { host, port, timeoutMs = DEFAULT_START_TIMEOUT_MS }) {
165
+ const deadline = Date.now() + timeoutMs;
166
+ do {
167
+ if (await dependencies.probe({ host, port, timeoutMs: 500 })) return true;
168
+ if (Date.now() >= deadline) break;
169
+ await dependencies.wait(100);
170
+ } while (Date.now() < deadline);
171
+ return false;
172
+ }
173
+
174
+ async function waitForExit(dependencies, pid, timeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
175
+ const deadline = Date.now() + timeoutMs;
176
+ do {
177
+ if (!(await dependencies.isAlive(pid))) return true;
178
+ if (Date.now() >= deadline) break;
179
+ await dependencies.wait(100);
180
+ } while (Date.now() < deadline);
181
+ return false;
182
+ }
183
+
184
+ function makeRecord({ rootDir, port, serverEntry, pid, dependencies }) {
185
+ return {
186
+ pid,
187
+ host: DEFAULT_WEB_HOST,
188
+ port,
189
+ rootDir: path.resolve(rootDir),
190
+ serverEntry: path.resolve(serverEntry),
191
+ startedAt: dependencies.now(),
192
+ };
193
+ }
194
+
195
+ async function openIfRequested(dependencies, url, open) {
196
+ if (!open) return false;
197
+ try {
198
+ return await dependencies.openBrowser(url);
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ export async function startWeb({
205
+ rootDir,
206
+ codeRoot,
207
+ libraryRoot,
208
+ projectPath = process.cwd(),
209
+ port = DEFAULT_WEB_PORT,
210
+ open = true,
211
+ runtimeDir = tmpdir(),
212
+ runtimePath,
213
+ serverEntry,
214
+ startTimeoutMs = DEFAULT_START_TIMEOUT_MS,
215
+ dependencies: dependencyOverrides,
216
+ } = {}) {
217
+ if (!rootDir && !codeRoot) throw new Error('rootDir is required');
218
+
219
+ // Web 进程跑的是代码根里的 server.js;原件库根通过环境变量传给子进程,
220
+ // 两者解耦之后 Web 才能在任意安装形态下指向同一个原件库。
221
+ const resolvedCodeRoot = path.resolve(codeRoot || rootDir);
222
+ const resolvedServerEntry = serverEntry ? path.resolve(serverEntry) : path.join(resolvedCodeRoot, 'server.js');
223
+
224
+ const dependencies = makeDependencies(dependencyOverrides);
225
+ const normalizedPort = normalizePort(port);
226
+ const host = DEFAULT_WEB_HOST;
227
+ const pidPath = runtimePath ?? getRuntimePath(resolvedCodeRoot, normalizedPort, runtimeDir);
228
+ const url = buildWebUrl({ host, port: normalizedPort, projectPath });
229
+ const existing = await readRecord(pidPath, dependencies);
230
+
231
+ if (existing) {
232
+ if (existing.rootDir !== resolvedCodeRoot || existing.port !== normalizedPort) {
233
+ throw new Error(`Web PID record does not belong to ${resolvedCodeRoot}:${normalizedPort}`);
234
+ }
235
+
236
+ if (await dependencies.isAlive(existing.pid) && await dependencies.isOwned(existing)) {
237
+ const ready = await waitForProbe(dependencies, {
238
+ host,
239
+ port: normalizedPort,
240
+ timeoutMs: startTimeoutMs,
241
+ });
242
+ if (!ready) throw new Error(`managed Web process ${existing.pid} did not become ready`);
243
+ await openIfRequested(dependencies, url, open);
244
+ return { status: 'already-running', managed: true, pid: existing.pid, port: normalizedPort, url };
245
+ }
246
+
247
+ await removeRecord(pidPath, dependencies, existing.pid);
248
+ }
249
+
250
+ if (await dependencies.probe({ host, port: normalizedPort, timeoutMs: 500 })) {
251
+ await openIfRequested(dependencies, url, open);
252
+ return { status: 'already-running', managed: false, port: normalizedPort, url };
253
+ }
254
+
255
+ const child = dependencies.spawn(process.execPath, [resolvedServerEntry], {
256
+ cwd: resolvedCodeRoot,
257
+ env: buildChildEnv(libraryRoot, normalizedPort),
258
+ detached: true,
259
+ stdio: 'ignore',
260
+ });
261
+ const record = makeRecord({
262
+ rootDir: resolvedCodeRoot,
263
+ port: normalizedPort,
264
+ serverEntry: resolvedServerEntry,
265
+ pid: child.pid,
266
+ dependencies,
267
+ });
268
+
269
+ try {
270
+ await dependencies.writeFile(pidPath, `${JSON.stringify(record)}\n`, {
271
+ encoding: 'utf8',
272
+ flag: 'wx',
273
+ });
274
+ } catch (error) {
275
+ if (child.pid && typeof dependencies.terminate === 'function') {
276
+ await terminateBestEffort(dependencies, child.pid);
277
+ }
278
+ if (error?.code === 'EEXIST') {
279
+ const concurrent = await readRecord(pidPath, dependencies);
280
+ if (concurrent && await dependencies.isAlive(concurrent.pid)) {
281
+ return {
282
+ status: 'already-running',
283
+ managed: true,
284
+ pid: concurrent.pid,
285
+ port: normalizedPort,
286
+ url,
287
+ };
288
+ }
289
+ }
290
+ throw error;
291
+ }
292
+
293
+ if (typeof child.unref === 'function') child.unref();
294
+ const ready = await waitForProbe(dependencies, {
295
+ host,
296
+ port: normalizedPort,
297
+ timeoutMs: startTimeoutMs,
298
+ });
299
+ if (!ready) {
300
+ await terminateBestEffort(dependencies, child.pid);
301
+ await removeRecord(pidPath, dependencies, child.pid);
302
+ throw new Error(`Web process ${child.pid} did not become ready on port ${normalizedPort}`);
303
+ }
304
+
305
+ await openIfRequested(dependencies, url, open);
306
+ return { status: 'started', managed: true, pid: child.pid, port: normalizedPort, url };
307
+ }
308
+
309
+ async function terminateBestEffort(dependencies, pid) {
310
+ try {
311
+ await dependencies.terminate(pid);
312
+ } catch (error) {
313
+ if (error?.code !== 'ESRCH') throw error;
314
+ }
315
+ }
316
+
317
+ export async function stopWeb({
318
+ rootDir,
319
+ codeRoot,
320
+ port = DEFAULT_WEB_PORT,
321
+ runtimeDir = tmpdir(),
322
+ runtimePath,
323
+ stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS,
324
+ dependencies: dependencyOverrides,
325
+ } = {}) {
326
+ if (!rootDir && !codeRoot) throw new Error('rootDir is required');
327
+
328
+ const resolvedCodeRoot = path.resolve(codeRoot || rootDir);
329
+ const dependencies = makeDependencies(dependencyOverrides);
330
+ const normalizedPort = normalizePort(port);
331
+ const host = DEFAULT_WEB_HOST;
332
+ const pidPath = runtimePath ?? getRuntimePath(resolvedCodeRoot, normalizedPort, runtimeDir);
333
+ const url = buildWebUrl({ host, port: normalizedPort });
334
+ const record = await readRecord(pidPath, dependencies);
335
+
336
+ if (!record) {
337
+ const external = await dependencies.probe({ host, port: normalizedPort, timeoutMs: 500 });
338
+ return { status: external ? 'not-managed' : 'not-running', managed: false, port: normalizedPort, url };
339
+ }
340
+
341
+ if (record.rootDir !== resolvedCodeRoot || record.port !== normalizedPort) {
342
+ throw new Error(`Web PID record does not belong to ${resolvedCodeRoot}:${normalizedPort}`);
343
+ }
344
+
345
+ if (!(await dependencies.isAlive(record.pid))) {
346
+ await removeRecord(pidPath, dependencies, record.pid);
347
+ const external = await dependencies.probe({ host, port: normalizedPort, timeoutMs: 500 });
348
+ return { status: external ? 'not-managed' : 'not-running', managed: !external, port: normalizedPort, url };
349
+ }
350
+
351
+ if (!(await dependencies.isOwned(record))) {
352
+ throw new Error(`refusing to stop PID ${record.pid}: process ownership could not be verified`);
353
+ }
354
+
355
+ await dependencies.terminate(record.pid);
356
+ if (!(await waitForExit(dependencies, record.pid, stopTimeoutMs))) {
357
+ throw new Error(`Web process ${record.pid} did not stop within ${stopTimeoutMs}ms`);
358
+ }
359
+ await removeRecord(pidPath, dependencies, record.pid);
360
+ return { status: 'stopped', managed: true, pid: record.pid, port: normalizedPort, url };
361
+ }
362
+
363
+ export async function restartWeb(options = {}) {
364
+ const stopped = await stopWeb(options);
365
+ if (stopped.status === 'not-managed') {
366
+ throw new Error(`refusing to restart an unmanaged Web process on port ${stopped.port}`);
367
+ }
368
+ const started = await startWeb(options);
369
+ return { status: 'restarted', stopped, started };
370
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "skillcaddy",
3
+ "version": "0.27.0",
4
+ "description": "Local skill source library with project, global, and Hermes symlink scopes.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "skillcaddy": "scripts/tui.js"
9
+ },
10
+ "files": [
11
+ "lib/",
12
+ "scripts/cli.js",
13
+ "scripts/source.js",
14
+ "scripts/tui.js",
15
+ "scripts/pull-github.sh",
16
+ "scripts/cron-sync-update-git.sh",
17
+ "public/",
18
+ "skills/",
19
+ "!skills/.claude",
20
+ "collection-metadata/",
21
+ "server.js",
22
+ "README.md",
23
+ "README_CN.md",
24
+ "CHANGELOG.md",
25
+ "CONTEXT.md",
26
+ "LICENSE"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/chenweil/skillcaddy.git"
31
+ },
32
+ "homepage": "https://github.com/chenweil/skillcaddy#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/chenweil/skillcaddy/issues"
35
+ },
36
+ "keywords": [
37
+ "skill",
38
+ "skills",
39
+ "agent",
40
+ "claude-code",
41
+ "codex",
42
+ "opencode",
43
+ "symlink",
44
+ "skill-library"
45
+ ],
46
+ "scripts": {
47
+ "start": "node server.js",
48
+ "source": "node scripts/source.js",
49
+ "test": "node --test test/*.test.js && npm run lint:imports && npm run check:specs",
50
+ "test:unit": "node --test test/*.test.js",
51
+ "check:specs": "node scripts/check-specs.js",
52
+ "enable:spec-pipeline": "node scripts/enable-spec-pipeline.js",
53
+ "lint:imports": "node scripts/check-imports.js",
54
+ "lint": "node scripts/check-imports.js",
55
+ "install:manager": "node scripts/manager-skill.js install",
56
+ "check:manager": "node scripts/manager-skill.js check",
57
+ "install:cli": "node scripts/tui-command.js install cli",
58
+ "check:cli": "node scripts/tui-command.js check cli",
59
+ "install:tui": "node scripts/tui-command.js install tui",
60
+ "check:tui": "node scripts/tui-command.js check tui",
61
+ "migrate:metadata": "node scripts/migrate-metadata.js",
62
+ "pull:github": "bash scripts/pull-github.sh",
63
+ "tui": "node scripts/tui.js"
64
+ },
65
+ "engines": {
66
+ "node": ">=20"
67
+ },
68
+ "dependencies": {
69
+ "tldts": "^7.4.9"
70
+ },
71
+ "publishConfig": {
72
+ "access": "public"
73
+ }
74
+ }
@@ -0,0 +1,138 @@
1
+ import { emptyState } from './emptyState.js';
2
+
3
+ export function renderAgentsSkills({ enabled, skills, elements, onDisable, scope = 'project', isPreview = false, query = '' }) {
4
+ const config = scopeConfig(scope, elements);
5
+ const list = config.list;
6
+ const clearButton = config.clearButton;
7
+ const normalizedQuery = query.trim().toLowerCase();
8
+ const filtered = enabled.filter((skill) => matchesEnabledQuery(skill, skills, normalizedQuery));
9
+ clearButton.disabled = isPreview && config.previewOnly ? true : !enabled.some((skill) => skill.canDisable ?? skill.isSymlink);
10
+ if (isPreview && config.previewOnly) {
11
+ clearButton.title = '预览模式只读:先在下方读取你自己的项目再操作';
12
+ }
13
+ config.resultCount.hidden = !normalizedQuery;
14
+ config.resultCount.textContent = normalizedQuery ? `显示 ${filtered.length}/${enabled.length}` : '';
15
+ list.replaceChildren();
16
+ if (enabled.length === 0) {
17
+ list.append(emptyState(
18
+ config.emptyTitle,
19
+ config.emptyDetail
20
+ ));
21
+ return 0;
22
+ }
23
+ if (filtered.length === 0) {
24
+ list.append(emptyState('没有匹配的 skill', '换一个名称或来源,或清空上方搜索。'));
25
+ return 0;
26
+ }
27
+
28
+ filtered.forEach((skill) => {
29
+ const item = document.createElement('article');
30
+ item.className = 'enabled';
31
+ item.dataset.focusScope = '';
32
+ item.innerHTML = `
33
+ <div>
34
+ <div class="enabled-head">
35
+ <strong class="name"></strong>
36
+ <span class="tag-pill enabled-source"></span>
37
+ </div>
38
+ <p class="path"></p>
39
+ </div>
40
+ <div class="actions"></div>
41
+ `;
42
+ item.querySelector('.name').textContent = skill.alias;
43
+ item.querySelector('.path').textContent = skill.targetPath || skill.linkPath;
44
+
45
+ const sourceSkill = skills.find((s) => s.path === skill.targetPath);
46
+ const sourceChip = item.querySelector('.enabled-source');
47
+ if (sourceSkill) {
48
+ // 同名 skill 可能来自多个来源:alias 旁标注来源,否则两行同名条目无法区分。
49
+ sourceChip.textContent = sourceSkill.source;
50
+ sourceChip.title = `${sourceSkill.collection} 来源`;
51
+ } else {
52
+ sourceChip.hidden = true;
53
+ }
54
+ if (sourceSkill) {
55
+ const description = sourceSkill.note || sourceSkill.description;
56
+ if (description) item.title = description;
57
+ }
58
+
59
+ const button = document.createElement('button');
60
+ button.className = 'secondary danger';
61
+ button.type = 'button';
62
+ // 单条用「移除」,只有整栏批量才叫「清空」:此前两者共用一个词,
63
+ // 光看标签分不出作用域。
64
+ button.textContent = '移除';
65
+ button.dataset.focusKey = `${config.key}-remove:${skill.alias}`;
66
+ button.dataset.focusFallbackSelector = `${config.selector} [data-focus-key^="${config.key}-remove:"]:not(:disabled)`;
67
+ button.dataset.focusFallbackKey = 'enabled-search';
68
+ button.setAttribute('aria-label', `从${config.label} ${config.directory} 移除 ${skill.alias}${sourceSkill ? `(${sourceSkill.source})` : ''}`);
69
+ button.disabled = isPreview && config.previewOnly ? true : !(skill.canDisable ?? skill.isSymlink);
70
+ if (isPreview && config.previewOnly) {
71
+ button.title = '预览模式只读:先在下方读取你自己的项目再操作';
72
+ }
73
+ button.addEventListener('click', () => onDisable(skill.alias));
74
+ item.querySelector('.actions').append(button);
75
+ list.append(item);
76
+ });
77
+ return filtered.length;
78
+ }
79
+
80
+ function matchesEnabledQuery(skill, skills, query) {
81
+ if (!query) return true;
82
+ const sourceSkill = skills.find((candidate) => candidate.path === skill.targetPath);
83
+ const fields = [
84
+ skill.alias,
85
+ skill.targetPath,
86
+ skill.linkPath,
87
+ sourceSkill?.name,
88
+ sourceSkill?.source,
89
+ sourceSkill?.collection,
90
+ sourceSkill?.description,
91
+ sourceSkill?.note,
92
+ ...(sourceSkill?.tags || [])
93
+ ];
94
+ return fields.some((value) => String(value || '').toLowerCase().includes(query));
95
+ }
96
+
97
+ function scopeConfig(scope, elements) {
98
+ if (scope === 'global') {
99
+ return {
100
+ key: 'global',
101
+ label: '全局',
102
+ directory: '.agents/skills',
103
+ list: elements.globalList,
104
+ clearButton: elements.disableGlobal,
105
+ resultCount: elements.globalResultCount,
106
+ selector: '#globalList',
107
+ previewOnly: false,
108
+ emptyTitle: '还没有全局 skill',
109
+ emptyDetail: '在原件库中选择「启用到全局」,即可让所有项目使用。'
110
+ };
111
+ }
112
+ if (scope === 'hermes') {
113
+ return {
114
+ key: 'hermes',
115
+ label: 'Hermes',
116
+ directory: '~/.hermes/skills',
117
+ list: elements.hermesList,
118
+ clearButton: elements.disableHermes,
119
+ resultCount: elements.hermesResultCount,
120
+ selector: '#hermesList',
121
+ previewOnly: false,
122
+ emptyTitle: '还没有 Hermes skill',
123
+ emptyDetail: '在原件库中选择「启用到 Hermes」,只会管理 eligible 的 official/github/personal skill。'
124
+ };
125
+ }
126
+ return {
127
+ key: 'agents',
128
+ label: '当前项目',
129
+ directory: '.agents/skills',
130
+ list: elements.enabledList,
131
+ clearButton: elements.disableAgents,
132
+ resultCount: elements.agentsResultCount,
133
+ selector: '#enabledList',
134
+ previewOnly: true,
135
+ emptyTitle: '当前项目还没有启用 skill',
136
+ emptyDetail: '在「Skill 原件库」里展开任意分组,点「启用 agents skill」即可加入这里。'
137
+ };
138
+ }