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
package/public/app.js ADDED
@@ -0,0 +1,1431 @@
1
+ import { renderAgentsSkills } from './agentsUi.js';
2
+ import { renderClaudeStatus } from './claudeUi.js';
3
+ import { emptyState } from './emptyState.js';
4
+
5
+ const PROJECT_HISTORY_KEY = 'skillcaddy.projectHistory';
6
+ const MAX_PROJECT_HISTORY = 8;
7
+ const HERMES_TOGGLE_KEY = 'skillcaddy.showHermes';
8
+ // 默认不展示 Hermes:项目与 Claude Code 是主要工作面,Hermes 段按需打开。
9
+ const HERMES_TOGGLE_DEFAULT = false;
10
+
11
+ const state = {
12
+ rootDir: '',
13
+ projectPath: '',
14
+ projectHistory: readProjectHistory(),
15
+ skills: [],
16
+ enabled: [],
17
+ global: [],
18
+ hermes: [],
19
+ setups: [],
20
+ sources: [],
21
+ advice: [],
22
+ claude: null,
23
+ collapsedGroups: new Set(),
24
+ knownGroups: new Set(),
25
+ activeTag: '',
26
+ searchQuery: '',
27
+ enabledQuery: '',
28
+ editingSkillId: '',
29
+ stats: { total: 0, enabled: 0, available: 0 }
30
+ };
31
+
32
+ const elements = {
33
+ hero: document.querySelector('.hero'),
34
+ controlsForm: document.querySelector('#controlsForm'),
35
+ projectPath: document.querySelector('#projectPath'),
36
+ addProject: document.querySelector('#addProject'),
37
+ projectHistory: document.querySelector('#projectHistory'),
38
+ loadProject: document.querySelector('#loadProject'),
39
+ refreshButton: document.querySelector('#refreshButton'),
40
+ disableAgents: document.querySelector('#disableAgents'),
41
+ disableGlobal: document.querySelector('#disableGlobal'),
42
+ disableHermes: document.querySelector('#disableHermes'),
43
+ unlinkClaude: document.querySelector('#unlinkClaude'),
44
+ syncClaude: document.querySelector('#syncClaude'),
45
+ claudeSkillList: document.querySelector('#claudeSkillList'),
46
+ hermesToggle: document.querySelector('#hermesToggle'),
47
+ hermesSection: document.querySelector('[data-hermes-section]'),
48
+ totalSkills: document.querySelector('#totalSkills'),
49
+ sourceFilter: document.querySelector('#sourceFilter'),
50
+ skillSearch: document.querySelector('#skillSearch'),
51
+ enabledSearch: document.querySelector('#enabledSearch'),
52
+ enabledSearchSummary: document.querySelector('#enabledSearchSummary'),
53
+ enabledList: document.querySelector('#enabledList'),
54
+ globalList: document.querySelector('#globalList'),
55
+ hermesList: document.querySelector('#hermesList'),
56
+ agentsCount: document.querySelector('#agentsCount'),
57
+ agentsResultCount: document.querySelector('#agentsResultCount'),
58
+ globalCount: document.querySelector('#globalCount'),
59
+ globalResultCount: document.querySelector('#globalResultCount'),
60
+ hermesCount: document.querySelector('#hermesCount'),
61
+ hermesResultCount: document.querySelector('#hermesResultCount'),
62
+ claudeCount: document.querySelector('#claudeCount'),
63
+ claudeResultCount: document.querySelector('#claudeResultCount'),
64
+ skillList: document.querySelector('#skillList'),
65
+ adviceList: document.querySelector('#adviceList'),
66
+ adviceToggle: document.querySelector('#adviceToggle'),
67
+ adviceCount: document.querySelector('#adviceCount'),
68
+ tagTabs: document.querySelector('#tagTabs'),
69
+ message: document.querySelector('#message'),
70
+ errorMessage: document.querySelector('#errorMessage'),
71
+ versionTag: document.querySelector('#versionTag'),
72
+ heroTotalSkills: document.querySelector('#heroTotalSkills'),
73
+ heroAgentsCount: document.querySelector('#heroAgentsCount'),
74
+ heroClaudeCount: document.querySelector('#heroClaudeCount'),
75
+ activeProject: document.querySelector('#activeProject')
76
+ };
77
+
78
+ // These module-level states must be initialized before the startup top-level
79
+ // await below can resume through loadState() -> render().
80
+ let lastFilterSignature = '';
81
+ let filterExpandedGroups = new Set();
82
+ let isRestoringFocus = false;
83
+ let messageTimer = null;
84
+ let searchDebounceTimer = null;
85
+ let enabledSearchDebounceTimer = null;
86
+ // 首访(无历史、URL 无项目参数)时服务端展示的是仓库默认状态:
87
+ // 标注为预览,避免用户把 skillcaddy 自带的 skill 当成「别人配好的项目」。
88
+ let isPreviewSession = false;
89
+
90
+ // 项目路径框在 form 里,Enter 走隐式提交;点「读取项目」也是同一条 submit 路径。
91
+ // 读取进行中按钮处于忙碌态,此时的重复提交(如连按 Enter)直接忽略。
92
+ elements.controlsForm.addEventListener('submit', (event) => {
93
+ event.preventDefault();
94
+ if (elements.loadProject.disabled) return;
95
+ isPreviewSession = false;
96
+ // 用户开始读取自己的项目:hero 从此坍缩成状态带,不再占首屏。
97
+ elements.hero.classList.add('is-compact');
98
+ loadState({ feedback: true });
99
+ });
100
+ elements.addProject.addEventListener('click', addCurrentProject);
101
+ elements.refreshButton.addEventListener('click', () => loadState({ button: elements.refreshButton, feedback: true, label: '刷新' }));
102
+ elements.sourceFilter.addEventListener('change', renderSkills);
103
+ // 每个按键全量重建列表 DOM 在几百个 skill 时会卡顿,防抖到停顿后再渲染。
104
+ elements.skillSearch.addEventListener('input', (event) => {
105
+ clearTimeout(searchDebounceTimer);
106
+ searchDebounceTimer = setTimeout(() => {
107
+ state.searchQuery = event.target.value.trim().toLowerCase();
108
+ renderSkills();
109
+ }, 120);
110
+ });
111
+ elements.enabledSearch.addEventListener('input', (event) => {
112
+ clearTimeout(enabledSearchDebounceTimer);
113
+ enabledSearchDebounceTimer = setTimeout(() => {
114
+ state.enabledQuery = event.target.value.trim().toLowerCase();
115
+ renderEnabled();
116
+ }, 120);
117
+ });
118
+ elements.unlinkClaude.addEventListener('click', unlinkClaude);
119
+ elements.syncClaude.addEventListener('click', syncClaude);
120
+ elements.disableAgents.addEventListener('click', () => disableAgents('project'));
121
+ elements.disableGlobal.addEventListener('click', () => disableAgents('global'));
122
+ elements.disableHermes.addEventListener('click', () => disableAgents('hermes'));
123
+
124
+ // Hermes 开关:默认关,状态落到 localStorage;body.hermes-hidden 控制三处 UI 同步消失。
125
+ applyHermesToggle(readHermesToggle());
126
+ elements.hermesToggle.addEventListener('change', (event) => {
127
+ const next = event.target.checked;
128
+ localStorage.setItem(HERMES_TOGGLE_KEY, JSON.stringify(next));
129
+ applyHermesToggle(next);
130
+ renderEnabled();
131
+ });
132
+
133
+ // 只做两个高频快捷键:/ 聚焦搜索,Esc 逐级清除(先清搜索词,再清全部筛选)。
134
+ // 输入控件内不劫持按键,避免与正常输入冲突。
135
+ document.addEventListener('keydown', (event) => {
136
+ const isEditable = Boolean(event.target.closest?.('input, textarea, select, [contenteditable="true"]'));
137
+
138
+ if (event.key === '/' && !isEditable && !event.metaKey && !event.ctrlKey && !event.altKey) {
139
+ event.preventDefault();
140
+ elements.skillSearch.focus();
141
+ return;
142
+ }
143
+
144
+ if (event.key !== 'Escape') return;
145
+
146
+ if (event.target === elements.skillSearch && elements.skillSearch.value) {
147
+ clearTimeout(searchDebounceTimer);
148
+ elements.skillSearch.value = '';
149
+ state.searchQuery = '';
150
+ renderSkills();
151
+ return;
152
+ }
153
+
154
+ if (event.target === elements.enabledSearch && elements.enabledSearch.value) {
155
+ clearTimeout(enabledSearchDebounceTimer);
156
+ elements.enabledSearch.value = '';
157
+ state.enabledQuery = '';
158
+ renderEnabled();
159
+ return;
160
+ }
161
+
162
+ // 第一下 Esc 清掉搜索词后焦点仍在搜索框:此时再按 Esc 必须继续清 tag/来源
163
+ // 过滤,否则键盘用户(Sam)会卡在残留过滤里。搜索框因此不算 isEditable。
164
+ const isSearchBox = event.target === elements.skillSearch || event.target === elements.enabledSearch;
165
+ if ((!isEditable || isSearchBox) && hasActiveFilter()) clearFilters();
166
+ });
167
+
168
+ initializeProjectPathFromUrl();
169
+ initializeHeroState();
170
+ await Promise.all([loadState(), loadVersion()]);
171
+
172
+ async function loadState(options = {}) {
173
+ const action = options.label || '读取项目';
174
+ const button = options.button === undefined ? elements.loadProject : options.button;
175
+ const task = async () => {
176
+ const projectPath = elements.projectPath.value.trim();
177
+ const url = `/api/state${projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : ''}`;
178
+ const nextState = await api(url);
179
+ Object.assign(state, nextState);
180
+ elements.projectPath.value = state.projectPath;
181
+ rememberProject(state.projectPath);
182
+ syncProjectPathToUrl(state.projectPath);
183
+ render();
184
+ if (options.feedback) setMessage(`已${action}:${state.projectPath}`);
185
+ };
186
+
187
+ button ? await withButtonState(button, `${action}中`, task) : await task();
188
+ }
189
+
190
+ function render() {
191
+ withPreservedFocus(renderAll);
192
+ }
193
+
194
+ function renderAll() {
195
+ elements.totalSkills.textContent = state.stats.total;
196
+ elements.agentsCount.textContent = state.enabled.length;
197
+ elements.globalCount.textContent = state.global.length;
198
+ elements.hermesCount.textContent = state.hermes.length;
199
+ elements.claudeCount.textContent = state.claude?.skills?.length || 0;
200
+ elements.heroTotalSkills.textContent = state.stats.total;
201
+ elements.heroAgentsCount.textContent = state.enabled.length;
202
+ elements.heroClaudeCount.textContent = state.claude?.skills?.length || 0;
203
+ elements.activeProject.textContent = isPreviewSession
204
+ ? `默认预览:${state.projectPath || 'skillcaddy 仓库'}(在下方输入你的项目路径后点「读取项目」)`
205
+ : state.projectPath || '等待读取项目路径';
206
+ renderEnabledPanel();
207
+ renderAdvice();
208
+ renderProjectHistory();
209
+ renderTagTabs();
210
+ renderSkillList();
211
+ }
212
+
213
+ function renderEnabled() {
214
+ withPreservedFocus(renderEnabledPanel);
215
+ }
216
+
217
+ function renderEnabledPanel() {
218
+ const query = state.enabledQuery;
219
+ const matchedCounts = [
220
+ renderAgentsSkills({ enabled: state.enabled, skills: state.skills, elements, onDisable: disable, isPreview: isPreviewSession, query }),
221
+ renderClaudeStatus({ claude: state.claude, skills: state.skills, elements, onUnlink: unlinkClaudeSkill, isPreview: isPreviewSession, query }),
222
+ renderAgentsSkills({ enabled: state.global, skills: state.skills, elements, onDisable: (alias) => disable(alias, 'global'), scope: 'global', query })
223
+ ];
224
+ // Hermes 关闭时不要拉取/渲染 hermes 列表:保持 count=0,DOM 静止。
225
+ if (isHermesEnabled()) {
226
+ matchedCounts.push(renderAgentsSkills({ enabled: state.hermes, skills: state.skills, elements, onDisable: (alias) => disable(alias, 'hermes'), scope: 'hermes', query }));
227
+ }
228
+ updateEnabledSearchSummary(query, matchedCounts);
229
+ }
230
+
231
+ function updateEnabledSearchSummary(query, matchedCounts) {
232
+ elements.enabledSearchSummary.hidden = !query;
233
+ if (!query) {
234
+ elements.enabledSearchSummary.textContent = '';
235
+ return;
236
+ }
237
+ const total = state.enabled.length
238
+ + (state.claude?.skills?.length || 0)
239
+ + state.global.length
240
+ + (isHermesEnabled() ? state.hermes.length : 0);
241
+ const matched = matchedCounts.reduce((sum, count) => sum + count, 0);
242
+ elements.enabledSearchSummary.textContent = `命中 ${matched} / ${total} 个已启用条目`;
243
+ }
244
+
245
+ function renderProjectHistory() {
246
+ elements.projectHistory.replaceChildren();
247
+ if (state.projectHistory.length === 0) return;
248
+
249
+ state.projectHistory.forEach((projectPath) => {
250
+ const item = document.createElement('div');
251
+ item.className = 'project-chip';
252
+ item.dataset.focusScope = '';
253
+ item.title = projectPath;
254
+
255
+ const button = document.createElement('button');
256
+ button.className = 'project-chip-path';
257
+ button.type = 'button';
258
+ button.textContent = projectPath;
259
+ button.dataset.focusKey = `project-open:${projectPath}`;
260
+ button.disabled = projectPath === state.projectPath;
261
+ button.addEventListener('click', async () => {
262
+ elements.projectPath.value = projectPath;
263
+ await loadState({ button: null, feedback: true });
264
+ });
265
+
266
+ const removeButton = document.createElement('button');
267
+ removeButton.className = 'project-chip-remove';
268
+ removeButton.type = 'button';
269
+ removeButton.textContent = '×';
270
+ removeButton.dataset.focusKey = `project-remove:${projectPath}`;
271
+ removeButton.dataset.focusFallbackSelector = '#projectHistory .project-chip-remove';
272
+ removeButton.dataset.focusFallbackKey = 'project-path';
273
+ removeButton.title = `移除项目:${projectPath}`;
274
+ removeButton.setAttribute('aria-label', `从历史移除项目:${projectPath}`);
275
+ removeButton.addEventListener('click', () => forgetProject(projectPath));
276
+
277
+ item.append(button, removeButton);
278
+ elements.projectHistory.append(item);
279
+ });
280
+ }
281
+
282
+ function addCurrentProject() {
283
+ const projectPath = elements.projectPath.value.trim();
284
+ if (!projectPath) {
285
+ setMessage('请输入项目路径后再添加', true);
286
+ return;
287
+ }
288
+
289
+ rememberProject(projectPath);
290
+ renderProjectHistory();
291
+ setMessage(`已添加项目:${projectPath}`);
292
+ }
293
+
294
+ function rememberProject(projectPath) {
295
+ const value = projectPath.trim();
296
+ if (!value) return;
297
+ state.projectHistory = [value, ...state.projectHistory.filter((item) => item !== value)].slice(0, MAX_PROJECT_HISTORY);
298
+ localStorage.setItem(PROJECT_HISTORY_KEY, JSON.stringify(state.projectHistory));
299
+ }
300
+
301
+ function forgetProject(projectPath) {
302
+ state.projectHistory = state.projectHistory.filter((item) => item !== projectPath);
303
+ localStorage.setItem(PROJECT_HISTORY_KEY, JSON.stringify(state.projectHistory));
304
+ withPreservedFocus(renderProjectHistory);
305
+ setMessage(`已移除项目:${projectPath}`);
306
+ }
307
+
308
+ function readProjectHistory() {
309
+ try {
310
+ const parsed = JSON.parse(localStorage.getItem(PROJECT_HISTORY_KEY) || '[]');
311
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, MAX_PROJECT_HISTORY) : [];
312
+ } catch {
313
+ return [];
314
+ }
315
+ }
316
+
317
+ function readHermesToggle() {
318
+ try {
319
+ const raw = localStorage.getItem(HERMES_TOGGLE_KEY);
320
+ if (raw === null) return HERMES_TOGGLE_DEFAULT;
321
+ return JSON.parse(raw) === true;
322
+ } catch {
323
+ return HERMES_TOGGLE_DEFAULT;
324
+ }
325
+ }
326
+
327
+ function isHermesEnabled() {
328
+ return document.body.classList.contains('hermes-hidden') === false;
329
+ }
330
+
331
+ function applyHermesToggle(enabled) {
332
+ elements.hermesToggle.checked = enabled;
333
+ // 单一 class 触发三处折叠:已启用面板的 Hermes 段、库分组里的 Hermes 操作、单条 skill 卡片里的 Hermes 按钮。
334
+ document.body.classList.toggle('hermes-hidden', !enabled);
335
+ // 关闭时不渲染 Hermes 列表和清空按钮(按钮可空:列表项 disabled 状态更省心)
336
+ elements.hermesList.hidden = !enabled;
337
+ elements.disableHermes.hidden = !enabled;
338
+ if (elements.hermesSection) elements.hermesSection.hidden = !enabled;
339
+ if (enabled) {
340
+ elements.hermesCount.textContent = state.hermes?.length || 0;
341
+ } else {
342
+ elements.hermesCount.textContent = 0;
343
+ }
344
+ }
345
+
346
+ function initializeProjectPathFromUrl() {
347
+ const projectPath = new URLSearchParams(window.location.search).get('projectPath');
348
+ if (projectPath) elements.projectPath.value = projectPath;
349
+ }
350
+
351
+ // loadState 会把服务端回退的 rootDir 写进历史,因此不能用会话中的状态判断
352
+ // 「首次访客」,只能看启动瞬间:有历史或 URL 带项目的是老用户,hero 坍缩成
353
+ // 状态带;两者皆无的首次访客保留完整 hero 一整个会话。
354
+ function initializeHeroState() {
355
+ const hasProjectParam = new URLSearchParams(window.location.search).has('projectPath');
356
+ const isReturningUser = state.projectHistory.length > 0 || hasProjectParam;
357
+ isPreviewSession = !isReturningUser;
358
+ elements.hero.classList.toggle('is-compact', isReturningUser);
359
+ }
360
+
361
+ function syncProjectPathToUrl(projectPath) {
362
+ const url = new URL(window.location.href);
363
+ url.searchParams.set('projectPath', projectPath);
364
+ window.history.replaceState({}, '', url);
365
+ }
366
+
367
+ function renderAdvice() {
368
+ elements.adviceList.replaceChildren();
369
+ const list = state.advice || [];
370
+ const count = list.length;
371
+ // 触发按钮:0 条时整个隐藏,1 条及以上露出徽标;存在 warning 时染色提示。
372
+ const hasWarning = list.some((advice) => advice.severity === 'warning');
373
+ elements.adviceToggle.hidden = count === 0;
374
+ elements.adviceCount.textContent = count;
375
+ elements.adviceToggle.classList.toggle('has-warning', hasWarning);
376
+ if (count === 0) {
377
+ // 同时收起已展开的 popover,避免下次打开还是 0 条。
378
+ setAdvicePopoverOpen(false);
379
+ return;
380
+ }
381
+
382
+ // 服务端最多生成 8 条(skillStore 6 + collectionSetup 2),全部渲染;
383
+ // 此前 slice(0, 6) 会让第 7、8 条建议静默消失。
384
+ list.forEach((advice, index) => {
385
+ const item = document.createElement('article');
386
+ item.className = `advice ${advice.severity || 'info'}`;
387
+ item.innerHTML = `
388
+ <div>
389
+ <strong></strong>
390
+ <p></p>
391
+ </div>
392
+ <div class="advice-aside">
393
+ <span class="advice-severity"></span>
394
+ <span class="advice-type"></span>
395
+ </div>
396
+ `;
397
+ item.querySelector('strong').textContent = advice.title;
398
+ item.querySelector('p').textContent = advice.detail;
399
+ // 严重级别必须有文字表达,不能只靠颜色区分。
400
+ item.querySelector('.advice-severity').textContent = advice.severity === 'warning' ? '注意' : '提示';
401
+ item.querySelector('.advice-type').textContent = advice.type;
402
+ const setupAction = (advice.actions || []).find((action) => action.type === 'run-setup-skill');
403
+ if (setupAction) {
404
+ const button = document.createElement('button');
405
+ button.className = 'secondary advice-action';
406
+ button.type = 'button';
407
+ button.textContent = '查看配置指引';
408
+ button.dataset.focusKey = `advice-action:${index}`;
409
+ button.addEventListener('click', () => setMessage(setupAction.instruction));
410
+ item.querySelector('div').append(button);
411
+ }
412
+ elements.adviceList.append(item);
413
+ });
414
+ }
415
+
416
+ // Popover 状态机:点击 toggle 翻转 open;点 popover 外或按 Esc 关闭。
417
+ // 状态全部落到 aria-expanded + hidden 单一来源,方便测试和无障碍工具。
418
+ function setAdvicePopoverOpen(open) {
419
+ if (open) {
420
+ elements.adviceList.hidden = false;
421
+ elements.adviceToggle.setAttribute('aria-expanded', 'true');
422
+ } else {
423
+ elements.adviceList.hidden = true;
424
+ elements.adviceToggle.setAttribute('aria-expanded', 'false');
425
+ }
426
+ }
427
+
428
+ function toggleAdvicePopover() {
429
+ setAdvicePopoverOpen(elements.adviceList.hidden);
430
+ }
431
+
432
+ elements.adviceToggle.addEventListener('click', (event) => {
433
+ event.stopPropagation();
434
+ toggleAdvicePopover();
435
+ });
436
+ // popover 内部点击不冒泡到 document 的 outside-click 关闭。
437
+ elements.adviceList.addEventListener('click', (event) => event.stopPropagation());
438
+ document.addEventListener('click', () => {
439
+ if (!elements.adviceList.hidden) setAdvicePopoverOpen(false);
440
+ });
441
+ document.addEventListener('keydown', (event) => {
442
+ if (event.key === 'Escape' && !elements.adviceList.hidden) {
443
+ setAdvicePopoverOpen(false);
444
+ elements.adviceToggle.focus();
445
+ }
446
+ });
447
+
448
+ function renderSkills() {
449
+ withPreservedFocus(renderSkillList);
450
+ }
451
+
452
+ function renderLibrary() {
453
+ withPreservedFocus(() => {
454
+ renderTagTabs();
455
+ renderSkillList();
456
+ });
457
+ }
458
+
459
+ function renderSkillList() {
460
+ const filter = elements.sourceFilter.value;
461
+ const enabledByTarget = new Map(state.enabled.filter((item) => item.isSymlink && item.targetPath).map((item) => [item.targetPath, item]));
462
+ const globalByTarget = new Map(state.global.filter((item) => item.isSymlink && item.targetPath).map((item) => [item.targetPath, item]));
463
+ const hermesByTarget = new Map(state.hermes.filter((item) => item.isSymlink && item.targetPath).map((item) => [item.targetPath, item]));
464
+ const enabledTargets = new Set(enabledByTarget.keys());
465
+ const globalTargets = new Set(globalByTarget.keys());
466
+ const hermesTargets = new Set(hermesByTarget.keys());
467
+ const sourceSkills = filter ? state.skills.filter((skill) => skill.source === filter) : state.skills;
468
+ const searchedSkills = state.searchQuery ? sourceSkills.filter((skill) => matchesSearchQuery(skill, state.searchQuery)) : sourceSkills;
469
+ const skills = state.activeTag ? searchedSkills.filter((skill) => (skill.tags || []).includes(state.activeTag)) : searchedSkills;
470
+ const groups = groupSkills(skills);
471
+
472
+ elements.skillList.replaceChildren();
473
+
474
+ if (skills.length === 0) { elements.skillList.append(hasActiveFilter() ? filteredEmpty() : libraryEmpty()); return; }
475
+
476
+ initializeCollapsedGroups(groups);
477
+ groups.forEach((group) => {
478
+ const isCollapsed = state.collapsedGroups.has(group.key);
479
+ const groupElement = document.createElement('section');
480
+ groupElement.className = `skill-group${isCollapsed ? ' is-collapsed' : ''}`;
481
+ groupElement.innerHTML = `
482
+ <div class="group-bar" data-focus-scope>
483
+ <div class="group-head">
484
+ <h3 class="group-heading">
485
+ <button class="group-toggle" type="button" aria-expanded="${!isCollapsed}">
486
+ <span class="chevron" aria-hidden="true">▾</span>
487
+ <span class="title"></span>
488
+ </button>
489
+ </h3>
490
+ <span class="badge"></span>
491
+ <div class="group-actions">
492
+ <div class="scope-action-group project-scope-actions">
493
+ <span class="scope-label">项目</span>
494
+ <button class="icon-button group-enable-all" type="button">+</button>
495
+ <button class="icon-button group-disable-all danger" type="button" title="清空该库已启用的 skill" aria-label="清空该库已启用的 skill">×</button>
496
+ </div>
497
+ <span class="scope-divider" aria-hidden="true"></span>
498
+ <div class="scope-action-group global-scope-actions">
499
+ <span class="scope-label">全局</span>
500
+ <button class="icon-button group-enable-global" type="button" title="批量启用到全局">+</button>
501
+ <button class="icon-button group-disable-global danger" type="button" title="清空该库的全局 skill" aria-label="清空该库的全局 skill">×</button>
502
+ </div>
503
+ <span class="scope-divider" aria-hidden="true"></span>
504
+ <div class="scope-action-group hermes-scope-actions">
505
+ <span class="scope-label">Hermes</span>
506
+ <button class="icon-button group-enable-hermes" type="button" title="批量启用到 Hermes">+</button>
507
+ <button class="icon-button group-disable-hermes danger" type="button" title="清空该库的 Hermes skill" aria-label="清空该库的 Hermes skill">×</button>
508
+ </div>
509
+ </div>
510
+ <p></p>
511
+ </div>
512
+ </div>
513
+ <div class="group-items"></div>
514
+ `;
515
+
516
+ groupElement.querySelector('.group-toggle .title').textContent = group.collection;
517
+ const sourceInfo = findSourceInfo(group.key);
518
+ const versionText = sourceInfo?.version ? ` · ${sourceInfo.version}` : '';
519
+ groupElement.querySelector('p').textContent = `${group.collectionPath}${versionText}`;
520
+ const setup = findCollectionSetup(group.source, group.collection);
521
+ const badge = groupElement.querySelector('.badge');
522
+ badge.textContent = `${group.source} · ${group.skills.length}${setup ? ` · ${setupStatusLabel(setup)}` : ''}`;
523
+ if (setup) badge.title = setupTooltip(setup);
524
+ const toggleButton = groupElement.querySelector('.group-toggle');
525
+ toggleButton.dataset.focusKey = `group-toggle:${group.key}`;
526
+ toggleButton.addEventListener('click', () => toggleGroup(group.key));
527
+ const enableAllButton = groupElement.querySelector('.group-enable-all');
528
+ const pendingSkills = group.skills.filter((skill) => canBulkEnableSkill(skill, enabledTargets));
529
+ const globalEnableButton = groupElement.querySelector('.group-enable-global');
530
+ const globalPendingSkills = group.skills.filter((skill) => canBulkEnableSkill(skill, globalTargets));
531
+ const globalEnabledSkills = group.skills.filter((skill) => globalByTarget.has(skill.path));
532
+ const removableGlobalSkills = group.skills.filter((skill) => (globalByTarget.get(skill.path)?.canDisable ?? false));
533
+ const hermesEnableButton = groupElement.querySelector('.group-enable-hermes');
534
+ const hermesPendingSkills = group.skills.filter((skill) => canBulkEnableSkill(skill, hermesTargets, 'hermes'));
535
+ const hermesEnabledSkills = group.skills.filter((skill) => hermesByTarget.has(skill.path));
536
+ const removableHermesSkills = group.skills.filter((skill) => (hermesByTarget.get(skill.path)?.canDisable ?? false));
537
+ const enabledSkills = group.skills.filter((skill) => enabledByTarget.has(skill.path));
538
+ const pendingSetupSkill = setup?.status !== 'ready' && setup?.status !== 'invalid' && !setup?.setupSkillEnabled;
539
+ enableAllButton.disabled = pendingSkills.length === 0 && !pendingSetupSkill;
540
+ enableAllButton.dataset.focusKey = `group-enable:${group.key}`;
541
+ enableAllButton.textContent = enabledSkills.length > 0 ? `${enabledSkills.length}/${group.skills.length}` : '+';
542
+ // 可见文字必须出现在可访问名里,否则语音控制用户念不出这个按钮。
543
+ const enableAllLabel = enabledSkills.length > 0
544
+ ? `已启用 ${enabledSkills.length}/${group.skills.length},批量启用该库其余 skill`
545
+ : '批量启用该库全部 skill';
546
+ enableAllButton.title = enableAllLabel;
547
+ enableAllButton.setAttribute('aria-label', enableAllLabel);
548
+ if (enabledSkills.length === group.skills.length) enableAllButton.classList.add('is-complete');
549
+ enableAllButton.addEventListener('click', () => enableGroup(group, 'project', enableAllButton));
550
+ globalEnableButton.disabled = globalPendingSkills.length === 0;
551
+ globalEnableButton.dataset.focusKey = `group-enable-global:${group.key}`;
552
+ // 与项目 + 保持一致:有已全局启用时显示进度计数,否则显示 +。
553
+ globalEnableButton.textContent = globalEnabledSkills.length > 0 ? `${globalEnabledSkills.length}/${group.skills.length}` : '+';
554
+ const globalEnableLabel = globalEnabledSkills.length > 0
555
+ ? `已全局启用 ${globalEnabledSkills.length}/${group.skills.length},批量启用该库其余 skill 到全局`
556
+ : '批量启用该库全部 skill 到全局';
557
+ globalEnableButton.title = globalEnableLabel;
558
+ globalEnableButton.setAttribute('aria-label', globalEnableLabel);
559
+ if (globalEnabledSkills.length === group.skills.length) globalEnableButton.classList.add('is-complete');
560
+ globalEnableButton.addEventListener('click', () => enableGroup(group, 'global', globalEnableButton));
561
+ const disableAllButton = groupElement.querySelector('.group-disable-all');
562
+ disableAllButton.disabled = enabledSkills.length === 0;
563
+ disableAllButton.dataset.focusKey = `group-disable:${group.key}`;
564
+ disableAllButton.addEventListener('click', () => disableGroup(group, 'project', disableAllButton));
565
+ const globalDisableButton = groupElement.querySelector('.group-disable-global');
566
+ globalDisableButton.disabled = removableGlobalSkills.length === 0;
567
+ globalDisableButton.dataset.focusKey = `group-disable-global:${group.key}`;
568
+ globalDisableButton.addEventListener('click', () => disableGroup(group, 'global', globalDisableButton));
569
+ hermesEnableButton.disabled = hermesPendingSkills.length === 0;
570
+ hermesEnableButton.dataset.focusKey = `group-enable-hermes:${group.key}`;
571
+ hermesEnableButton.textContent = hermesEnabledSkills.length > 0 ? `${hermesEnabledSkills.length}/${group.skills.length}` : '+';
572
+ const hermesEnableLabel = hermesEnabledSkills.length > 0
573
+ ? `已启用到 Hermes ${hermesEnabledSkills.length}/${group.skills.length},批量启用该库其余 eligible skill 到 Hermes`
574
+ : '批量启用该库全部 eligible skill 到 Hermes';
575
+ hermesEnableButton.title = hermesEnableLabel;
576
+ hermesEnableButton.setAttribute('aria-label', hermesEnableLabel);
577
+ if (hermesEnabledSkills.length === group.skills.length) hermesEnableButton.classList.add('is-complete');
578
+ hermesEnableButton.addEventListener('click', () => enableGroup(group, 'hermes', hermesEnableButton));
579
+ const hermesDisableButton = groupElement.querySelector('.group-disable-hermes');
580
+ hermesDisableButton.disabled = removableHermesSkills.length === 0;
581
+ hermesDisableButton.dataset.focusKey = `group-disable-hermes:${group.key}`;
582
+ hermesDisableButton.addEventListener('click', () => disableGroup(group, 'hermes', hermesDisableButton));
583
+
584
+ if (!isCollapsed) {
585
+ const items = groupElement.querySelector('.group-items');
586
+ group.skills.forEach((skill) => items.append(renderSkill(skill, enabledTargets, globalTargets, hermesTargets)));
587
+ }
588
+ elements.skillList.append(groupElement);
589
+ });
590
+ }
591
+
592
+ function hasActiveFilter() {
593
+ return Boolean(state.searchQuery || state.activeTag || elements.sourceFilter.value);
594
+ }
595
+
596
+ function filterSignature() {
597
+ return JSON.stringify([elements.sourceFilter.value, state.searchQuery, state.activeTag]);
598
+ }
599
+
600
+ function describeActiveFilter() {
601
+ const parts = [];
602
+ if (state.searchQuery) parts.push(`搜索「${elements.skillSearch.value.trim()}」`);
603
+ if (elements.sourceFilter.value) parts.push(`来源 ${elements.sourceFilter.value}`);
604
+ if (state.activeTag) parts.push(`标签 ${state.activeTag}`);
605
+ return parts.join(' · ');
606
+ }
607
+
608
+ function libraryEmpty() {
609
+ return emptyState('还没有 skill 原件', '把 skill 目录放入 official、github 或 personal,然后点右上角刷新。');
610
+ }
611
+
612
+ function filteredEmpty() {
613
+ const element = emptyState(
614
+ '没有匹配的 skill',
615
+ `当前筛选:${describeActiveFilter()}。库里共有 ${state.stats.total} 个 skill。`
616
+ );
617
+
618
+ const button = document.createElement('button');
619
+ button.className = 'secondary empty-action';
620
+ button.type = 'button';
621
+ button.textContent = '清除筛选条件';
622
+ button.dataset.focusKey = 'clear-filters';
623
+ button.addEventListener('click', clearFilters);
624
+ element.append(button);
625
+
626
+ return element;
627
+ }
628
+
629
+ function clearFilters() {
630
+ clearTimeout(searchDebounceTimer);
631
+ elements.sourceFilter.value = '';
632
+ elements.skillSearch.value = '';
633
+ state.searchQuery = '';
634
+ state.activeTag = '';
635
+ renderLibrary();
636
+ elements.skillSearch.focus();
637
+ }
638
+
639
+ // 分组默认折叠可以压住 30 个库的长度,但筛选结果同样折叠时,
640
+ // 用户只看到一排组标题,会把「命中藏在折叠分组里」误读成「没搜到」。
641
+ // 因此带任何筛选(搜索词、标签、来源)时展开全部命中分组;无筛选时
642
+ // 默认折叠成列表,由用户按需展开自己要看的分组。
643
+ function initializeCollapsedGroups(groups) {
644
+ const signature = filterSignature();
645
+ if (signature === lastFilterSignature) {
646
+ groups.forEach((group) => collapseNewGroup(group.key));
647
+ return;
648
+ }
649
+
650
+ lastFilterSignature = signature;
651
+ filterExpandedGroups.forEach((key) => state.collapsedGroups.add(key));
652
+ filterExpandedGroups = new Set();
653
+
654
+ if (!state.searchQuery && !state.activeTag && !elements.sourceFilter.value) {
655
+ groups.forEach((group) => collapseNewGroup(group.key));
656
+ return;
657
+ }
658
+
659
+ groups.forEach((group) => {
660
+ const wasCollapsed = state.collapsedGroups.has(group.key) || !state.knownGroups.has(group.key);
661
+ collapseNewGroup(group.key);
662
+ if (!wasCollapsed) return;
663
+ state.collapsedGroups.delete(group.key);
664
+ filterExpandedGroups.add(group.key);
665
+ });
666
+ }
667
+
668
+ function collapseNewGroup(key) {
669
+ if (state.knownGroups.has(key)) return;
670
+ state.knownGroups.add(key); state.collapsedGroups.add(key);
671
+ }
672
+
673
+ function renderSkill(skill, enabledTargets, globalTargets = new Set(), hermesTargets = new Set()) {
674
+ const isEnabled = enabledTargets.has(skill.path);
675
+ const isGloballyEnabled = globalTargets.has(skill.path);
676
+ const isHermesEnabled = hermesTargets.has(skill.path);
677
+ const item = document.createElement('article');
678
+ item.className = 'skill';
679
+ item.dataset.focusScope = '';
680
+ item.innerHTML = `
681
+ <div class="skill-head">
682
+ <span class="name"></span>
683
+ <span class="badge">${skill.source}</span>
684
+ </div>
685
+ <div class="skill-tags"></div>
686
+ <div class="bulk-status"></div>
687
+ <p class="note"></p>
688
+ <div class="meta"></div>
689
+ <div class="path"></div>
690
+ <div class="metadata-editor"></div>
691
+ <div class="actions"></div>
692
+ `;
693
+
694
+ item.querySelector('.name').textContent = skill.name;
695
+ renderSkillTags(item.querySelector('.skill-tags'), skill.tags || []);
696
+ renderBulkStatus(item.querySelector('.bulk-status'), skill);
697
+ const noteElement = item.querySelector('.note');
698
+ if (skill.note) {
699
+ noteElement.textContent = skill.note;
700
+ } else {
701
+ // 无备注时整行不渲染:171 行里 86 行是「未填写备注」空占位,没有信息量。
702
+ noteElement.remove();
703
+ }
704
+ item.querySelector('.meta').textContent = skill.description || (skill.hasSkillFile ? '未填写 description' : '缺少 SKILL.md');
705
+ item.querySelector('.path').textContent = skill.path;
706
+ // 原件库卡片与已启用卡片保持同一悬停契约:优先展示人为备注,其次回退到 description。
707
+ const hoverText = skill.note || skill.description;
708
+ if (hoverText) item.title = hoverText;
709
+ if (state.editingSkillId === skill.id) renderMetadataEditor(item.querySelector('.metadata-editor'), skill);
710
+
711
+ const actions = item.querySelector('.actions');
712
+ const projectActions = document.createElement('div');
713
+ projectActions.className = 'scope-action-group project-scope-actions';
714
+ const globalActions = document.createElement('div');
715
+ globalActions.className = 'scope-action-group global-scope-actions';
716
+ const hermesActions = document.createElement('div');
717
+ hermesActions.className = 'scope-action-group hermes-scope-actions';
718
+ const scopeDivider = document.createElement('span');
719
+ scopeDivider.className = 'scope-divider';
720
+ scopeDivider.setAttribute('aria-hidden', 'true');
721
+ const editButton = document.createElement('button');
722
+ editButton.className = 'secondary';
723
+ editButton.type = 'button';
724
+ editButton.dataset.focusKey = `skill-edit:${skill.id}`;
725
+ editButton.textContent = state.editingSkillId === skill.id ? '收起备注' : '编辑备注';
726
+ editButton.addEventListener('click', () => {
727
+ state.editingSkillId = state.editingSkillId === skill.id ? '' : skill.id;
728
+ renderSkills();
729
+ // withPreservedFocus 会把焦点送回重渲染后的编辑按钮;
730
+ // 打开表单时必须再显式送进 textarea,否则键盘用户 Tab 会从编辑按钮
731
+ // 直接跳到下一行(WCAG 2.4.3 焦点顺序)。
732
+ if (state.editingSkillId === skill.id) {
733
+ document.querySelector(`[data-focus-key="skill-note:${skill.id}"]`)?.focus();
734
+ }
735
+ });
736
+ const button = document.createElement('button');
737
+ button.type = 'button';
738
+ button.dataset.focusKey = `skill-enable:${skill.id}`;
739
+ button.textContent = isEnabled ? '已启用' : '启用 agents skill';
740
+ // 无理由的灰按钮会让用户反复尝试后放弃:禁用必须给出原因和解法。
741
+ const archivedReason = 'archived 来源不参与启用;如需使用,请先把 skill 目录移回 official、github 或 personal';
742
+ if (isEnabled) {
743
+ // 已启用是状态而非禁用:用 aria-disabled 保留可聚焦性,
744
+ // 键盘和读屏用户才能停在这里听到「已启用」。
745
+ button.classList.add('is-enabled');
746
+ button.setAttribute('aria-disabled', 'true');
747
+ } else if (skill.source === 'archived') {
748
+ button.disabled = true;
749
+ button.title = archivedReason;
750
+ button.setAttribute('aria-label', `启用 agents skill(不可用):${archivedReason}`);
751
+ }
752
+ button.addEventListener('click', () => {
753
+ if (button.getAttribute('aria-disabled') === 'true') return;
754
+ enable(skill);
755
+ });
756
+ projectActions.append(editButton, button);
757
+
758
+ const globalButton = document.createElement('button');
759
+ globalButton.type = 'button';
760
+ globalButton.dataset.focusKey = `skill-enable-global:${skill.id}`;
761
+ globalButton.textContent = isGloballyEnabled ? '已全局启用' : '启用到全局';
762
+ if (isGloballyEnabled) {
763
+ globalButton.classList.add('is-enabled');
764
+ globalButton.setAttribute('aria-disabled', 'true');
765
+ } else if (skill.source === 'archived') {
766
+ globalButton.disabled = true;
767
+ globalButton.title = archivedReason;
768
+ globalButton.setAttribute('aria-label', `启用到全局(不可用):${archivedReason}`);
769
+ }
770
+ globalButton.addEventListener('click', () => {
771
+ if (globalButton.getAttribute('aria-disabled') === 'true') return;
772
+ enable(skill, 'global');
773
+ });
774
+ globalActions.append(globalButton);
775
+ const hermesDivider = document.createElement('span');
776
+ hermesDivider.className = 'scope-divider';
777
+ hermesDivider.setAttribute('aria-hidden', 'true');
778
+ const hermesButton = document.createElement('button');
779
+ hermesButton.type = 'button';
780
+ hermesButton.dataset.focusKey = `skill-enable-hermes:${skill.id}`;
781
+ hermesButton.textContent = isHermesEnabled ? '已启用 Hermes' : '启用 Hermes';
782
+ const hermesEligible = ['official', 'github', 'personal'].includes(skill.source);
783
+ const hermesReason = 'Hermes 只接受 official、github 或 personal 来源的 skill';
784
+ if (isHermesEnabled) {
785
+ hermesButton.classList.add('is-enabled');
786
+ hermesButton.setAttribute('aria-disabled', 'true');
787
+ } else if (!hermesEligible) {
788
+ hermesButton.disabled = true;
789
+ hermesButton.title = hermesReason;
790
+ hermesButton.setAttribute('aria-label', `启用 Hermes(不可用):${hermesReason}`);
791
+ }
792
+ hermesButton.addEventListener('click', () => {
793
+ if (hermesButton.getAttribute('aria-disabled') === 'true') return;
794
+ enable(skill, 'hermes');
795
+ });
796
+ hermesActions.append(hermesButton);
797
+ actions.append(projectActions, scopeDivider, globalActions, hermesDivider, hermesActions);
798
+
799
+ return item;
800
+ }
801
+
802
+ function renderTagTabs() {
803
+ const tags = [...new Set(state.skills.flatMap((skill) => skill.tags || []))].sort((left, right) => left.localeCompare(right));
804
+ elements.tagTabs.replaceChildren();
805
+ if (tags.length === 0) return;
806
+
807
+ const allButton = tagTabButton('全部标签', state.activeTag === '', 'tag:');
808
+ allButton.addEventListener('click', () => {
809
+ state.activeTag = '';
810
+ renderLibrary();
811
+ });
812
+ elements.tagTabs.append(allButton);
813
+
814
+ tags.forEach((tag) => {
815
+ const button = tagTabButton(tag, state.activeTag === tag, `tag:${tag}`);
816
+ button.addEventListener('click', () => {
817
+ state.activeTag = tag;
818
+ renderLibrary();
819
+ });
820
+ elements.tagTabs.append(button);
821
+ });
822
+
823
+ // roving tabindex:26 个标签此前全部在 Tab 序里,键盘用户到第一个启用
824
+ // 按钮要按 100+ 次 Tab;改为单一停靠点 + 方向键在标签间移动。
825
+ elements.tagTabs.querySelectorAll('.category-tab').forEach((button) => {
826
+ button.tabIndex = button.classList.contains('is-active') ? 0 : -1;
827
+ });
828
+ }
829
+
830
+ // 方向键在标签间移动(roving tabindex),Home/End 跳首尾。
831
+ // 绑定一次即可:按钮每次重建,容器与委托逻辑不变。
832
+ elements.tagTabs.addEventListener('keydown', (event) => {
833
+ const current = event.target.closest('.category-tab');
834
+ if (!current) return;
835
+
836
+ const tabs = [...elements.tagTabs.querySelectorAll('.category-tab')];
837
+ const index = tabs.indexOf(current);
838
+ if (index === -1) return;
839
+
840
+ let nextIndex = -1;
841
+ if (event.key === 'ArrowRight') nextIndex = (index + 1) % tabs.length;
842
+ else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + tabs.length) % tabs.length;
843
+ else if (event.key === 'Home') nextIndex = 0;
844
+ else if (event.key === 'End') nextIndex = tabs.length - 1;
845
+ else return;
846
+
847
+ event.preventDefault();
848
+ tabs.forEach((button) => { button.tabIndex = -1; });
849
+ tabs[nextIndex].tabIndex = 0;
850
+ tabs[nextIndex].focus();
851
+ });
852
+
853
+ function tagTabButton(text, isActive, focusKey) {
854
+ const button = document.createElement('button');
855
+ button.className = `category-tab${isActive ? ' is-active' : ''}`;
856
+ button.type = 'button';
857
+ button.textContent = text;
858
+ button.dataset.focusKey = focusKey;
859
+ // 选中状态此前只有背景色,读屏用户听不出当前过滤在哪一项。
860
+ button.setAttribute('aria-pressed', String(isActive));
861
+ return button;
862
+ }
863
+
864
+ function renderSkillTags(container, tags) {
865
+ container.replaceChildren();
866
+ tags.forEach((tag) => {
867
+ const badge = document.createElement('span');
868
+ badge.className = 'tag-pill';
869
+ badge.textContent = tag;
870
+ container.append(badge);
871
+ });
872
+ }
873
+
874
+ function renderBulkStatus(container, skill) {
875
+ container.replaceChildren();
876
+ if (skill.autoEnable !== false) return;
877
+ const badge = document.createElement('span');
878
+ badge.className = 'tag-pill is-muted';
879
+ badge.textContent = '不参与批量启用';
880
+ container.append(badge);
881
+ }
882
+
883
+ function renderMetadataEditor(container, skill) {
884
+ const form = document.createElement('form');
885
+ form.className = 'metadata-form';
886
+ form.innerHTML = `
887
+ <label>
888
+ <span>备注</span>
889
+ <textarea name="note" rows="3" maxlength="500"></textarea>
890
+ </label>
891
+ <label>
892
+ <span>Tags</span>
893
+ <input name="tags" type="text" placeholder="Developer Tools, Productivity">
894
+ </label>
895
+ <label class="toggle-field">
896
+ <input name="autoEnable" type="checkbox">
897
+ <span>参与库级批量启用</span>
898
+ </label>
899
+ <div class="metadata-actions">
900
+ <button type="submit">保存</button>
901
+ <button class="secondary" type="button">取消</button>
902
+ </div>
903
+ `;
904
+ form.elements.note.value = skill.note || '';
905
+ form.elements.tags.value = (skill.tags || []).join(', ');
906
+ form.elements.autoEnable.checked = skill.autoEnable !== false;
907
+
908
+ form.elements.note.dataset.focusKey = `skill-note:${skill.id}`;
909
+ form.elements.note.dataset.focusFallbackKey = `skill-edit:${skill.id}`;
910
+ form.elements.tags.dataset.focusKey = `skill-tags:${skill.id}`;
911
+ form.elements.tags.dataset.focusFallbackKey = `skill-edit:${skill.id}`;
912
+ form.elements.autoEnable.dataset.focusKey = `skill-auto-enable:${skill.id}`;
913
+ form.elements.autoEnable.dataset.focusFallbackKey = `skill-edit:${skill.id}`;
914
+ form.querySelector('button[type="submit"]').dataset.focusKey = `skill-save:${skill.id}`;
915
+ form.querySelector('button[type="submit"]').dataset.focusFallbackKey = `skill-edit:${skill.id}`;
916
+ form.querySelector('button[type="button"]').dataset.focusKey = `skill-cancel:${skill.id}`;
917
+ form.querySelector('button[type="button"]').dataset.focusFallbackKey = `skill-edit:${skill.id}`;
918
+
919
+ form.addEventListener('submit', async (event) => {
920
+ event.preventDefault();
921
+ await saveMetadata(skill, form);
922
+ });
923
+ form.querySelector('button[type="button"]').addEventListener('click', () => {
924
+ state.editingSkillId = '';
925
+ renderSkills();
926
+ });
927
+ container.append(form);
928
+ }
929
+
930
+ function groupSkills(skills) {
931
+ const groups = new Map();
932
+
933
+ skills.forEach((skill) => {
934
+ const key = `${skill.source}/${skill.collection}`;
935
+ if (!groups.has(key)) {
936
+ groups.set(key, {
937
+ key,
938
+ source: skill.source,
939
+ collection: skill.collection,
940
+ collectionPath: skill.collectionPath,
941
+ skills: []
942
+ });
943
+ }
944
+
945
+ groups.get(key).skills.push(skill);
946
+ });
947
+
948
+ return [...groups.values()].sort((left, right) =>
949
+ left.source.localeCompare(right.source) || left.collection.localeCompare(right.collection)
950
+ );
951
+ }
952
+
953
+ function matchesSearchQuery(skill, query) {
954
+ const name = (skill.name || '').toLowerCase();
955
+ const description = (skill.description || '').toLowerCase();
956
+ const tags = (skill.tags || []).join(' ').toLowerCase();
957
+ return name.includes(query) || description.includes(query) || tags.includes(query);
958
+ }
959
+
960
+ function toggleGroup(key) {
961
+ state.collapsedGroups.has(key) ? state.collapsedGroups.delete(key) : state.collapsedGroups.add(key);
962
+ renderSkills();
963
+ }
964
+
965
+ async function enable(skill, scope = 'project') {
966
+ if (scope === 'project' && !confirmSetupPreflight([skill.id])) return;
967
+ const result = await api('/api/enable', {
968
+ method: 'POST',
969
+ body: {
970
+ scope,
971
+ ...(scope === 'project' ? { projectPath: elements.projectPath.value } : {}),
972
+ skillPath: skill.path,
973
+ alias: skill.name
974
+ }
975
+ });
976
+ setMessage(enableMessage(skill.name, result, scope));
977
+ await loadState({ button: null });
978
+ }
979
+
980
+ async function enableGroup(group, scope = 'project', button = null) {
981
+ const plan = await api('/api/enable-plan', {
982
+ method: 'POST',
983
+ body: {
984
+ scope,
985
+ ...(scope === 'project' ? { projectPath: elements.projectPath.value } : {}),
986
+ skillIds: group.skills.map((skill) => skill.id)
987
+ }
988
+ });
989
+ const skills = plan.targetSkillIds.map((skillId) => state.skills.find((skill) => skill.id === skillId)).filter(Boolean);
990
+ const skipped = plan.skippedSkillIds;
991
+ const pendingSetups = plan.setups;
992
+
993
+ if (skills.length === 0) {
994
+ setMessage(skipped.length ? `${group.collection} 没有需要启用的 skill,已跳过 ${skipped.length} 个不参与批量启用` : `${group.collection} 没有需要启用的 skill`);
995
+ return;
996
+ }
997
+
998
+ if (scope === 'project' && pendingSetups.length > 0 && !confirmSetupPreflight(skills.map((skill) => skill.id))) return;
999
+
1000
+ // 圆形图标按钮容不下多字文案,忙碌态只用省略号,结果交给 toast。
1001
+ const run = async () => {
1002
+ const result = await api('/api/enable-collection', {
1003
+ method: 'POST',
1004
+ body: {
1005
+ scope,
1006
+ ...(scope === 'project' ? { projectPath: elements.projectPath.value } : {}),
1007
+ skillIds: group.skills.map((skill) => skill.id)
1008
+ }
1009
+ });
1010
+
1011
+ const parts = [`已处理 ${group.collection}`];
1012
+ if (result.counts.enabled) parts.push(`启用 ${result.counts.enabled}`);
1013
+ if (result.counts.unchanged) parts.push(`已存在 ${result.counts.unchanged}`);
1014
+ if (result.counts.skipped) parts.push(`跳过 ${result.counts.skipped}`);
1015
+ if (result.counts.failed) {
1016
+ const failedAliases = (result.outcomes || [])
1017
+ .filter((outcome) => outcome.status === 'failed')
1018
+ .map((outcome) => outcome.alias)
1019
+ .filter(Boolean);
1020
+ parts.push(failedAliases.length
1021
+ ? `以下 ${failedAliases.length} 个未能启用,可单独重试:${failedAliases.join('、')}`
1022
+ : `失败 ${result.counts.failed}`);
1023
+ }
1024
+ await loadState({ button: null });
1025
+ const refreshedSetups = result.refresh.ok ? result.setups : pendingSetups;
1026
+ const remainingSetups = refreshedSetups.filter((setup) => setup.status !== 'ready');
1027
+ if (scope === 'project' && remainingSetups.length > 0) parts.push(`待配置:${remainingSetups.map((setup) => setup.setupSkillName).join('、')}`);
1028
+ if (!result.refresh.ok) parts.push(`配置提醒刷新失败:${result.refresh.error}`);
1029
+ setMessage(parts.join(','), result.counts.failed > 0 || !result.refresh.ok);
1030
+ };
1031
+
1032
+ button ? await withButtonState(button, '…', run) : await run();
1033
+ }
1034
+
1035
+ function findCollectionSetup(source, collection) {
1036
+ return (state.setups || []).find((setup) => setup.id === `${source}/${collection}`);
1037
+ }
1038
+
1039
+ function findSetupsForSkillIds(skillIds) {
1040
+ const candidates = new Set(skillIds);
1041
+ return (state.setups || []).filter((setup) =>
1042
+ setup.status !== 'invalid' && setup.applicableSkillIds.some((skillId) => candidates.has(skillId))
1043
+ );
1044
+ }
1045
+
1046
+ function confirmSetupPreflight(skillIds) {
1047
+ const setups = findSetupsForSkillIds(skillIds).filter((setup) => setup.status !== 'ready');
1048
+ if (setups.length === 0) return true;
1049
+ const detail = setups.map((setup) => {
1050
+ const status = setup.status === 'partial' ? '配置不完整' : '尚未配置';
1051
+ return `${setup.id}:${status}\n初始化 skill:${setup.setupSkillName}\n缺少:${setup.missingArtifacts.join('、')}`;
1052
+ }).join('\n\n');
1053
+ return window.confirm(`${detail}\n\n可以先启用,但完成配置前不能视为已就绪。启用后请在 Agent 中运行初始化 skill。`);
1054
+ }
1055
+
1056
+ function setupStatusLabel(setup) {
1057
+ if (setup.status === 'missing') return setup.affectedEnabledSkillIds.length > 0 ? '待配置' : '需初始化';
1058
+ return { partial: '配置不完整', ready: '已就绪', invalid: '配置无效' }[setup.status] || setup.status;
1059
+ }
1060
+
1061
+ function setupTooltip(setup) {
1062
+ const tooltips = {
1063
+ missing: '尚未配置,启用后需运行初始化 skill',
1064
+ partial: '仅完成部分配置,请运行初始化 skill',
1065
+ ready: '已完成初始化配置',
1066
+ invalid: '配置定义无效,请检查 collection-metadata 文件'
1067
+ };
1068
+ return tooltips[setup.status] || '';
1069
+ }
1070
+
1071
+ function findSourceInfo(groupKey) {
1072
+ return state.sources.find((source) => source.installPath === groupKey);
1073
+ }
1074
+
1075
+ function canBulkEnableSkill(skill, enabledTargets, scope = 'project') {
1076
+ if (scope === 'hermes' && !['official', 'github', 'personal'].includes(skill.source)) return false;
1077
+ return skill.source !== 'archived' && skill.autoEnable !== false && !enabledTargets.has(skill.path);
1078
+ }
1079
+
1080
+ // 串行 2N 次往返会让 20 个 skill 的清空静止十几秒;全并发又可能瞬间打满本地
1081
+ // 服务。固定小并发消费队列,调用方在 worker 里自行捕获错误并驱动进度提示。
1082
+ const BULK_CONCURRENCY = 4;
1083
+
1084
+ async function mapWithConcurrency(items, limit, worker) {
1085
+ const queue = [...items];
1086
+ const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
1087
+ while (queue.length > 0) {
1088
+ await worker(queue.shift());
1089
+ }
1090
+ });
1091
+ await Promise.all(runners);
1092
+ }
1093
+
1094
+ async function disableGroup(group, scope = 'project', button = null) {
1095
+ const enabledItems = scope === 'global' ? state.global : scope === 'hermes' ? state.hermes : state.enabled;
1096
+ const enabledByTarget = new Map(enabledItems.filter((item) => (item.canDisable ?? item.isSymlink) && item.targetPath).map((item) => [item.targetPath, item]));
1097
+ const aliases = group.skills
1098
+ .map((skill) => enabledByTarget.get(skill.path)?.alias)
1099
+ .filter(Boolean);
1100
+
1101
+ if (aliases.length === 0) {
1102
+ setMessage(`${group.collection} 没有可清空的${scopeLabel(scope)} skill`);
1103
+ return;
1104
+ }
1105
+
1106
+ if (!confirmBulkClear(`清空 ${group.collection} 的 ${aliases.length} 个 skill?`, aliases.length)) return;
1107
+
1108
+ const run = async () => {
1109
+ let removed = 0;
1110
+ let claudeRemoved = 0;
1111
+ let unchanged = 0;
1112
+ let settled = 0;
1113
+ const failed = [];
1114
+
1115
+ await mapWithConcurrency(aliases, BULK_CONCURRENCY, async (alias) => {
1116
+ try {
1117
+ const result = await api('/api/disable', {
1118
+ method: 'POST',
1119
+ body: {
1120
+ scope,
1121
+ ...(scope === 'project' ? { projectPath: elements.projectPath.value } : {}),
1122
+ alias
1123
+ }
1124
+ });
1125
+ result.removed ? removed += 1 : unchanged += 1;
1126
+
1127
+ if (scope === 'project') {
1128
+ const claudeResult = await api('/api/unlink-claude-skill', {
1129
+ method: 'POST',
1130
+ body: { projectPath: elements.projectPath.value, alias }
1131
+ });
1132
+ if (claudeResult.removed) claudeRemoved += 1;
1133
+ }
1134
+ } catch {
1135
+ failed.push(alias);
1136
+ } finally {
1137
+ settled += 1;
1138
+ setMessage(`清空 ${group.collection} 中:${settled}/${aliases.length}`);
1139
+ }
1140
+ });
1141
+
1142
+ const parts = [`已清空${scopeLabel(scope)} ${group.collection}`];
1143
+ if (removed) parts.push(`${removed} 个${scopeLabel(scope)} skill`);
1144
+ if (claudeRemoved) parts.push(`${claudeRemoved} 个 Claude Code skill`);
1145
+ if (unchanged) parts.push(`已不存在 ${unchanged}`);
1146
+ // 只报失败个数的话,用户既不知道是哪些,也无从判断该重试还是手工处理。
1147
+ if (failed.length) parts.push(`以下 ${failed.length} 个未能清空,可单独重试:${failed.join('、')}`);
1148
+ setMessage(parts.join(','), failed.length > 0);
1149
+ await loadState({ button: null });
1150
+ };
1151
+
1152
+ button ? await withButtonState(button, '…', run) : await run();
1153
+ }
1154
+
1155
+ // 批量清空会一次性删掉多个软链接,且没有撤销入口,因此先复述后果再执行。
1156
+ function confirmBulkClear(question, count) {
1157
+ return window.confirm(`${question}\n\n将移除 ${count} 个软链接。skill 原件不受影响,之后可以重新启用。`);
1158
+ }
1159
+
1160
+ async function saveMetadata(skill, form) {
1161
+ const result = await api('/api/skill-metadata', {
1162
+ method: 'POST',
1163
+ body: {
1164
+ skillPath: skill.path,
1165
+ note: form.elements.note.value,
1166
+ tags: form.elements.tags.value,
1167
+ autoEnable: form.elements.autoEnable.checked
1168
+ }
1169
+ });
1170
+ state.editingSkillId = '';
1171
+ setMessage(`已保存备注:${skill.name}`);
1172
+ await loadState({ button: null });
1173
+ return result;
1174
+ }
1175
+
1176
+ function enableMessage(skillName, result, scope = 'project') {
1177
+ if (result.claudeSync?.ok === false) {
1178
+ return `已启用 ${skillName};Claude Code 自动同步失败:${result.claudeSync.error}。可点 Claude Code 栏的 + 手动同步。`;
1179
+ }
1180
+
1181
+ return result.claudeSync
1182
+ ? `已启用 ${skillName},并同步 Claude Code`
1183
+ : `${scope === 'global' ? '已全局启用' : scope === 'hermes' ? '已启用到 Hermes' : '已启用'} ${skillName}`;
1184
+ }
1185
+
1186
+ function scopeLabel(scope) {
1187
+ return { project: '当前项目', global: '全局', hermes: 'Hermes' }[scope] || scope;
1188
+ }
1189
+
1190
+ async function disable(alias, scope = 'project') {
1191
+ if (isPreviewSession && scope === 'project') {
1192
+ setMessage('预览模式只读:先在下方读取你自己的项目再操作', true);
1193
+ return;
1194
+ }
1195
+ await api('/api/disable', {
1196
+ method: 'POST',
1197
+ body: {
1198
+ scope,
1199
+ ...(scope === 'project' ? { projectPath: elements.projectPath.value } : {}),
1200
+ alias
1201
+ }
1202
+ });
1203
+ setMessage(`已从${scopeLabel(scope)} ${scope === 'hermes' ? '~/.hermes/skills' : '.agents/skills'} 移除 ${alias}`);
1204
+ await loadState({ button: null });
1205
+ }
1206
+
1207
+ async function disableAgents(scope = 'project') {
1208
+ // 防御:click 裸绑定会把 PointerEvent 当作 scope 传入,这里兜底回 project。
1209
+ if (typeof scope !== 'string') scope = 'project';
1210
+ const enabledItems = scope === 'global' ? state.global : scope === 'hermes' ? state.hermes : state.enabled;
1211
+ const aliases = enabledItems.filter((skill) => skill.canDisable ?? skill.isSymlink).map((skill) => skill.alias);
1212
+ if (aliases.length === 0) {
1213
+ setMessage(`没有可清空的${scopeLabel(scope)} skill`);
1214
+ return;
1215
+ }
1216
+
1217
+ if (isPreviewSession && scope === 'project') {
1218
+ setMessage('预览模式只读:先在下方读取你自己的项目再操作', true);
1219
+ return;
1220
+ }
1221
+
1222
+ const directory = scope === 'hermes' ? '~/.hermes/skills' : '.agents/skills';
1223
+ if (!confirmBulkClear(`清空 ${directory} 里全部 ${aliases.length} 个 skill?`, aliases.length)) return;
1224
+
1225
+ const clearButton = scope === 'global' ? elements.disableGlobal : scope === 'hermes' ? elements.disableHermes : elements.disableAgents;
1226
+ await withButtonState(clearButton, '…', async () => {
1227
+ const results = await Promise.allSettled(aliases.map((alias) => api('/api/disable', {
1228
+ method: 'POST',
1229
+ body: {
1230
+ scope,
1231
+ ...(scope === 'project' ? { projectPath: elements.projectPath.value } : {}),
1232
+ alias
1233
+ }
1234
+ })));
1235
+ const failed = results
1236
+ .map((result, index) => result.status === 'rejected' ? aliases[index] : null)
1237
+ .filter(Boolean);
1238
+ const removed = aliases.length - failed.length;
1239
+ const message = [`已清空 ${removed} 个${scopeLabel(scope)} skill`];
1240
+ if (failed.length) message.push(`以下 ${failed.length} 个未能清空,可单独重试:${failed.join('、')}`);
1241
+ setMessage(message.join(','), failed.length > 0);
1242
+ await loadState({ button: null });
1243
+ }, () => (scope === 'global' ? state.global : scope === 'hermes' ? state.hermes : state.enabled).filter((skill) => skill.canDisable ?? skill.isSymlink).length === 0);
1244
+ }
1245
+
1246
+ async function syncClaude() {
1247
+ if (isPreviewSession) {
1248
+ setMessage('预览模式只读:先在下方读取你自己的项目再操作', true);
1249
+ return;
1250
+ }
1251
+ await withButtonState(elements.syncClaude, '同步中', async () => {
1252
+ const result = await api('/api/sync-claude', {
1253
+ method: 'POST',
1254
+ body: { projectPath: elements.projectPath.value }
1255
+ });
1256
+ setMessage(`已同步到 Claude Code:${result.targetPath}`);
1257
+ await loadState({ button: null });
1258
+ await flashButton(elements.syncClaude, '✓');
1259
+ });
1260
+ }
1261
+
1262
+ async function unlinkClaude() {
1263
+ if (isPreviewSession) {
1264
+ setMessage('预览模式只读:先在下方读取你自己的项目再操作', true);
1265
+ return;
1266
+ }
1267
+ const count = state.claude?.skills?.length || 0;
1268
+ if (count === 0) {
1269
+ setMessage('Claude Code 当前未启用 skill 入口');
1270
+ return;
1271
+ }
1272
+
1273
+ if (!confirmBulkClear(`清空 Claude Code 里全部 ${count} 个 skill 入口?`, count)) return;
1274
+
1275
+ // 圆形图标按钮宽 36px:多字文案会冲出边界,忙碌与完成态只用字形,
1276
+ // 具体结果交给下方的消息区。
1277
+ await withButtonState(elements.unlinkClaude, '…', async () => {
1278
+ const result = await api('/api/unlink-claude', {
1279
+ method: 'POST',
1280
+ body: { projectPath: elements.projectPath.value }
1281
+ });
1282
+ setMessage(result.removed ? `已清空 Claude Code 的 ${count} 个 skill 入口` : 'Claude Code 当前未启用 skill 入口');
1283
+ await loadState({ button: null });
1284
+ await flashButton(elements.unlinkClaude, '✓');
1285
+ }, () => !state.claude || !state.claude.exists || state.claude.skills.length === 0);
1286
+ }
1287
+
1288
+ async function unlinkClaudeSkill(alias) {
1289
+ if (isPreviewSession) {
1290
+ setMessage('预览模式只读:先在下方读取你自己的项目再操作', true);
1291
+ return;
1292
+ }
1293
+ await api('/api/unlink-claude-skill', {
1294
+ method: 'POST',
1295
+ body: { projectPath: elements.projectPath.value, alias }
1296
+ });
1297
+ setMessage(`已从 Claude Code 移除 ${alias}`);
1298
+ await loadState({ button: null });
1299
+ }
1300
+
1301
+ async function api(url, options = {}) {
1302
+ const response = await fetch(url, {
1303
+ method: options.method || 'GET',
1304
+ headers: options.body ? { 'Content-Type': 'application/json' } : undefined,
1305
+ body: options.body ? JSON.stringify(options.body) : undefined
1306
+ });
1307
+ const payload = await response.json();
1308
+
1309
+ if (!response.ok) {
1310
+ setMessage(payload.error || '请求失败。请确认 skillcaddy 服务仍在运行,然后重试。', true);
1311
+ throw new Error(payload.error || '请求失败');
1312
+ }
1313
+
1314
+ return payload;
1315
+ }
1316
+
1317
+ async function loadVersion() {
1318
+ try {
1319
+ const info = await api('/api/version');
1320
+ if (!info || !info.version) return;
1321
+ elements.versionTag.textContent = `v${info.version}`;
1322
+ elements.versionTag.title = `${info.name}@${info.version}`;
1323
+ } catch {
1324
+ // 版本信息不是关键路径,失败时静默(标签留空,CSS :empty 会隐藏)
1325
+ }
1326
+ }
1327
+
1328
+ // 成功走 role="status",失败走 role="alert":失败此前和成功共用礼貌区域,
1329
+ // 可能被排在队列后面才读到。写入一侧必须清空另一侧,否则会读出陈旧内容。
1330
+ // toast 形态下成功确认读完即撤(按文案长度延时);错误与失败清单需要用户
1331
+ // 处理,常驻到下一条消息覆盖为止。
1332
+ function setMessage(text, isError = false) {
1333
+ const target = isError ? elements.errorMessage : elements.message;
1334
+ const other = isError ? elements.message : elements.errorMessage;
1335
+ other.textContent = '';
1336
+ target.textContent = text;
1337
+ clearTimeout(messageTimer);
1338
+ if (!isError && text) {
1339
+ messageTimer = setTimeout(() => {
1340
+ elements.message.textContent = '';
1341
+ }, Math.min(8000 + text.length * 50, 20000));
1342
+ }
1343
+ }
1344
+
1345
+ // DOM 全量重建会把焦点掉回 <body>:键盘用户每展开一个分组、启用一个 skill,
1346
+ // 都得从页首重新 Tab。渲染前记下焦点控件的稳定标识,渲染后再找回来。
1347
+ function withPreservedFocus(task) {
1348
+ if (isRestoringFocus) {
1349
+ task();
1350
+ return;
1351
+ }
1352
+
1353
+ const focus = captureFocus();
1354
+ isRestoringFocus = true;
1355
+ try {
1356
+ task();
1357
+ } finally {
1358
+ isRestoringFocus = false;
1359
+ restoreFocus(focus);
1360
+ }
1361
+ }
1362
+
1363
+ function captureFocus() {
1364
+ const active = document.activeElement;
1365
+ const focusKey = active?.dataset?.focusKey || '';
1366
+ if (!focusKey) return null;
1367
+
1368
+ const fallbackSelector = active.dataset.focusFallbackSelector || '';
1369
+ const fallbackCandidates = fallbackSelector ? [...document.querySelectorAll(fallbackSelector)] : [];
1370
+ return {
1371
+ focusKey,
1372
+ fallbackKey: active.dataset.focusFallbackKey || '',
1373
+ fallbackSelector,
1374
+ fallbackIndex: fallbackCandidates.indexOf(active)
1375
+ };
1376
+ }
1377
+
1378
+ function restoreFocus(focus) {
1379
+ if (!focus?.focusKey) return;
1380
+
1381
+ const target = document.querySelector(`[data-focus-key="${CSS.escape(focus.focusKey)}"]`);
1382
+
1383
+ if (focusTarget(target)) {
1384
+ return;
1385
+ }
1386
+
1387
+ if (target?.disabled) {
1388
+ const fallback = target.closest('[data-focus-scope]')?.querySelector('button:not(:disabled)');
1389
+ if (focusTarget(fallback)) return;
1390
+ }
1391
+
1392
+ if (focus.fallbackSelector) {
1393
+ const candidates = [...document.querySelectorAll(focus.fallbackSelector)].filter((candidate) => !candidate.disabled);
1394
+ if (candidates.length > 0) {
1395
+ const index = Math.min(Math.max(focus.fallbackIndex, 0), candidates.length - 1);
1396
+ if (focusTarget(candidates[index])) return;
1397
+ }
1398
+ }
1399
+
1400
+ if (focus.fallbackKey) {
1401
+ const fallback = document.querySelector(`[data-focus-key="${CSS.escape(focus.fallbackKey)}"]`);
1402
+ if (focusTarget(fallback)) return;
1403
+ }
1404
+ }
1405
+
1406
+ function focusTarget(target) {
1407
+ if (!target || target.disabled) return false;
1408
+ target.focus();
1409
+ return true;
1410
+ }
1411
+
1412
+ async function withButtonState(button, busyText, task, resolveDisabled = () => false) {
1413
+ const focus = captureFocus();
1414
+ const originalText = button.textContent;
1415
+ button.disabled = true;
1416
+ button.textContent = busyText;
1417
+ try {
1418
+ await task();
1419
+ } finally {
1420
+ button.disabled = resolveDisabled();
1421
+ button.textContent = originalText;
1422
+ restoreFocus(focus);
1423
+ }
1424
+ }
1425
+
1426
+ async function flashButton(button, text) {
1427
+ const originalText = button.textContent;
1428
+ button.textContent = text;
1429
+ await new Promise((resolve) => setTimeout(resolve, 700));
1430
+ button.textContent = originalText;
1431
+ }