mulmoclaude 0.6.2 → 0.6.4

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 (182) hide show
  1. package/README.md +26 -0
  2. package/bin/mulmoclaude.js +11 -1
  3. package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
  4. package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
  5. package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
  6. package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
  7. package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
  8. package/client/assets/index-CyBr8Mkr.css +2 -0
  9. package/client/assets/index-zZIqEbNX.js +5106 -0
  10. package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
  11. package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
  12. package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
  13. package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
  14. package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
  15. package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
  16. package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
  17. package/client/index.html +10 -10
  18. package/package.json +9 -8
  19. package/server/agent/backend/claude-code.ts +34 -0
  20. package/server/agent/backend/fake-echo.ts +370 -0
  21. package/server/agent/backend/index.ts +16 -1
  22. package/server/agent/config.ts +74 -24
  23. package/server/agent/index.ts +104 -80
  24. package/server/agent/mcpFailureMonitor.ts +167 -0
  25. package/server/agent/mcpPreflight.ts +185 -0
  26. package/server/agent/prompt.ts +50 -359
  27. package/server/agent/stdioHttpShim.ts +171 -0
  28. package/server/agent/stream.ts +12 -1
  29. package/server/api/routes/encore.ts +55 -0
  30. package/server/api/routes/files.ts +22 -0
  31. package/server/api/routes/mulmo-script.ts +19 -1
  32. package/server/api/routes/schedulerHandlers.ts +52 -4
  33. package/server/api/routes/sessions.ts +15 -0
  34. package/server/api/routes/skills.ts +263 -0
  35. package/server/build/dispatcher.mjs +299 -0
  36. package/server/encore/INVARIANTS.md +272 -0
  37. package/server/encore/boot.ts +39 -0
  38. package/server/encore/closure.ts +36 -0
  39. package/server/encore/cycle.ts +276 -0
  40. package/server/encore/dispatch.ts +103 -0
  41. package/server/encore/handlers/amend.ts +99 -0
  42. package/server/encore/handlers/appendNote.ts +74 -0
  43. package/server/encore/handlers/defineEncore.ts +42 -0
  44. package/server/encore/handlers/listTickets.ts +107 -0
  45. package/server/encore/handlers/markStepDone.ts +41 -0
  46. package/server/encore/handlers/markTargetSkipped.ts +33 -0
  47. package/server/encore/handlers/query.ts +138 -0
  48. package/server/encore/handlers/recordValues.ts +44 -0
  49. package/server/encore/handlers/resolveNotification.ts +121 -0
  50. package/server/encore/handlers/setup.ts +81 -0
  51. package/server/encore/handlers/shared.ts +137 -0
  52. package/server/encore/handlers/snooze.ts +87 -0
  53. package/server/encore/handlers/startObligationChat.ts +64 -0
  54. package/server/encore/handlers/startSetupChat.ts +50 -0
  55. package/server/encore/lock.ts +61 -0
  56. package/server/encore/notifier.ts +123 -0
  57. package/server/encore/obligation.ts +25 -0
  58. package/server/encore/paths.ts +78 -0
  59. package/server/encore/reconcile.ts +661 -0
  60. package/server/encore/tick.ts +191 -0
  61. package/server/encore/yaml-fm.ts +63 -0
  62. package/server/events/notifications.ts +19 -91
  63. package/server/index.ts +94 -9
  64. package/server/notifier/engine.ts +102 -1
  65. package/server/notifier/macosReminderAdapter.ts +30 -0
  66. package/server/notifier/runtime-api.ts +41 -1
  67. package/server/notifier/types.ts +15 -2
  68. package/server/plugins/runtime.ts +11 -2
  69. package/server/prompts/index.ts +39 -0
  70. package/server/prompts/system/journal-pointer.md +12 -0
  71. package/server/prompts/system/memory-management-atomic.md +33 -0
  72. package/server/prompts/system/memory-management-topic.md +60 -0
  73. package/server/prompts/system/news-concierge.md +24 -0
  74. package/server/prompts/system/sandbox-tools.md +10 -0
  75. package/server/prompts/system/sources-context.md +16 -0
  76. package/server/prompts/system/system.md +91 -0
  77. package/server/system/announceOptionalDeps.ts +57 -0
  78. package/server/system/appVersion.ts +34 -0
  79. package/server/system/config.ts +17 -1
  80. package/server/system/docker.ts +14 -6
  81. package/server/system/env.ts +18 -5
  82. package/server/system/optionalDeps.ts +129 -0
  83. package/server/utils/cli-flags.d.mts +14 -0
  84. package/server/utils/cli-flags.mjs +53 -0
  85. package/server/utils/files/encore-io.ts +111 -0
  86. package/server/utils/time.ts +6 -0
  87. package/server/workspace/helps/business.md +2 -2
  88. package/server/workspace/helps/encore-dsl.md +482 -0
  89. package/server/workspace/helps/index.md +15 -13
  90. package/server/workspace/helps/mulmoscript.md +3 -3
  91. package/server/workspace/helps/sandbox.md +2 -2
  92. package/server/workspace/hooks/dispatcher.ts +7 -5
  93. package/server/workspace/hooks/provision.ts +6 -3
  94. package/server/workspace/paths.ts +13 -4
  95. package/server/workspace/skills/catalog.ts +355 -0
  96. package/server/workspace/skills/external/catalog.ts +283 -0
  97. package/server/workspace/skills/external/clone.ts +129 -0
  98. package/server/workspace/skills/external/id.ts +194 -0
  99. package/server/workspace/skills/external/install.ts +417 -0
  100. package/server/workspace/skills/external/presets.ts +50 -0
  101. package/server/workspace/skills-preset.ts +29 -17
  102. package/server/workspace/workspace.ts +10 -5
  103. package/src/App.vue +37 -8
  104. package/src/components/FileContentRenderer.vue +102 -9
  105. package/src/components/JsonEditor.vue +160 -0
  106. package/src/components/NotificationBell.vue +35 -3
  107. package/src/components/PluginLauncher.vue +20 -41
  108. package/src/components/RightSidebar.vue +19 -0
  109. package/src/components/SettingsMcpTab.vue +58 -11
  110. package/src/components/SettingsModal.vue +22 -1
  111. package/src/components/StackView.vue +10 -1
  112. package/src/components/TodoExplorer.vue +16 -0
  113. package/src/components/todo/TodoKanbanView.vue +34 -6
  114. package/src/composables/useNotifications.ts +21 -1
  115. package/src/config/apiRoutes.ts +0 -6
  116. package/src/config/mcpCatalog.ts +12 -7
  117. package/src/config/mcpTypes.ts +5 -0
  118. package/src/config/roles.ts +52 -15
  119. package/src/config/systemFileDescriptors.ts +12 -0
  120. package/src/lang/de.ts +108 -12
  121. package/src/lang/en.ts +105 -11
  122. package/src/lang/es.ts +106 -11
  123. package/src/lang/fr.ts +106 -11
  124. package/src/lang/ja.ts +104 -11
  125. package/src/lang/ko.ts +105 -11
  126. package/src/lang/pt-BR.ts +106 -11
  127. package/src/lang/zh.ts +103 -11
  128. package/src/main.ts +1 -0
  129. package/src/plugins/_generated/metas.ts +4 -0
  130. package/src/plugins/_generated/registrations.ts +2 -0
  131. package/src/plugins/_generated/server-bindings.ts +5 -0
  132. package/src/plugins/encore/EncoreDashboard.vue +504 -0
  133. package/src/plugins/encore/EncoreRedirect.vue +116 -0
  134. package/src/plugins/encore/View.vue +36 -0
  135. package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
  136. package/src/plugins/encore/defineEncoreMeta.ts +13 -0
  137. package/src/plugins/encore/index.ts +93 -0
  138. package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
  139. package/src/plugins/encore/manageEncoreMeta.ts +36 -0
  140. package/src/plugins/manageSkills/View.vue +832 -30
  141. package/src/plugins/manageSkills/categories.ts +125 -0
  142. package/src/plugins/manageSkills/meta.ts +30 -0
  143. package/src/plugins/markdown/definition.ts +3 -3
  144. package/src/plugins/meta-types.ts +5 -0
  145. package/src/plugins/presentMulmoScript/Preview.vue +3 -3
  146. package/src/plugins/presentMulmoScript/View.vue +157 -33
  147. package/src/plugins/presentMulmoScript/meta.ts +4 -0
  148. package/src/plugins/scheduler/View.vue +45 -9
  149. package/src/plugins/scheduler/calendarDefinition.ts +6 -2
  150. package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
  151. package/src/plugins/skill/View.vue +1 -5
  152. package/src/plugins/spreadsheet/View.vue +3 -3
  153. package/src/plugins/spreadsheet/definition.ts +1 -1
  154. package/src/plugins/textResponse/Preview.vue +14 -1
  155. package/src/plugins/textResponse/View.vue +39 -24
  156. package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
  157. package/src/router/index.ts +11 -0
  158. package/src/router/pageRoutes.ts +1 -0
  159. package/src/types/encore-dsl/at-expression.ts +120 -0
  160. package/src/types/encore-dsl/at-resolver.ts +32 -0
  161. package/src/types/encore-dsl/cadence.ts +289 -0
  162. package/src/types/encore-dsl/schema.ts +288 -0
  163. package/src/types/notification.ts +2 -1
  164. package/src/types/session.ts +6 -0
  165. package/src/types/sse.ts +5 -0
  166. package/src/types/toolCallHistory.ts +7 -0
  167. package/src/utils/agent/eventDispatch.ts +26 -5
  168. package/src/utils/agent/mcpHint.ts +50 -0
  169. package/src/utils/image/htmlSrcAttrs.ts +117 -13
  170. package/src/utils/session/sessionEntries.ts +8 -32
  171. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
  172. package/client/assets/chunk-CernVdwh.js +0 -1
  173. package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
  174. package/client/assets/index-BwrlMMHr.js +0 -5005
  175. package/client/assets/index-CvvNuegU.css +0 -2
  176. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  177. package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
  178. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
  179. package/server/api/routes/notifications.ts +0 -195
  180. package/server/notifier/legacy-adapters.ts +0 -76
  181. package/server/workspace/hooks/dispatcher.mjs +0 -300
  182. package/src/composables/useSelectedResult.ts +0 -49
