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
@@ -0,0 +1,417 @@
1
+ // Install / uninstall of an external-skill repo into the catalog
2
+ // (#1383 / #1335 PR-C).
3
+ //
4
+ // Pipeline for install:
5
+ // 1. validate URL → derive repoId
6
+ // 2. clone (or refresh) the scratch dir via `cloneOrUpdate`
7
+ // 3. discover SKILL.md files inside the (optionally subpath-
8
+ // scoped) tree
9
+ // 4. copy each discovered skill folder into
10
+ // `<workspace>/data/skills/catalog/external/<repoId>/`
11
+ // 5. write `.source.json` recording url / subpath / ref / sha / time
12
+ //
13
+ // Uninstall is the inverse: blow away the catalog dir for `repoId`
14
+ // and the corresponding scratch clone. Active copies (anything the
15
+ // user already Starred into `.claude/skills/`) are NOT touched —
16
+ // star = fork, so users keep what they activated even after the
17
+ // upstream repo is removed.
18
+
19
+ import { copyFile, lstat, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
20
+ import path from "node:path";
21
+
22
+ import { workspacePath } from "../../workspace.js";
23
+ import { WORKSPACE_DIRS } from "../../paths.js";
24
+ import { parseSkillFrontmatter } from "../parser.js";
25
+ import { log } from "../../../system/logger/index.js";
26
+ import { errorMessage } from "../../../utils/errors.js";
27
+ import { writeFileAtomic } from "../../../utils/files/index.js";
28
+ import { cloneOrUpdate, defaultCacheRoot, type CloneDeps, type CloneResult } from "./clone.js";
29
+ import { canonicalRepoUrl, deriveRepoId, safeRepoId, safeSkillFolder, sanitiseSubpath, urlCacheKey } from "./id.js";
30
+
31
+ const SOURCE_METADATA_FILE = ".source.json";
32
+
33
+ export interface InstallRepoOptions {
34
+ url: string;
35
+ subpath?: string;
36
+ ref?: string;
37
+ }
38
+
39
+ export interface InstallResult {
40
+ repoId: string;
41
+ url: string;
42
+ subpath?: string;
43
+ ref?: string;
44
+ sha: string;
45
+ installedAt: string;
46
+ /** Number of SKILL.md files discovered + copied into the catalog
47
+ * dir. Zero is technically valid (an empty repo) but suspicious;
48
+ * callers may surface it as a warning. */
49
+ skillCount: number;
50
+ }
51
+
52
+ export interface ExternalInstallOptions extends CloneDeps {
53
+ /** Override the workspace root. Default: live `workspacePath`. */
54
+ workspaceRoot?: string;
55
+ }
56
+
57
+ interface DiscoveredSkill {
58
+ /** Folder name as it appears in the catalog dir. `"."` indicates
59
+ * the SKILL.md lives at the repo root (single-skill repo). */
60
+ folder: string;
61
+ /** Absolute source path of the skill dir inside the scratch
62
+ * clone. */
63
+ sourceDir: string;
64
+ }
65
+
66
+ async function isDirectory(absPath: string): Promise<boolean> {
67
+ try {
68
+ return (await stat(absPath)).isDirectory();
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // `lstat`-based checks for the UNTRUSTED scratch-clone tree. A
75
+ // malicious external repo can ship symlinks (`SKILL.md` →
76
+ // `/etc/passwd`, a skill dir → `/`); `stat` follows them, so
77
+ // discovery/copy would read or duplicate host files. These reject
78
+ // symlinks (and anything that isn't a plain dir / plain file). Our
79
+ // own catalog dirs still use the `stat`-based helpers above.
80
+ async function isRealDirectory(absPath: string): Promise<boolean> {
81
+ try {
82
+ return (await lstat(absPath)).isDirectory();
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ async function isRealFile(absPath: string): Promise<boolean> {
89
+ try {
90
+ return (await lstat(absPath)).isFile();
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ async function hasParseableSkill(skillDir: string): Promise<boolean> {
97
+ const skillMd = path.join(skillDir, "SKILL.md");
98
+ if (!(await isRealFile(skillMd))) return false;
99
+ try {
100
+ const raw = await readFile(skillMd, "utf-8");
101
+ return parseSkillFrontmatter(raw) !== null;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ /** Discovery rules:
108
+ * - `subpath` given → glob `<scratch>/<subpath>/&zwj;*&zwj;/SKILL.md`,
109
+ * one level deep.
110
+ * - Else → first try `<scratch>/SKILL.md` (single-skill-at-root).
111
+ * If absent, glob `<scratch>/&zwj;*&zwj;/SKILL.md` (multi-skill
112
+ * without a `subpath` declaration). */
113
+ async function discoverSkills(cacheDir: string, subpath: string | undefined): Promise<DiscoveredSkill[]> {
114
+ if (subpath) {
115
+ const scanRoot = path.join(cacheDir, subpath);
116
+ if (!(await isRealDirectory(scanRoot))) return [];
117
+ return await scanOneLevel(scanRoot);
118
+ }
119
+ if (await hasParseableSkill(cacheDir)) {
120
+ return [{ folder: ".", sourceDir: cacheDir }];
121
+ }
122
+ return await scanOneLevel(cacheDir);
123
+ }
124
+
125
+ async function scanOneLevel(scanRoot: string): Promise<DiscoveredSkill[]> {
126
+ let entries: string[];
127
+ try {
128
+ entries = await readdir(scanRoot);
129
+ } catch {
130
+ return [];
131
+ }
132
+ const out: DiscoveredSkill[] = [];
133
+ for (const entry of entries) {
134
+ // Same folder-name rule the read/star side uses (`safeSkillFolder`)
135
+ // so anything we accept here stays listable + addressable. Skips
136
+ // hidden / `.` / `..` / separator-bearing names too.
137
+ const folder = safeSkillFolder(entry);
138
+ if (folder === null) continue;
139
+ const candidate = path.join(scanRoot, folder);
140
+ if (!(await isRealDirectory(candidate))) continue;
141
+ if (!(await hasParseableSkill(candidate))) continue;
142
+ out.push({ folder, sourceDir: candidate });
143
+ }
144
+ // Stable order for deterministic test output.
145
+ out.sort((left, right) => left.folder.localeCompare(right.folder));
146
+ return out;
147
+ }
148
+
149
+ function catalogExternalRoot(workspaceRoot: string): string {
150
+ return path.join(workspaceRoot, WORKSPACE_DIRS.skillsCatalog, "external");
151
+ }
152
+
153
+ interface ExtRepoPaths {
154
+ repoDir: string;
155
+ metadataPath: string;
156
+ }
157
+
158
+ function pathsForRepo(workspaceRoot: string, repoId: string): ExtRepoPaths {
159
+ const repoDir = path.join(catalogExternalRoot(workspaceRoot), repoId);
160
+ return { repoDir, metadataPath: path.join(repoDir, SOURCE_METADATA_FILE) };
161
+ }
162
+
163
+ async function writeMetadata(metadataPath: string, payload: Omit<InstallResult, "repoId" | "skillCount">): Promise<void> {
164
+ await writeFileAtomic(metadataPath, `${JSON.stringify(payload, null, 2)}\n`);
165
+ }
166
+
167
+ /** Copy the discovered skill folder into the catalog under
168
+ * `<external>/<repoId>/<targetFolder>/`. For single-skill-at-root
169
+ * installs (`folder === "."`), the SKILL.md + siblings are copied
170
+ * directly under the repo dir; nothing nests. */
171
+ async function copyIntoCatalog(skill: DiscoveredSkill, repoDir: string): Promise<void> {
172
+ if (skill.folder === ".") {
173
+ await copyTreeNoSymlinks(skill.sourceDir, repoDir, [".git", SOURCE_METADATA_FILE]);
174
+ return;
175
+ }
176
+ await copyTreeNoSymlinks(skill.sourceDir, path.join(repoDir, skill.folder), [".git", SOURCE_METADATA_FILE]);
177
+ }
178
+
179
+ /** Recursively copy `srcDir` → `destDir`, copying ONLY plain dirs and
180
+ * plain files. `Dirent` predicates use `lstat` semantics, so a
181
+ * symlink entry is neither `isDirectory()` nor `isFile()` and is
182
+ * silently skipped — an external repo can't smuggle a `SKILL.md` →
183
+ * `/etc/passwd` (or a dir symlink) into the catalog / active layer.
184
+ * `node:fs cp` with `recursive` would dereference such links. */
185
+ async function copyTreeNoSymlinks(srcDir: string, destDir: string, skip: readonly string[]): Promise<void> {
186
+ await mkdir(destDir, { recursive: true });
187
+ const entries = await readdir(srcDir, { withFileTypes: true });
188
+ for (const entry of entries) {
189
+ if (skip.includes(entry.name)) continue;
190
+ const src = path.join(srcDir, entry.name);
191
+ const dst = path.join(destDir, entry.name);
192
+ if (entry.isDirectory()) {
193
+ await copyTreeNoSymlinks(src, dst, skip);
194
+ } else if (entry.isFile()) {
195
+ await copyFile(src, dst);
196
+ }
197
+ // Symlinks / sockets / FIFOs intentionally skipped.
198
+ }
199
+ }
200
+
201
+ export type InstallExternalRepoResult =
202
+ | { kind: "installed"; detail: InstallResult }
203
+ | { kind: "invalid-url"; url: string }
204
+ | { kind: "invalid-subpath"; subpath: string }
205
+ | { kind: "no-skills"; repoId: string; sha: string }
206
+ | { kind: "id-collision"; repoId: string; existingUrl: string }
207
+ | { kind: "error"; reason: string };
208
+
209
+ /** When `repoDir` already holds an install, return its recorded
210
+ * canonical URL. `repoId` is a lossy punctuation-normalised slug, so
211
+ * two different repos (`foo/a.b`, `foo/a-b`) can map to the same dir;
212
+ * the canonical URL in `.source.json` is what actually identifies
213
+ * the occupant. */
214
+ async function existingCanonicalUrl(metadataPath: string): Promise<string | null> {
215
+ try {
216
+ const parsed = JSON.parse(await readFile(metadataPath, "utf-8")) as { url?: unknown };
217
+ return typeof parsed.url === "string" ? canonicalRepoUrl(parsed.url) : null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /** Install an external skill repo into the catalog. */
224
+ export async function installExternalRepo(opts: InstallRepoOptions, deps: ExternalInstallOptions = {}): Promise<InstallExternalRepoResult> {
225
+ const repoId = deriveRepoId(opts.url);
226
+ if (!repoId) return { kind: "invalid-url", url: opts.url };
227
+
228
+ // Sanitise the subpath BEFORE it reaches `path.join` / the git
229
+ // sparse-checkout pattern. `path.join` collapses `..`, so an
230
+ // un-validated subpath could escape the scratch cache dir.
231
+ let subpath: string | undefined;
232
+ if (opts.subpath !== undefined) {
233
+ const clean = sanitiseSubpath(opts.subpath);
234
+ if (clean === null) return { kind: "invalid-subpath", subpath: opts.subpath };
235
+ subpath = clean;
236
+ }
237
+
238
+ const workspaceRoot = deps.workspaceRoot ?? workspacePath;
239
+ let clone: CloneResult;
240
+ try {
241
+ clone = await cloneOrUpdate({ url: opts.url, subpath, ref: opts.ref }, deps);
242
+ } catch (err) {
243
+ log.warn("skills-external", "git clone failed", { url: opts.url, error: errorMessage(err) });
244
+ return { kind: "error", reason: errorMessage(err, "git clone failed") };
245
+ }
246
+
247
+ const skills = await discoverSkills(clone.cacheDir, subpath);
248
+
249
+ // Bail BEFORE touching the catalog. Wiping + recreating + writing
250
+ // `.source.json` first meant a zero-skill fetch returned 422 while
251
+ // `GET /external/repos` still listed the repo and the previous
252
+ // catalog contents were already destroyed. Leaving a prior good
253
+ // install intact on a transient empty fetch is the safer outcome.
254
+ if (skills.length === 0) {
255
+ log.warn("skills-external", "install found no skills; catalog left untouched", { repoId, url: opts.url, subpath });
256
+ return { kind: "no-skills", repoId, sha: clone.sha };
257
+ }
258
+
259
+ const { repoDir, metadataPath } = pathsForRepo(workspaceRoot, repoId);
260
+
261
+ // Collision guard: `repoId` is a lossy slug. If this dir is already
262
+ // occupied by a DIFFERENT source repo (canonical URL mismatch),
263
+ // wiping it would silently destroy that repo's catalog + metadata.
264
+ // Refuse instead.
265
+ const occupant = await existingCanonicalUrl(metadataPath);
266
+ const incoming = canonicalRepoUrl(opts.url);
267
+ if (occupant && incoming && occupant !== incoming) {
268
+ log.warn("skills-external", "repoId collision; refusing to overwrite", { repoId, occupant, incoming });
269
+ return { kind: "id-collision", repoId, existingUrl: occupant };
270
+ }
271
+
272
+ // Wipe the previous catalog tree for this repoId so a re-install
273
+ // starts clean (removed skills don't linger). Cache dir is kept
274
+ // because we still need the checked-out tree to copy from.
275
+ await rm(repoDir, { recursive: true, force: true });
276
+ await mkdir(repoDir, { recursive: true });
277
+
278
+ for (const skill of skills) {
279
+ await copyIntoCatalog(skill, repoDir);
280
+ }
281
+
282
+ const installedAt = new Date().toISOString();
283
+ const detail: InstallResult = {
284
+ repoId,
285
+ url: opts.url,
286
+ subpath,
287
+ ref: opts.ref,
288
+ sha: clone.sha,
289
+ installedAt,
290
+ skillCount: skills.length,
291
+ };
292
+ await writeMetadata(metadataPath, {
293
+ url: opts.url,
294
+ subpath,
295
+ ref: opts.ref,
296
+ sha: clone.sha,
297
+ installedAt,
298
+ });
299
+
300
+ log.info("skills-external", "installed repo", { repoId, sha: clone.sha, skillCount: skills.length });
301
+ return { kind: "installed", detail };
302
+ }
303
+
304
+ export type UninstallResult = { kind: "uninstalled"; repoId: string } | { kind: "not-found"; repoId: string } | { kind: "invalid-repo-id"; repoId: string };
305
+
306
+ /** Uninstall a repo: remove `data/skills/catalog/external/<repoId>/`
307
+ * and the matching scratch clone keyed by the recorded URL. Active
308
+ * copies under `.claude/skills/` are left in place. */
309
+ export async function uninstallExternalRepo(repoIdRaw: string, deps: ExternalInstallOptions = {}): Promise<UninstallResult> {
310
+ // `repoIdRaw` is user-controlled (DELETE /external/repos/:repoId).
311
+ // Launder via the `path.basename` round-trip BEFORE composing any
312
+ // path — CodeQL only clears `js/path-injection` taint through that
313
+ // sanitiser, not a bare regex `.test()`.
314
+ const repoId = safeRepoId(repoIdRaw);
315
+ if (repoId === null) return { kind: "invalid-repo-id", repoId: repoIdRaw };
316
+ const workspaceRoot = deps.workspaceRoot ?? workspacePath;
317
+ const { repoDir, metadataPath } = pathsForRepo(workspaceRoot, repoId);
318
+ if (!(await isDirectory(repoDir))) return { kind: "not-found", repoId };
319
+
320
+ // Read URL from metadata BEFORE removing so we can also drop the
321
+ // scratch clone. Missing metadata isn't fatal — uninstall the
322
+ // catalog dir regardless.
323
+ let url: string | null = null;
324
+ try {
325
+ const raw = await readFile(metadataPath, "utf-8");
326
+ const parsed = JSON.parse(raw) as { url?: unknown };
327
+ const { url: parsedUrl } = parsed;
328
+ if (typeof parsedUrl === "string") url = parsedUrl;
329
+ } catch {
330
+ /* missing or corrupt metadata — proceed with catalog cleanup */
331
+ }
332
+
333
+ await rm(repoDir, { recursive: true, force: true });
334
+ if (url) {
335
+ const cacheRoot = deps.cacheRoot ?? defaultCacheRoot();
336
+ const cacheDir = path.join(cacheRoot, urlCacheKey(url));
337
+ await rm(cacheDir, { recursive: true, force: true }).catch((err) => {
338
+ log.warn("skills-external", "scratch clone cleanup failed", { repoId, cacheDir, error: errorMessage(err) });
339
+ });
340
+ }
341
+ log.info("skills-external", "uninstalled repo", { repoId });
342
+ return { kind: "uninstalled", repoId };
343
+ }
344
+
345
+ export interface InstalledRepo {
346
+ repoId: string;
347
+ url: string;
348
+ subpath?: string;
349
+ ref?: string;
350
+ sha: string;
351
+ installedAt: string;
352
+ }
353
+
354
+ interface RepoMetadataShape {
355
+ url: string;
356
+ subpath?: string;
357
+ ref?: string;
358
+ sha: string;
359
+ installedAt: string;
360
+ }
361
+
362
+ function isRepoMetadata(value: unknown): value is RepoMetadataShape {
363
+ if (!value || typeof value !== "object") return false;
364
+ const record = value as Record<string, unknown>;
365
+ if (typeof record.url !== "string") return false;
366
+ if (typeof record.sha !== "string") return false;
367
+ if (typeof record.installedAt !== "string") return false;
368
+ if (record.subpath !== undefined && typeof record.subpath !== "string") return false;
369
+ if (record.ref !== undefined && typeof record.ref !== "string") return false;
370
+ return true;
371
+ }
372
+
373
+ /** Enumerate installed external repos by reading each `.source.json`
374
+ * under `data/skills/catalog/external/`. Repos whose metadata fails
375
+ * shape validation are skipped with a `log.warn`. */
376
+ export async function listInstalledRepos(deps: ExternalInstallOptions = {}): Promise<InstalledRepo[]> {
377
+ const workspaceRoot = deps.workspaceRoot ?? workspacePath;
378
+ const root = catalogExternalRoot(workspaceRoot);
379
+ let entries: string[];
380
+ try {
381
+ entries = await readdir(root);
382
+ } catch {
383
+ return [];
384
+ }
385
+ const out: InstalledRepo[] = [];
386
+ for (const entry of entries) {
387
+ if (entry.startsWith(".")) continue;
388
+ // `entry` is `readdir`-sourced (tainted). Launder through the
389
+ // `path.basename` round-trip before any join.
390
+ const repoId = safeRepoId(entry);
391
+ if (repoId === null) continue;
392
+ const repoDir = path.join(root, repoId);
393
+ if (!(await isDirectory(repoDir))) continue;
394
+ const metadataPath = path.join(repoDir, SOURCE_METADATA_FILE);
395
+ let parsed: unknown;
396
+ try {
397
+ parsed = JSON.parse(await readFile(metadataPath, "utf-8"));
398
+ } catch {
399
+ log.warn("skills-external", "metadata missing or unreadable; skipping repo", { repoId });
400
+ continue;
401
+ }
402
+ if (!isRepoMetadata(parsed)) {
403
+ log.warn("skills-external", "metadata failed shape check; skipping repo", { repoId });
404
+ continue;
405
+ }
406
+ out.push({
407
+ repoId,
408
+ url: parsed.url,
409
+ subpath: parsed.subpath,
410
+ ref: parsed.ref,
411
+ sha: parsed.sha,
412
+ installedAt: parsed.installedAt,
413
+ });
414
+ }
415
+ out.sort((left, right) => left.repoId.localeCompare(right.repoId));
416
+ return out;
417
+ }
@@ -0,0 +1,50 @@
1
+ // Bundled external-skill suggestions surfaced by
2
+ // `GET /api/skills/external/suggestions` (#1383 / #1335 PR-C).
3
+ //
4
+ // These are hand-curated repos we recommend to new users. The list
5
+ // ships in the launcher binary — there's no workspace-side state to
6
+ // migrate when entries change across releases. Users discover them
7
+ // through the "+ Add skill repository" modal (UI lands in PR-C2).
8
+ //
9
+ // Adding an entry here is a deliberate editorial act: each new repo
10
+ // expands what new users see by default. Stay conservative — verify
11
+ // the repo's license is permissive, the skill quality is high, and
12
+ // the repo is actively maintained.
13
+
14
+ export interface ExternalPresetSuggestion {
15
+ url: string;
16
+ /** Optional subpath if the repo bundles multiple skills under a
17
+ * common dir (Anthropic's `skills/<name>/` layout). Omitted for
18
+ * single-skill repos that ship `SKILL.md` at the root. */
19
+ subpath?: string;
20
+ /** User-facing repo name. Free-form; UI may show it as the
21
+ * collapsible section heading. */
22
+ displayName: string;
23
+ description: string;
24
+ /** SPDX-ish license identifier from the repo's LICENSE file.
25
+ * Display-only; not enforced. */
26
+ license?: string;
27
+ }
28
+
29
+ export const EXTERNAL_PRESETS: readonly ExternalPresetSuggestion[] = [
30
+ {
31
+ url: "https://github.com/anthropics/skills",
32
+ subpath: "skills",
33
+ displayName: "Anthropic skills",
34
+ description: "Anthropic's official skill collection — PDF / Excel / DOCX builders, MCP server scaffolder, and more.",
35
+ license: "MIT",
36
+ },
37
+ {
38
+ // Engineering-workflow skills that also pay off inside
39
+ // MulmoClaude's task agent: brainstorming, writing/executing
40
+ // plans, systematic-debugging, code review. Layout is one-level
41
+ // (`skills/<name>/SKILL.md`), so the existing one-level
42
+ // discovery handles it without a scanner change. Verified
43
+ // 2026-05: MIT, actively maintained, high-quality skills.
44
+ url: "https://github.com/obra/superpowers",
45
+ subpath: "skills",
46
+ displayName: "Superpowers",
47
+ description: "Battle-tested workflow skills — brainstorming, planning, systematic debugging, TDD, and code review.",
48
+ license: "MIT",
49
+ },
50
+ ] as const;
@@ -1,15 +1,21 @@
1
- // Preset skills bundled with mulmoclaude (#1210). Sibling to
2
- // `helps/`: this file owns the boot-time copy from
3
- // `server/workspace/skills-preset/<slug>/SKILL.md` (shipped with the
4
- // launcher tarball) into `<workspaceRoot>/.claude/skills/<slug>/SKILL.md`,
5
- // where Claude Code's slash-command resolver picks them up.
1
+ // Preset skills bundled with mulmoclaude.
6
2
  //
7
- // The launcher overwrites preset entries unconditionally on every
8
- // boot — preset skills are "factory defaults", not user state. The
9
- // `mc-` slug prefix is the namespace boundary: anything under
10
- // `mc-*` belongs to the launcher and may be added / overwritten /
11
- // removed across releases. Anything WITHOUT the `mc-` prefix is
12
- // user-owned and never touched.
3
+ // History: introduced in #1210 to ship launcher-managed "factory"
4
+ // skills like `mc-library`. Originally synced straight into
5
+ // `<workspaceRoot>/.claude/skills/<slug>/`, which made every preset
6
+ // auto-active and inflated the Claude system prompt as new presets
7
+ // landed.
8
+ //
9
+ // #1335 PR-A flipped the destination to the catalog
10
+ // (`<workspaceRoot>/data/skills/catalog/preset/<slug>/`). Catalog
11
+ // entries are visible to UI / tooling but NOT discovered by Claude
12
+ // Code's slash-command resolver — they don't enter the system
13
+ // prompt unless the user (or a later UI in PR-B) explicitly copies
14
+ // one into `.claude/skills/`.
15
+ //
16
+ // The launcher overwrites catalog entries unconditionally on every
17
+ // boot — they're factory defaults, not user state. Anything in
18
+ // `.claude/skills/` (active layer) is left untouched.
13
19
  //
14
20
  // `syncPresetSkills(...)` is exported as a pure-ish helper (takes
15
21
  // paths + a logger sink, returns a summary) so tests can drive it
@@ -25,7 +31,10 @@ const SKILL_FILENAME = "SKILL.md";
25
31
  export interface SyncPresetSkillsOptions {
26
32
  /** Source directory: `<launcher>/server/workspace/skills-preset/`. */
27
33
  sourceDir: string;
28
- /** Destination directory: `<workspaceRoot>/.claude/skills/`. */
34
+ /** Destination directory:
35
+ * `<workspaceRoot>/data/skills/catalog/preset/`. The catalog
36
+ * half of the catalog-vs-active split — entries here are visible
37
+ * to UI but NOT to Claude Code's prompt-time skill resolver. */
29
38
  destDir: string;
30
39
  /** Logger callbacks — kept injectable so tests don't need to
31
40
  * spin up the structured logger. The boot-side wrapper threads
@@ -142,11 +151,14 @@ function removeRetiredPresets(destDir: string, synced: ReadonlySet<string>, opts
142
151
  }
143
152
  }
144
153
 
145
- /** Copy every preset slug from `sourceDir` into `destDir`, then
146
- * remove any `mc-*` entries in `destDir` that no longer have a
147
- * source. Slugs without the `mc-` prefix are skipped (with a warn)
148
- * on the source side and left untouched on the dest side — that's
149
- * how user-authored skills coexist with launcher-managed presets. */
154
+ /** Copy every preset slug from `sourceDir` into `destDir` (the
155
+ * preset slot under the catalog root), then remove any `mc-*`
156
+ * entries in `destDir` that no longer have a source. The catalog
157
+ * preset subdir is fully launcher-owned, so the `mc-*` prefix
158
+ * check at destination is defence-in-depth: a stray non-preset
159
+ * slug landing in `catalog/preset/` is unexpected, and we'd
160
+ * rather skip it than silently delete a directory we don't
161
+ * recognise. */
150
162
  export function syncPresetSkills(opts: SyncPresetSkillsOptions): SyncPresetSkillsResult {
151
163
  const result: SyncPresetSkillsResult = { copied: [], removed: [], skipped: [] };
152
164
  if (!existsSync(opts.sourceDir)) {
@@ -41,13 +41,18 @@ export function initWorkspace(): string {
41
41
  copyFileSync(path.join(TEMPLATES_DIR, file), path.join(WORKSPACE_PATHS.helps, file));
42
42
  }
43
43
 
44
- // Sync preset skills (#1210) from server/workspace/skills-preset/
45
- // into <workspaceRoot>/.claude/skills/. Only `mc-*` slugs are
46
- // touched user-authored skills coexist untouched. Retired
47
- // presets (no longer in source) are removed from the workspace.
44
+ // Sync preset skills from `server/workspace/skills-preset/` into
45
+ // the catalog (#1335 PR-A). Catalog entries are visible to UI /
46
+ // tooling but NOT in `.claude/skills/`, so they don't enter
47
+ // Claude Code's system prompt by default. Activation (catalog
48
+ // `.claude/skills/`) lands with the UI in #1335 PR-B; until then
49
+ // the catalog is reachable by file path only. The dest directory
50
+ // is created here because it's outside `EAGER_WORKSPACE_DIRS`
51
+ // (it lives several levels deep and is preset-specific).
52
+ mkdirSync(WORKSPACE_PATHS.skillsCatalogPreset, { recursive: true });
48
53
  syncPresetSkills({
49
54
  sourceDir: SKILLS_PRESET_SOURCE_DIR,
50
- destDir: WORKSPACE_PATHS.claudeSkills,
55
+ destDir: WORKSPACE_PATHS.skillsCatalogPreset,
51
56
  onInfo: (message, data) => log.info("skills-preset", message, data),
52
57
  onWarn: (message, data) => log.warn("skills-preset", message, data),
53
58
  });
package/src/App.vue CHANGED
@@ -158,8 +158,20 @@
158
158
  <div v-else-if="selectedResult" class="h-full overflow-auto p-6">
159
159
  <pre class="text-sm text-gray-700 whitespace-pre-wrap">{{ JSON.stringify(selectedResult, null, 2) }}</pre>
160
160
  </div>
161
- <div v-else class="flex items-center justify-center h-full text-gray-600">
162
- <p>{{ t("app.startConversation") }}</p>
161
+ <div v-else class="flex flex-col items-center justify-center h-full px-6 text-center">
162
+ <span class="material-icons text-5xl text-gray-400 mb-2" aria-hidden="true">{{ sessionRoleIcon }}</span>
163
+ <p class="text-lg font-medium text-gray-700 mb-4">{{ sessionRoleName }}</p>
164
+ <div v-if="sessionRoleQueries.length > 0" class="flex flex-wrap gap-2 justify-center max-w-xl">
165
+ <button
166
+ v-for="(query, queryIdx) in sessionRoleQueries"
167
+ :key="`${queryIdx}-${query}`"
168
+ class="text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-full px-4 py-2 border border-gray-300 transition-colors"
169
+ @click="sendMessage(query)"
170
+ >
171
+ {{ query }}
172
+ </button>
173
+ </div>
174
+ <p v-else class="text-sm text-gray-500">{{ t("app.startConversation") }}</p>
163
175
  </div>
164
176
  </template>
165
177
  <StackView
@@ -223,6 +235,19 @@
223
235
  Debug plugin is not loaded. Make sure @mulmoclaude/debug-plugin is built and registered as a preset.
224
236
  </div>
225
237
  <!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
238
+ <!-- Encore page. The View branches on `?pendingId`:
239
+ - With pendingId: chat-on-mount redirect (transient,
240
+ ~300ms) for notification clicks — the tick publishes
241
+ `/encore?pendingId=<uuid>` URLs and the View
242
+ dispatches resolveNotification then full-navigates
243
+ to /chat/<chatId>.
244
+ - Without pendingId: read-only dashboard of active
245
+ obligations and their cycle history, reachable from
246
+ the top-bar launcher. -->
247
+ <component :is="encoreViewComponent" v-else-if="currentPage === 'encore' && encoreViewComponent" />
248
+ <!-- eslint-disable @intlify/vue-i18n/no-raw-text -- encore chat-on-mount page is a transient redirect, not a user-facing surface. -->
249
+ <div v-else-if="currentPage === 'encore'" class="h-full flex items-center justify-center text-sm text-gray-500">Encore is not loaded.</div>
250
+ <!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
226
251
  </div>
227
252
 
228
253
  <!-- Bottom bar (Stack chat only — plugin views have no
@@ -328,7 +353,6 @@ import { useGlobalImageErrorRepair } from "./composables/useImageErrorRepair";
328
353
  import { useMergedSessions } from "./composables/useMergedSessions";
329
354
  import { useLayoutMode } from "./composables/useLayoutMode";
330
355
  import { useSidePanelVisible } from "./composables/useSidePanelVisible";
331
- import { useSelectedResult } from "./composables/useSelectedResult";
332
356
  import { useMcpTools } from "./composables/useMcpTools";
333
357
  import { useRoles } from "./composables/useRoles";
334
358
  import { useCurrentRole } from "./composables/useCurrentRole";
@@ -443,10 +467,11 @@ const { geminiAvailable, sandboxEnabled, cpuLoadRatio, fetchHealth } = useHealth
443
467
  const { activeSession, toolResults, sidebarResults, isRunning, activeSessionRunning, statusMessage, toolCallHistory, activeSessionCount, unreadCount } =
444
468
  useSessionDerived({ sessionMap, currentSessionId, sessions });
445
469
 
446
- const { selectedResultUuid } = useSelectedResult({
447
- activeSession,
448
- sessionMap,
449
- currentSessionId,
470
+ const selectedResultUuid = computed<string | null>({
471
+ get: () => activeSession.value?.selectedResultUuid ?? null,
472
+ set: (val) => {
473
+ if (activeSession.value) activeSession.value.selectedResultUuid = val;
474
+ },
450
475
  });
451
476
 
452
477
  // Display name and icon of the role the active session was created
@@ -661,6 +686,11 @@ const selectedResult = computed(() => toolResults.value.find((result) => result.
661
686
  // the /debug branch in the template lights up without a refresh.
662
687
  const debugViewComponent = computed(() => getPlugin("manageDebug")?.viewComponent ?? null);
663
688
 
689
+ // Encore plugin View — built-in plugin under src/plugins/encore/.
690
+ // Mounted at /encore — the chat-on-mount page after a notification-bell click
691
+ // (View.vue dispatches resolveNotification on mount and redirects).
692
+ const encoreViewComponent = computed(() => getPlugin(TOOL_NAMES.manageEncore)?.viewComponent ?? null);
693
+
664
694
  // Google Maps API key from `AppSettings.googleMapsApiKey`. Fetched
665
695
  // once on mount and refreshed whenever Settings reports a save.
666
696
  //
@@ -855,7 +885,6 @@ async function loadSession(sessionId: string) {
855
885
  id: sessionId,
856
886
  entries: response.data,
857
887
  defaultRoleId: roles.value[0]?.id ?? "",
858
- urlResult: typeof route.query.result === "string" ? route.query.result : null,
859
888
  serverSummary: sessions.value.find((summary) => summary.id === sessionId),
860
889
  nowIso: new Date().toISOString(),
861
890
  });