@@ -5,6 +5,17 @@
5
5
  <div>
6
6
  <h2 class="text-lg font-semibold text-gray-800">{{ t("pluginManageSkills.heading") }}</h2>
7
7
  <p class="text-xs text-gray-400 mt-0.5">{{ t("pluginManageSkills.subheading", { count: skills.length }) }}</p>
8
+ <i18n-t keypath="pluginManageSkills.sectionLegend" tag="p" class="text-xs text-gray-400 mt-0.5">
9
+ <template #system>
10
+ <span class="material-icons !text-sm align-middle leading-none text-gray-500" aria-hidden="true">lock</span>
11
+ </template>
12
+ <template #project>
13
+ <span class="material-icons !text-sm align-middle leading-none text-green-600" aria-hidden="true">folder</span>
14
+ </template>
15
+ <template #user>
16
+ <span class="material-icons !text-sm align-middle leading-none text-blue-500" aria-hidden="true">home</span>
17
+ </template>
18
+ </i18n-t>
8
19
  </div>
9
20
  </div>
10
21
 
@@ -14,34 +25,280 @@
14
25
  </div>
15
26
 
16
27
  <div class="flex-1 min-h-0 flex overflow-hidden">
17
- <!-- Left: skill list -->
28
+ <!-- Left: two collapsible sections — Active (discovered by
29
+ Claude Code, loaded into the prompt) and Catalog (browse /
30
+ ★ star / ▶ run once without bloating the prompt). Aligns
31
+ with the #1335 catalog/active model. -->
18
32
  <div class="w-64 shrink-0 border-r border-gray-100 overflow-y-auto bg-gray-50">
19
- <div
20
- v-for="skill in skills"
21
- :key="skill.name"
22
- :data-testid="`skill-item-${skill.name}`"
23
- class="cursor-pointer px-4 py-3 border-b border-gray-100 text-sm hover:bg-white transition-colors"
24
- :class="selectedName === skill.name ? 'bg-white border-l-2 border-l-blue-500' : ''"
25
- @click="selectedName = skill.name"
26
- >
27
- <div class="font-medium text-gray-800 truncate">{{ skill.name }}</div>
28
- <div class="text-xs text-gray-500 truncate mt-0.5">
29
- {{ skill.description }}
33
+ <!-- ★ Active -->
34
+ <div data-testid="skill-section-active">
35
+ <button
36
+ type="button"
37
+ data-testid="skill-section-toggle-active"
38
+ class="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide hover:bg-gray-100 border-b border-gray-100"
39
+ :aria-expanded="isSectionOpen('active')"
40
+ aria-controls="skill-section-panel-active"
41
+ @click="toggleSection('active')"
42
+ >
43
+ <span class="flex items-center gap-1">
44
+ <span class="material-icons text-base">{{ isSectionOpen("active") ? "expand_more" : "chevron_right" }}</span>
45
+ {{ t("pluginManageSkills.sectionActive") }}
46
+ </span>
47
+ <span data-testid="skill-section-count-active" class="text-gray-400 font-normal normal-case">{{ activeSkills.length }}</span>
48
+ </button>
49
+ <div v-show="isSectionOpen('active')" id="skill-section-panel-active" role="group">
50
+ <div
51
+ v-for="skill in activeSkills"
52
+ :key="skill.name"
53
+ :data-testid="`skill-item-${skill.name}`"
54
+ class="cursor-pointer px-4 py-3 border-b border-gray-100 text-sm hover:bg-white transition-colors focus:outline-none focus:bg-white focus:border-l-2 focus:border-l-blue-400"
55
+ :class="selectedName === skill.name && !selectedCatalog ? 'bg-white border-l-2 border-l-blue-500' : ''"
56
+ role="button"
57
+ tabindex="0"
58
+ :aria-pressed="selectedName === skill.name && !selectedCatalog"
59
+ @click="selectActiveSkill(skill.name)"
60
+ @keydown.enter.prevent="selectActiveSkill(skill.name)"
61
+ @keydown.space.prevent="selectActiveSkill(skill.name)"
62
+ >
63
+ <div class="flex items-center gap-2">
64
+ <div class="flex-1 min-w-0">
65
+ <div class="font-medium text-gray-800 truncate">{{ skill.name }}</div>
66
+ <div class="text-xs text-gray-500 truncate mt-0.5">
67
+ {{ skill.description }}
68
+ </div>
69
+ </div>
70
+ <span class="shrink-0 material-icons text-sm" :class="skillBadge(skill).colour" :title="skillBadge(skill).title" aria-hidden="true">{{
71
+ skillBadge(skill).icon
72
+ }}</span>
73
+ </div>
74
+ </div>
75
+ <i18n-t v-if="activeSkills.length === 0" keypath="pluginManageSkills.emptyWithPath" tag="p" class="p-4 text-sm text-gray-400 italic">
76
+ <template #path>
77
+ <code class="text-[11px]">{{ t("pluginManageSkills.emptySkillPath") }}</code>
78
+ </template>
79
+ </i18n-t>
30
80
  </div>
31
- <div class="text-[10px] text-gray-400 uppercase mt-0.5">
32
- {{ skill.source }}
81
+ </div>
82
+
83
+ <!-- 📚 Catalog: launcher-managed presets. Rows behave like the
84
+ active list — click selects an entry, loading its detail
85
+ into the right pane with ★ Star / ▶ Run once actions.
86
+ Anthropic + Community sub-catalogs land with #1335 PR-C. -->
87
+ <div data-testid="skill-section-catalog" class="border-t border-gray-200">
88
+ <button
89
+ type="button"
90
+ data-testid="skill-section-toggle-catalog"
91
+ class="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide hover:bg-gray-100 border-b border-gray-100"
92
+ :aria-expanded="isSectionOpen('catalog')"
93
+ aria-controls="skill-section-panel-catalog"
94
+ @click="toggleSection('catalog')"
95
+ >
96
+ <span class="flex items-center gap-1">
97
+ <span class="material-icons text-base">{{ isSectionOpen("catalog") ? "expand_more" : "chevron_right" }}</span>
98
+ {{ t("pluginManageSkills.sectionCatalog") }}
99
+ </span>
100
+ <span data-testid="skill-section-count-catalog" class="text-gray-400 font-normal normal-case">{{
101
+ catalogPresets.length + catalogExternal.length
102
+ }}</span>
103
+ </button>
104
+ <div v-show="isSectionOpen('catalog')" id="skill-section-panel-catalog" role="group">
105
+ <div class="px-4 py-2 text-[11px] uppercase tracking-wide text-gray-500 font-semibold" data-testid="skill-catalog-section-heading">
106
+ {{ t("pluginManageSkills.catalogPresetHeading") }}
107
+ </div>
108
+ <div
109
+ v-for="entry in catalogPresets"
110
+ :key="`catalog-preset-${entryKey(entry)}`"
111
+ :data-testid="`skill-catalog-item-${entryKey(entry)}`"
112
+ class="cursor-pointer px-4 py-3 border-b border-gray-100 text-sm hover:bg-white transition-colors focus:outline-none focus:bg-white focus:border-l-2 focus:border-l-blue-400"
113
+ :class="selectedCatalogKey === entryKey(entry) ? 'bg-white border-l-2 border-l-blue-500' : ''"
114
+ role="button"
115
+ tabindex="0"
116
+ :aria-pressed="selectedCatalogKey === entryKey(entry)"
117
+ @click="selectCatalogEntry(entry)"
118
+ @keydown.enter.prevent="selectCatalogEntry(entry)"
119
+ @keydown.space.prevent="selectCatalogEntry(entry)"
120
+ >
121
+ <div class="flex items-center gap-2">
122
+ <div class="flex-1 min-w-0">
123
+ <div class="font-medium text-gray-700 truncate">{{ entry.name }}</div>
124
+ <div class="text-xs text-gray-500 truncate mt-0.5">{{ entry.description }}</div>
125
+ </div>
126
+ <span
127
+ v-if="entry.alreadyActive"
128
+ class="shrink-0 material-icons text-sm text-yellow-500"
129
+ :title="t('pluginManageSkills.catalogStarred')"
130
+ :data-testid="`skill-catalog-starred-indicator-${entryKey(entry)}`"
131
+ aria-hidden="true"
132
+ >star</span
133
+ >
134
+ <span class="shrink-0 material-icons text-sm" :class="presetSourceMeta.colour" :title="presetSourceMeta.title" aria-hidden="true">{{
135
+ presetSourceMeta.icon
136
+ }}</span>
137
+ </div>
138
+ </div>
139
+ <p v-if="catalogPresets.length === 0 && !catalogError" class="px-4 py-3 text-xs text-gray-400 italic" data-testid="skill-catalog-empty">
140
+ {{ t("pluginManageSkills.catalogEmpty") }}
141
+ </p>
142
+ <div v-if="catalogError" class="px-4 py-2 text-xs text-red-600">{{ catalogError }}</div>
143
+
144
+ <!-- External repos (#1383 PR-C2): one collapsible subgroup
145
+ per installed repo. Rows behave exactly like preset
146
+ rows (select → right pane with ★ Star / ▶ Run once). -->
147
+ <div
148
+ v-for="group in externalGroups"
149
+ :key="`catalog-repo-${group.repo.repoId}`"
150
+ :data-testid="`skill-catalog-repo-${group.repo.repoId}`"
151
+ class="border-t border-gray-100"
152
+ >
153
+ <div class="w-full flex items-center hover:bg-gray-100">
154
+ <button
155
+ type="button"
156
+ :data-testid="`skill-catalog-repo-toggle-${group.repo.repoId}`"
157
+ class="flex-1 min-w-0 flex items-center gap-1 px-4 py-2 text-[11px] uppercase tracking-wide text-gray-500 font-semibold"
158
+ :aria-expanded="isRepoOpen(group.repo.repoId)"
159
+ @click="toggleRepo(group.repo.repoId)"
160
+ >
161
+ <span class="material-icons text-sm">{{ isRepoOpen(group.repo.repoId) ? "expand_more" : "chevron_right" }}</span>
162
+ <span class="truncate normal-case text-gray-600">{{ repoLabel(group.repo) }}</span>
163
+ <span class="text-gray-400 font-normal">({{ group.entries.length }})</span>
164
+ </button>
165
+ <button
166
+ type="button"
167
+ class="h-8 w-8 flex items-center justify-center rounded text-gray-400 hover:text-blue-600 disabled:opacity-40"
168
+ :data-testid="`skill-catalog-repo-update-${group.repo.repoId}`"
169
+ :disabled="updatingRepoId === group.repo.repoId"
170
+ :title="t('pluginManageSkills.catalogUpdateRepo')"
171
+ :aria-label="t('pluginManageSkills.catalogUpdateRepo')"
172
+ :aria-busy="updatingRepoId === group.repo.repoId"
173
+ @click="updateRepo(group.repo)"
174
+ >
175
+ <span class="material-icons text-sm" :class="updatingRepoId === group.repo.repoId ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
176
+ </button>
177
+ <button
178
+ type="button"
179
+ class="h-8 w-8 flex items-center justify-center rounded text-gray-400 hover:text-red-600 disabled:opacity-40"
180
+ :data-testid="`skill-catalog-repo-uninstall-${group.repo.repoId}`"
181
+ :disabled="uninstallingRepoId === group.repo.repoId"
182
+ :title="t('pluginManageSkills.catalogUninstallRepo')"
183
+ :aria-label="t('pluginManageSkills.catalogUninstallRepo')"
184
+ :aria-busy="uninstallingRepoId === group.repo.repoId"
185
+ @click="uninstallRepo(group.repo.repoId)"
186
+ >
187
+ <span class="material-icons text-sm" aria-hidden="true">delete_outline</span>
188
+ </button>
189
+ </div>
190
+ <div v-show="isRepoOpen(group.repo.repoId)" role="group">
191
+ <div
192
+ v-for="entry in group.entries"
193
+ :key="`catalog-ext-${entryKey(entry)}`"
194
+ :data-testid="`skill-catalog-item-${entryKey(entry)}`"
195
+ class="cursor-pointer px-4 py-3 border-b border-gray-100 text-sm hover:bg-white transition-colors focus:outline-none focus:bg-white focus:border-l-2 focus:border-l-blue-400"
196
+ :class="selectedCatalogKey === entryKey(entry) ? 'bg-white border-l-2 border-l-blue-500' : ''"
197
+ role="button"
198
+ tabindex="0"
199
+ :aria-pressed="selectedCatalogKey === entryKey(entry)"
200
+ @click="selectCatalogEntry(entry)"
201
+ @keydown.enter.prevent="selectCatalogEntry(entry)"
202
+ @keydown.space.prevent="selectCatalogEntry(entry)"
203
+ >
204
+ <div class="flex items-center gap-2">
205
+ <div class="flex-1 min-w-0">
206
+ <div class="font-medium text-gray-700 truncate">{{ entry.name }}</div>
207
+ <div class="text-xs text-gray-500 truncate mt-0.5">{{ entry.description }}</div>
208
+ </div>
209
+ <span
210
+ v-if="entry.alreadyActive"
211
+ class="shrink-0 material-icons text-sm text-yellow-500"
212
+ :title="t('pluginManageSkills.catalogStarred')"
213
+ :data-testid="`skill-catalog-starred-indicator-${entryKey(entry)}`"
214
+ aria-hidden="true"
215
+ >star</span
216
+ >
217
+ <span class="shrink-0 material-icons text-sm text-gray-400" :title="t('pluginManageSkills.sourceExternalTitle')" aria-hidden="true"
218
+ >cloud</span
219
+ >
220
+ </div>
221
+ </div>
222
+ <p v-if="group.entries.length === 0" class="px-4 py-3 text-xs text-gray-400 italic">
223
+ {{ t("pluginManageSkills.catalogRepoEmpty") }}
224
+ </p>
225
+ </div>
226
+ </div>
227
+
228
+ <button
229
+ type="button"
230
+ data-testid="skill-catalog-add-repo"
231
+ class="w-full flex items-center gap-1 px-4 py-3 text-sm text-blue-600 hover:bg-white border-t border-gray-100"
232
+ @click="openAddRepo"
233
+ >
234
+ <span class="material-icons text-sm" aria-hidden="true">add</span>
235
+ {{ t("pluginManageSkills.catalogAddRepo") }}
236
+ </button>
33
237
  </div>
34
238
  </div>
35
- <i18n-t v-if="skills.length === 0" keypath="pluginManageSkills.emptyWithPath" tag="p" class="p-4 text-sm text-gray-400 italic">
36
- <template #path>
37
- <code class="text-[11px]">{{ t("pluginManageSkills.emptySkillPath") }}</code>
38
- </template>
39
- </i18n-t>
40
239
  </div>
41
240
 
42
241
  <!-- Right: detail pane -->
43
242
  <div class="flex-1 min-w-0 overflow-y-auto">
44
- <div v-if="!selected" class="p-6 text-sm text-gray-400 italic">{{ t("pluginManageSkills.selectHint") }}</div>
243
+ <!-- Catalog (preset) detail. Selecting a row from the
244
+ "Preset catalog" section in the left column routes
245
+ here. Shows description + body + Star / Run once
246
+ actions. (#1335 PR-B2 follow-up — replaces the inline
247
+ buttons and the Preview modal with a single right-pane
248
+ that mirrors the active-skill view.) -->
249
+ <div v-if="selectedCatalog" class="p-6" data-testid="skill-catalog-detail-pane">
250
+ <div class="flex items-start justify-between gap-4 mb-4">
251
+ <div class="min-w-0">
252
+ <div class="flex items-center gap-2 mb-1">
253
+ <span class="material-icons text-sm" :class="presetSourceMeta.colour" :title="presetSourceMeta.title" aria-hidden="true">{{
254
+ presetSourceMeta.icon
255
+ }}</span>
256
+ <h3 class="text-xl font-semibold text-gray-800 truncate">{{ selectedCatalog.name }}</h3>
257
+ </div>
258
+ <p class="text-sm text-gray-600 mt-1">{{ selectedCatalog.description }}</p>
259
+ </div>
260
+ <div class="flex items-center gap-2 shrink-0">
261
+ <button
262
+ v-if="!selectedCatalog.alreadyActive"
263
+ class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-yellow-400 text-yellow-600 hover:bg-yellow-50 disabled:opacity-40"
264
+ :disabled="catalogActioningKey === selectedCatalogKey"
265
+ :title="t('pluginManageSkills.catalogStar')"
266
+ data-testid="skill-catalog-detail-star-btn"
267
+ @click="starCatalogEntry(selectedCatalog)"
268
+ >
269
+ <span class="material-icons text-sm" aria-hidden="true">star_border</span>
270
+ {{ t("pluginManageSkills.catalogStar") }}
271
+ </button>
272
+ <button
273
+ v-else
274
+ class="h-8 px-2.5 flex items-center gap-1 text-sm rounded text-yellow-500 cursor-not-allowed"
275
+ :title="t('pluginManageSkills.catalogStarred')"
276
+ data-testid="skill-catalog-detail-starred"
277
+ disabled
278
+ >
279
+ <span class="material-icons text-sm" aria-hidden="true">star</span>
280
+ {{ t("pluginManageSkills.catalogStarred") }}
281
+ </button>
282
+ <button
283
+ class="h-8 px-2.5 flex items-center gap-1 text-sm rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40"
284
+ :disabled="catalogActioningKey === selectedCatalogKey || !catalogDetail"
285
+ :title="t('pluginManageSkills.catalogRunOnce')"
286
+ data-testid="skill-catalog-detail-run-btn"
287
+ @click="runOnceCatalogEntry(selectedCatalog)"
288
+ >
289
+ <span class="material-icons text-sm" aria-hidden="true">play_arrow</span>
290
+ {{ t("pluginManageSkills.catalogRunOnce") }}
291
+ </button>
292
+ </div>
293
+ </div>
294
+ <div v-if="catalogDetailLoading" class="text-sm text-gray-400 italic">{{ t("pluginManageSkills.loading") }}</div>
295
+ <div v-else-if="catalogError" class="text-sm text-red-600">{{ catalogError }}</div>
296
+ <!-- eslint-disable vue/no-v-html -- markdown sanitized via sanitizeMarkdownHtml; same trust chain as the active-skill body below -->
297
+ <div v-else-if="catalogDetail" class="markdown-content text-gray-700" v-html="catalogRenderedBody"></div>
298
+ <!-- eslint-enable vue/no-v-html -->
299
+ </div>
300
+
301
+ <div v-else-if="!selected" class="p-6 text-sm text-gray-400 italic">{{ t("pluginManageSkills.selectHint") }}</div>
45
302
  <div v-else class="p-6">
46
303
  <div class="flex items-start justify-between gap-4 mb-4">
47
304
  <div class="min-w-0">
@@ -73,7 +330,7 @@
73
330
  </template>
74
331
  <template v-else>
75
332
  <button
76
- v-if="detail && detail.source === 'project'"
333
+ v-if="isSelectedEditable"
77
334
  class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-40"
78
335
  :disabled="detailLoading"
79
336
  data-testid="skill-edit-btn"
@@ -83,7 +340,7 @@
83
340
  {{ t("pluginManageSkills.btnEdit") }}
84
341
  </button>
85
342
  <button
86
- v-if="detail && detail.source === 'project'"
343
+ v-if="isSelectedEditable"
87
344
  class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-red-300 text-red-600 hover:bg-red-50 disabled:opacity-40"
88
345
  :disabled="detailLoading || deleting"
89
346
  data-testid="skill-delete-btn"
@@ -142,22 +399,119 @@
142
399
  </div>
143
400
  </div>
144
401
  </div>
402
+
403
+ <!-- Add-repo modal (#1383 PR-C2). URL (+ optional subpath) or a
404
+ one-click seed suggestion. Backend error kinds (invalid-url /
405
+ invalid-subpath / id-collision / no-skills / 502) surface
406
+ inline. -->
407
+ <div
408
+ v-if="addRepoOpen"
409
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
410
+ data-testid="skill-add-repo-modal"
411
+ @click.self="addRepoOpen = false"
412
+ >
413
+ <div class="bg-white rounded-lg shadow-xl w-full max-w-md p-5">
414
+ <h3 class="text-base font-semibold text-gray-800 mb-3">{{ t("pluginManageSkills.catalogAddRepoTitle") }}</h3>
415
+ <label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageSkills.catalogRepoUrlLabel") }}</label>
416
+ <input
417
+ v-model="addRepoUrl"
418
+ type="text"
419
+ data-testid="skill-add-repo-url"
420
+ class="w-full h-8 px-2 mb-3 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400"
421
+ :placeholder="t('pluginManageSkills.catalogRepoUrlPlaceholder')"
422
+ @keydown.enter="installRepo(addRepoUrl, addRepoSubpath)"
423
+ />
424
+ <label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageSkills.catalogRepoSubpathLabel") }}</label>
425
+ <input
426
+ v-model="addRepoSubpath"
427
+ type="text"
428
+ data-testid="skill-add-repo-subpath"
429
+ class="w-full h-8 px-2 mb-3 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400"
430
+ :placeholder="t('pluginManageSkills.catalogRepoSubpathPlaceholder')"
431
+ @keydown.enter="installRepo(addRepoUrl, addRepoSubpath)"
432
+ />
433
+ <p v-if="addRepoError" class="text-xs text-red-600 mb-3" data-testid="skill-add-repo-error">{{ addRepoError }}</p>
434
+ <div class="flex items-center justify-end gap-2 mb-4">
435
+ <button
436
+ type="button"
437
+ class="h-8 px-2.5 flex items-center text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50"
438
+ @click="addRepoOpen = false"
439
+ >
440
+ {{ t("common.cancel") }}
441
+ </button>
442
+ <button
443
+ type="button"
444
+ data-testid="skill-add-repo-submit"
445
+ class="h-8 px-2.5 flex items-center gap-1 text-sm rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40"
446
+ :disabled="addRepoBusy"
447
+ @click="installRepo(addRepoUrl, addRepoSubpath)"
448
+ >
449
+ {{ addRepoBusy ? t("pluginManageSkills.catalogRepoInstalling") : t("pluginManageSkills.catalogAddRepoSubmit") }}
450
+ </button>
451
+ </div>
452
+ <div v-if="suggestions.length > 0">
453
+ <p class="text-xs font-medium text-gray-600 mb-2">{{ t("pluginManageSkills.catalogAddRepoSuggestions") }}</p>
454
+ <div
455
+ v-for="suggestion in suggestions"
456
+ :key="suggestion.url"
457
+ class="mb-1 rounded border"
458
+ :class="selectedSuggestionUrl === suggestion.url ? 'border-blue-400 bg-blue-50' : 'border-gray-200'"
459
+ >
460
+ <div class="flex items-start">
461
+ <button
462
+ type="button"
463
+ :data-testid="`skill-add-repo-suggestion-${suggestion.url}`"
464
+ class="flex-1 min-w-0 text-left px-3 py-2 text-sm"
465
+ :aria-pressed="selectedSuggestionUrl === suggestion.url"
466
+ @click="selectSuggestion(suggestion)"
467
+ >
468
+ <div class="font-medium text-gray-700">{{ suggestion.displayName }}</div>
469
+ <div class="text-xs text-gray-500" :class="selectedSuggestionUrl === suggestion.url ? 'whitespace-normal break-words' : 'truncate'">
470
+ {{ suggestion.description }}
471
+ </div>
472
+ </button>
473
+ <a
474
+ :href="suggestion.url"
475
+ target="_blank"
476
+ rel="noopener noreferrer"
477
+ :data-testid="`skill-add-repo-suggestion-link-${suggestion.url}`"
478
+ class="h-8 w-8 shrink-0 flex items-center justify-center rounded text-gray-400 hover:text-blue-600"
479
+ :title="t('pluginManageSkills.catalogRepoOpenLink')"
480
+ :aria-label="t('pluginManageSkills.catalogRepoOpenLink')"
481
+ @click.stop
482
+ >
483
+ <span class="material-icons text-sm" aria-hidden="true">open_in_new</span>
484
+ </a>
485
+ </div>
486
+ </div>
487
+ </div>
488
+ </div>
489
+ </div>
145
490
  </div>
146
491
  </template>
147
492
 
148
493
  <script setup lang="ts">
149
- import { computed, onMounted, ref, watch } from "vue";
494
+ import { computed, onMounted, ref, shallowRef, watch } from "vue";
150
495
  import { useI18n } from "vue-i18n";
151
496
  import { marked } from "marked";
152
497
  import type { ToolResultComplete } from "gui-chat-protocol/vue";
153
498
  import type { ManageSkillsData, SkillSummary } from "./index";
154
499
  import { useAppApi } from "../../composables/useAppApi";
155
- import { apiGet, apiPut, apiDelete } from "../../utils/api";
500
+ import { apiGet, apiPost, apiPut, apiDelete } from "../../utils/api";
156
501
  import { handleExternalLinkClick } from "../../utils/dom/externalLink";
157
502
  import { sanitizeMarkdownHtml } from "../../utils/markdown/sanitize";
158
503
  import { pluginEndpoints } from "../api";
159
504
  import { buildRouteUrl } from "../meta-types";
160
505
  import type { SkillsEndpoints } from "./definition";
506
+ import {
507
+ categorizeSkill,
508
+ loadCollapsedSections,
509
+ persistCollapsedSections,
510
+ loadRepoCollapsed,
511
+ persistRepoCollapsed,
512
+ pickInitialSelection,
513
+ type SkillSectionKey,
514
+ } from "./categories";
161
515
 
162
516
  const { t } = useI18n();
163
517
 
@@ -177,7 +531,34 @@ const props = defineProps<{
177
531
  // remove rows without waiting for a fresh tool_result push.
178
532
  // Re-seeded whenever the underlying tool result changes.
179
533
  const skills = ref<SkillSummary[]>(props.selectedResult?.data?.skills ?? []);
180
- const selectedName = ref<string | null>(skills.value[0]?.name ?? null);
534
+
535
+ // Collapsed-section state for the sidebar (active / catalog). Persisted
536
+ // to localStorage so each user's preference survives reloads.
537
+ // shallowRef because we always replace the Set wholesale (toggleSection
538
+ // builds a fresh Set), avoiding the deep-proxy that ref() would create.
539
+ const collapsedSections = shallowRef<Set<SkillSectionKey>>(loadCollapsedSections());
540
+
541
+ // Active skills, alphabetised. Provenance (system / project / user) is
542
+ // shown as a per-row badge via sourceMeta, not as its own collapsible
543
+ // group — the sidebar groups by section, not by provenance.
544
+ const activeSkills = computed(() => [...skills.value].sort((leftSkill, rightSkill) => leftSkill.name.localeCompare(rightSkill.name)));
545
+
546
+ function isSectionOpen(key: SkillSectionKey): boolean {
547
+ return !collapsedSections.value.has(key);
548
+ }
549
+
550
+ function toggleSection(key: SkillSectionKey): void {
551
+ const next = new Set(collapsedSections.value);
552
+ if (next.has(key)) {
553
+ next.delete(key);
554
+ } else {
555
+ next.add(key);
556
+ }
557
+ collapsedSections.value = next;
558
+ persistCollapsedSections(next);
559
+ }
560
+
561
+ const selectedName = ref<string | null>(pickInitialSelection(activeSkills.value, collapsedSections.value));
181
562
  const detail = ref<SkillDetail | null>(null);
182
563
  const detailLoading = ref(false);
183
564
  const detailError = ref<string | null>(null);
@@ -195,23 +576,435 @@ const renderedBody = computed(() => {
195
576
  return sanitizeMarkdownHtml(marked(body) as string);
196
577
  });
197
578
 
579
+ // Edit/Delete follows the backend writer contract (writer.ts rejects
580
+ // only source === "user"), NOT the mc- name heuristic. Under #1335
581
+ // PR-A the launcher syncs presets to data/skills/catalog/preset/ and
582
+ // leaves .claude/skills/ untouched, so a ★-starred mc- preset is a
583
+ // normal project-scope skill — gating it read-only by name would make
584
+ // activation one-way (no un-star / edit from /skills). The mc- =
585
+ // "system" classification survives only as the provenance badge.
586
+ const isSelectedEditable = computed(() => detail.value?.source === "project");
587
+
588
+ const listError = ref<string | null>(null);
589
+
590
+ const endpoints = pluginEndpoints<SkillsEndpoints>("skills");
591
+
592
+ // Catalog state (#1335 PR-B). Loaded on mount + after a successful
593
+ // star so the row updates from "★ Star" → "★ Starred".
594
+ // `catalogActioningKey` (declared below) disables the button
595
+ // mid-request to prevent double-clicks across Star / Run once.
596
+ type CatalogSource = "preset" | "external";
597
+ interface CatalogEntry {
598
+ slug: string;
599
+ name: string;
600
+ description: string;
601
+ source: CatalogSource;
602
+ alreadyActive: boolean;
603
+ // External entries only — identify the source repo + skill folder
604
+ // so star / preview / run-once can address them (slug alone is the
605
+ // derived activeId, not enough to locate the catalog copy).
606
+ repoId?: string;
607
+ skillFolder?: string;
608
+ repoUrl?: string;
609
+ }
610
+ interface CatalogDetail {
611
+ slug: string;
612
+ source: CatalogSource;
613
+ description: string;
614
+ body: string;
615
+ }
616
+ interface ExternalRepo {
617
+ repoId: string;
618
+ url: string;
619
+ subpath?: string;
620
+ sha: string;
621
+ installedAt: string;
622
+ }
623
+ interface ExternalSuggestion {
624
+ url: string;
625
+ subpath?: string;
626
+ displayName: string;
627
+ description: string;
628
+ license?: string;
629
+ }
630
+ const catalogPresets = ref<CatalogEntry[]>([]);
631
+ const catalogExternal = ref<CatalogEntry[]>([]);
632
+ const catalogRepos = ref<ExternalRepo[]>([]);
633
+ const catalogError = ref<string | null>(null);
634
+ // Per-repo collapse set (repoId ∈ set ⇒ collapsed). shallowRef: the
635
+ // Set is replaced wholesale on toggle.
636
+ const repoCollapsed = shallowRef<Set<string>>(loadRepoCollapsed());
637
+ // Add-repo modal state.
638
+ const addRepoOpen = ref(false);
639
+ const addRepoUrl = ref("");
640
+ const addRepoSubpath = ref("");
641
+ const addRepoError = ref<string | null>(null);
642
+ const addRepoBusy = ref(false);
643
+ const suggestions = ref<ExternalSuggestion[]>([]);
644
+ // Which suggestion the user picked: drives the form prefill + the
645
+ // "expanded description" / highlight. Selecting never installs —
646
+ // install stays explicit (Install button / Enter in the URL field).
647
+ const selectedSuggestionUrl = ref<string | null>(null);
648
+ const uninstallingRepoId = ref<string | null>(null);
649
+ const updatingRepoId = ref<string | null>(null);
650
+ // Single in-flight gate covers Star / Run once on the selected
651
+ // entry so a slow request doesn't let the user fire a second
652
+ // action mid-flight.
653
+ const catalogActioningKey = ref<string | null>(null);
654
+ // Right-pane selection for a catalog entry (mutually exclusive
655
+ // with `selectedName` — picking one clears the other).
656
+ const selectedCatalog = ref<CatalogEntry | null>(null);
657
+ const catalogDetail = ref<CatalogDetail | null>(null);
658
+ const catalogDetailLoading = ref(false);
659
+ // `appApi` is also referenced lower down by the existing `runSkill`
660
+ // (slash-command invocation for active skills); hoisting one
661
+ // declaration so the catalog handlers don't need their own lookup.
662
+ const catalogAppApi = useAppApi();
663
+
664
+ const catalogRenderedBody = computed(() => {
665
+ const body = catalogDetail.value?.body;
666
+ if (!body) return "";
667
+ return sanitizeMarkdownHtml(marked(body) as string);
668
+ });
669
+
670
+ // External catalog entries grouped under their repo, in the repo
671
+ // order returned by `/external/repos`. Repos with zero discoverable
672
+ // entries still render (header + empty state) so an install that
673
+ // found nothing is visible rather than silently absent.
674
+ const externalGroups = computed<{ repo: ExternalRepo; entries: CatalogEntry[] }[]>(() =>
675
+ catalogRepos.value.map((repo) => ({
676
+ repo,
677
+ entries: catalogExternal.value
678
+ .filter((entry) => entry.repoId === repo.repoId)
679
+ .sort((leftEntry, rightEntry) => leftEntry.slug.localeCompare(rightEntry.slug)),
680
+ })),
681
+ );
682
+
683
+ function repoLabel(repo: ExternalRepo): string {
684
+ // `https://github.com/owner/repo` → `owner/repo`; fall back to the
685
+ // repoId if the URL is somehow unparseable.
686
+ const match = /github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(repo.url);
687
+ return match ? match[1] : repo.repoId;
688
+ }
689
+
690
+ function isRepoOpen(repoId: string): boolean {
691
+ return !repoCollapsed.value.has(repoId);
692
+ }
693
+
694
+ function toggleRepo(repoId: string): void {
695
+ const next = new Set(repoCollapsed.value);
696
+ if (next.has(repoId)) {
697
+ next.delete(repoId);
698
+ } else {
699
+ next.add(repoId);
700
+ }
701
+ repoCollapsed.value = next;
702
+ persistRepoCollapsed(next);
703
+ }
704
+
705
+ // Body/query shape for star + preview: external entries are keyed by
706
+ // (repoId, skillFolder); presets by slug. Centralised so the two call
707
+ // sites can't drift.
708
+ function catalogActionParams(entry: CatalogEntry): Record<string, string> {
709
+ if (entry.source === "external" && entry.repoId && entry.skillFolder) {
710
+ return { source: "external", repoId: entry.repoId, skillFolder: entry.skillFolder };
711
+ }
712
+ return { source: entry.source, slug: entry.slug };
713
+ }
714
+
715
+ // Stable UI identity. External `slug` is the backend-derived
716
+ // `<owner>-<skillFolder>` activeId — lossy + owner-prefixed, so two
717
+ // external entries can collide (dup Vue keys / testids, wrong row
718
+ // highlighted, shared in-flight lock, stale preview guard passing for
719
+ // the wrong item). `(repoId, skillFolder)` is the unique stable key;
720
+ // presets keep their already-unique slug.
721
+ function entryKey(entry: CatalogEntry): string {
722
+ if (entry.source === "external" && entry.repoId && entry.skillFolder) {
723
+ return `${entry.repoId}/${entry.skillFolder}`;
724
+ }
725
+ return entry.slug;
726
+ }
727
+
728
+ const selectedCatalogKey = computed(() => (selectedCatalog.value ? entryKey(selectedCatalog.value) : null));
729
+
730
+ // Visual key for the provenance badge on every active row + the
731
+ // preset rows. Provenance is derived via categorizeSkill (NOT the raw
732
+ // `source`, which can't express "system") so the badge stays
733
+ // consistent with sectionLegend and the edit gate:
734
+ // - system `mc-` bundled, read-only — launcher-owned
735
+ // - project `<workspace>/.claude/skills/` — this workspace only
736
+ // - user `~/.claude/skills/` — global across workspaces
737
+ // - preset catalog (not yet ★ Starred) — launcher-managed
738
+ // Icons + colours are deliberately monochromatic except for the
739
+ // preset case where we hint "library / shelf" with the inventory
740
+ // glyph. The yellow ★ for "starred" is rendered separately so the
741
+ // scope badge stays semantically about provenance, not state.
742
+ interface SourceMeta {
743
+ icon: string;
744
+ title: string;
745
+ colour: string;
746
+ }
747
+
748
+ function skillBadge(skill: SkillSummary): SourceMeta {
749
+ const provenance = categorizeSkill(skill);
750
+ if (provenance === "system") {
751
+ return { icon: "lock", title: t("pluginManageSkills.sourceSystemTitle"), colour: "text-gray-500" };
752
+ }
753
+ if (provenance === "user") {
754
+ return { icon: "home", title: t("pluginManageSkills.sourceUserTitle"), colour: "text-blue-500" };
755
+ }
756
+ return { icon: "folder", title: t("pluginManageSkills.sourceProjectTitle"), colour: "text-green-600" };
757
+ }
758
+
759
+ const presetSourceMeta = computed<SourceMeta>(() => ({
760
+ icon: "inventory_2",
761
+ title: t("pluginManageSkills.sourcePresetTitle"),
762
+ colour: "text-gray-400",
763
+ }));
764
+
198
765
  // Reset the selection when the tool result is replaced (e.g. the
199
766
  // user opens a newer `manageSkills` invocation from the sidebar).
767
+ // Lives after the catalog refs so source-order use-before-define
768
+ // is satisfied — the closure runs at watch-fire time, not at
769
+ // module-eval time, but the lint rule is structural.
200
770
  watch(
201
771
  () => props.selectedResult?.uuid,
202
772
  () => {
203
773
  skills.value = props.selectedResult?.data?.skills ?? [];
204
- selectedName.value = skills.value[0]?.name ?? null;
774
+ selectedName.value = pickInitialSelection(activeSkills.value, collapsedSections.value);
775
+ selectedCatalog.value = null;
776
+ catalogDetail.value = null;
777
+ catalogDetailLoading.value = false;
778
+ catalogActioningKey.value = null;
779
+ catalogError.value = null;
780
+ addRepoOpen.value = false;
781
+ addRepoError.value = null;
782
+ selectedSuggestionUrl.value = null;
783
+ uninstallingRepoId.value = null;
784
+ updatingRepoId.value = null;
205
785
  },
206
786
  );
207
787
 
208
- const listError = ref<string | null>(null);
788
+ async function loadCatalog(): Promise<void> {
789
+ const response = await apiGet<{ entries: CatalogEntry[] }>(endpoints.catalogList.url);
790
+ if (!response.ok) {
791
+ catalogError.value = t("pluginManageSkills.errCatalogListFailed", { error: response.error });
792
+ return;
793
+ }
794
+ catalogError.value = null;
795
+ if (Array.isArray(response.data.entries)) {
796
+ catalogPresets.value = response.data.entries.filter((entry) => entry.source === "preset");
797
+ catalogExternal.value = response.data.entries.filter((entry) => entry.source === "external");
798
+ }
799
+ }
209
800
 
210
- const endpoints = pluginEndpoints<SkillsEndpoints>("skills");
801
+ async function loadExternalRepos(): Promise<void> {
802
+ const response = await apiGet<{ repos: ExternalRepo[] }>(endpoints.externalReposList.url);
803
+ if (!response.ok) {
804
+ catalogError.value = t("pluginManageSkills.errCatalogRepoListFailed", { error: response.error });
805
+ return;
806
+ }
807
+ if (Array.isArray(response.data.repos)) catalogRepos.value = response.data.repos;
808
+ }
809
+
810
+ async function loadSuggestions(): Promise<void> {
811
+ const response = await apiGet<{ suggestions: ExternalSuggestion[] }>(endpoints.externalSuggestions.url);
812
+ if (response.ok && Array.isArray(response.data.suggestions)) suggestions.value = response.data.suggestions;
813
+ }
814
+
815
+ function openAddRepo(): void {
816
+ addRepoUrl.value = "";
817
+ addRepoSubpath.value = "";
818
+ addRepoError.value = null;
819
+ selectedSuggestionUrl.value = null;
820
+ addRepoOpen.value = true;
821
+ if (suggestions.value.length === 0) void loadSuggestions();
822
+ }
823
+
824
+ // Pick a suggestion → prefill the form so the user can review and
825
+ // then press Install. Deliberately does NOT install (avoids the
826
+ // accidental one-click install footgun).
827
+ function selectSuggestion(suggestion: ExternalSuggestion): void {
828
+ addRepoUrl.value = suggestion.url;
829
+ addRepoSubpath.value = suggestion.subpath ?? "";
830
+ addRepoError.value = null;
831
+ selectedSuggestionUrl.value = suggestion.url;
832
+ }
833
+
834
+ async function installRepo(url: string, subpath?: string): Promise<void> {
835
+ if (addRepoBusy.value) return;
836
+ const trimmedUrl = url.trim();
837
+ if (trimmedUrl.length === 0) {
838
+ addRepoError.value = t("pluginManageSkills.errCatalogRepoInvalidUrl");
839
+ return;
840
+ }
841
+ addRepoBusy.value = true;
842
+ addRepoError.value = null;
843
+ try {
844
+ const trimmedSubpath = subpath?.trim();
845
+ const body: Record<string, string> = { url: trimmedUrl };
846
+ if (trimmedSubpath) body.subpath = trimmedSubpath;
847
+ const response = await apiPost<{ installed: true; repoId: string }>(endpoints.externalReposInstall.url, body);
848
+ if (!response.ok) {
849
+ addRepoError.value = t("pluginManageSkills.errCatalogRepoInstallFailed", { error: response.error });
850
+ return;
851
+ }
852
+ addRepoOpen.value = false;
853
+ await Promise.all([loadExternalRepos(), loadCatalog()]);
854
+ } finally {
855
+ addRepoBusy.value = false;
856
+ }
857
+ }
858
+
859
+ async function uninstallRepo(repoId: string): Promise<void> {
860
+ if (uninstallingRepoId.value !== null) return;
861
+ if (typeof window !== "undefined" && !window.confirm(t("pluginManageSkills.catalogUninstallConfirm"))) return;
862
+ uninstallingRepoId.value = repoId;
863
+ try {
864
+ const response = await apiDelete<{ uninstalled: true }>(buildRouteUrl(endpoints.externalReposRemove, { repoId }));
865
+ if (!response.ok) {
866
+ catalogError.value = t("pluginManageSkills.errCatalogRepoUninstallFailed", { error: response.error });
867
+ return;
868
+ }
869
+ catalogError.value = null;
870
+ if (selectedCatalog.value?.repoId === repoId) {
871
+ selectedCatalog.value = null;
872
+ catalogDetail.value = null;
873
+ }
874
+ // Starred copies survive uninstall (backend-guaranteed, C1) — pull
875
+ // the active list so any starred-from-this-repo rows stay visible.
876
+ await Promise.all([loadExternalRepos(), loadCatalog(), refreshActiveList()]);
877
+ } finally {
878
+ uninstallingRepoId.value = null;
879
+ }
880
+ }
881
+
882
+ // "Update" == re-install with the repo's recorded url/subpath. C1's
883
+ // install path re-fetches upstream HEAD, wipes + re-copies the
884
+ // catalog dir, and rewrites `.source.json` with the new SHA. Starred
885
+ // copies under `.claude/skills/` are untouched (catalog-layer only).
886
+ async function updateRepo(repo: ExternalRepo): Promise<void> {
887
+ if (updatingRepoId.value !== null) return;
888
+ updatingRepoId.value = repo.repoId;
889
+ // try/finally so the in-flight gate always clears even if the
890
+ // request throws — otherwise the button stays disabled forever
891
+ // (same hardening as runOnceCatalogEntry, Codex review #1374).
892
+ try {
893
+ const body: Record<string, string> = { url: repo.url };
894
+ if (repo.subpath) body.subpath = repo.subpath;
895
+ const response = await apiPost<{ installed: true; repoId: string }>(endpoints.externalReposInstall.url, body);
896
+ if (!response.ok) {
897
+ catalogError.value = t("pluginManageSkills.errCatalogRepoInstallFailed", { error: response.error });
898
+ return;
899
+ }
900
+ catalogError.value = null;
901
+ await Promise.all([loadExternalRepos(), loadCatalog()]);
902
+ } finally {
903
+ updatingRepoId.value = null;
904
+ }
905
+ }
906
+
907
+ async function refreshActiveList(): Promise<void> {
908
+ // Mirrors the onMounted fetch so the left-column list reflects the
909
+ // newly-starred skill without waiting for the next manageSkills
910
+ // tool result. Errors here are non-fatal — the catalog state is
911
+ // the source of truth for the "Starred" badge.
912
+ const response = await apiGet<{ skills: SkillSummary[] }>(endpoints.list.url);
913
+ if (response.ok && Array.isArray(response.data.skills)) {
914
+ skills.value = response.data.skills;
915
+ }
916
+ }
917
+
918
+ async function starCatalogEntry(entry: CatalogEntry): Promise<void> {
919
+ if (entry.alreadyActive) return;
920
+ catalogActioningKey.value = entryKey(entry);
921
+ const response = await apiPost<{ starred: true; slug: string }>(endpoints.catalogStar.url, catalogActionParams(entry));
922
+ catalogActioningKey.value = null;
923
+ if (!response.ok) {
924
+ catalogError.value = t("pluginManageSkills.errCatalogStarFailed", { error: response.error });
925
+ return;
926
+ }
927
+ catalogError.value = null;
928
+ // Refresh both lists so the row flips to "Starred" and the new
929
+ // active entry shows up in the left column.
930
+ await Promise.all([loadCatalog(), refreshActiveList()]);
931
+ // Reconcile the right-pane selection with the refreshed list so
932
+ // its `alreadyActive` flag reflects reality without forcing the
933
+ // user to re-click.
934
+ if (selectedCatalog.value && entryKey(selectedCatalog.value) === entryKey(entry)) {
935
+ const pool = entry.source === "external" ? catalogExternal.value : catalogPresets.value;
936
+ const updated = pool.find((candidate) => entryKey(candidate) === entryKey(entry));
937
+ if (updated) selectedCatalog.value = updated;
938
+ }
939
+ }
940
+
941
+ async function fetchCatalogDetail(entry: CatalogEntry): Promise<CatalogDetail | null> {
942
+ const response = await apiGet<{ detail: CatalogDetail }>(endpoints.catalogPreview.url, catalogActionParams(entry));
943
+ if (!response.ok) {
944
+ catalogError.value = t("pluginManageSkills.errCatalogPreviewFailed", { error: response.error });
945
+ return null;
946
+ }
947
+ catalogError.value = null;
948
+ return response.data.detail;
949
+ }
950
+
951
+ function selectActiveSkill(name: string): void {
952
+ // Active and catalog selections are mutually exclusive — picking
953
+ // one clears the other so the right pane has a single source of
954
+ // truth.
955
+ selectedCatalog.value = null;
956
+ catalogDetail.value = null;
957
+ selectedName.value = name;
958
+ }
959
+
960
+ async function selectCatalogEntry(entry: CatalogEntry): Promise<void> {
961
+ selectedName.value = null;
962
+ selectedCatalog.value = entry;
963
+ catalogDetail.value = null;
964
+ catalogDetailLoading.value = true;
965
+ const keyAtRequest = entryKey(entry);
966
+ const fetched = await fetchCatalogDetail(entry);
967
+ // Selection may have changed while the request was in flight —
968
+ // drop the response if so (same race-condition guard the active-
969
+ // skill detail watcher uses). Identity is the (repoId, skillFolder)
970
+ // composite for external entries, not the lossy slug.
971
+ if (!selectedCatalog.value || entryKey(selectedCatalog.value) !== keyAtRequest) return;
972
+ catalogDetailLoading.value = false;
973
+ if (fetched !== null) catalogDetail.value = fetched;
974
+ }
975
+
976
+ async function runOnceCatalogEntry(entry: CatalogEntry): Promise<void> {
977
+ // Use the already-fetched detail when the entry is the current
978
+ // right-pane selection (the common case — user reads body, then
979
+ // clicks Run once). Falls back to a fresh fetch when the click
980
+ // somehow lands without a prior selection (defensive — the right
981
+ // pane is the only place Run once is exposed today).
982
+ //
983
+ // The shared in-flight gate is held for the whole flow so a
984
+ // rapid double-click can't enqueue two `startNewChat` calls
985
+ // and spawn duplicate sessions. (Codex review on PR #1374.)
986
+ catalogActioningKey.value = entryKey(entry);
987
+ try {
988
+ const isSelectedEntry = selectedCatalog.value !== null && entryKey(selectedCatalog.value) === entryKey(entry) && catalogDetail.value !== null;
989
+ const body = isSelectedEntry && catalogDetail.value !== null ? catalogDetail.value.body : (await fetchCatalogDetail(entry))?.body;
990
+ if (!body || !body.trim()) {
991
+ catalogError.value = t("pluginManageSkills.errCatalogRunOnceEmpty");
992
+ return;
993
+ }
994
+ catalogAppApi.startNewChat(body);
995
+ } finally {
996
+ catalogActioningKey.value = null;
997
+ }
998
+ }
211
999
 
212
1000
  // Standalone mode: if no selectedResult was passed, fetch the skill
213
1001
  // list from the API on mount so the view is populated.
214
1002
  onMounted(async () => {
1003
+ // Always load the catalog so the section appears even when the
1004
+ // view was opened from a tool result (which only carries the
1005
+ // active list). External repos load in parallel — failure of one
1006
+ // doesn't block the other (each sets its own inline error).
1007
+ await Promise.all([loadCatalog(), loadExternalRepos()]);
215
1008
  if (props.selectedResult || skills.value.length > 0) return;
216
1009
  const response = await apiGet<{ skills: SkillSummary[] }>(endpoints.list.url);
217
1010
  if (!response.ok) {
@@ -220,7 +1013,7 @@ onMounted(async () => {
220
1013
  }
221
1014
  if (Array.isArray(response.data.skills)) {
222
1015
  skills.value = response.data.skills;
223
- selectedName.value = skills.value[0]?.name ?? null;
1016
+ selectedName.value = pickInitialSelection(activeSkills.value, collapsedSections.value);
224
1017
  }
225
1018
  });
226
1019
 
@@ -332,7 +1125,16 @@ async function deleteSkill(): Promise<void> {
332
1125
  if (idx >= 0) {
333
1126
  skills.value.splice(idx, 1);
334
1127
  }
335
- selectedName.value = skills.value[0]?.name ?? null;
1128
+ selectedName.value = pickInitialSelection(activeSkills.value, collapsedSections.value);
336
1129
  detail.value = null;
1130
+ // Refresh the catalog so a deleted star reverts to ☆ Star.
1131
+ // `alreadyActive` is computed from disk at list time — without
1132
+ // this call the badge + right-pane state would lag until the
1133
+ // next mount. (#1335 PR-B2 follow-up.)
1134
+ await loadCatalog();
1135
+ if (selectedCatalog.value?.slug === name) {
1136
+ const refreshed = catalogPresets.value.find((candidate) => candidate.slug === name);
1137
+ if (refreshed) selectedCatalog.value = refreshed;
1138
+ }
337
1139
  }
338
1140
  </script>