mulmoclaude 0.6.2 → 0.6.3

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 (94) hide show
  1. package/README.md +26 -0
  2. package/bin/mulmoclaude.js +11 -1
  3. package/client/assets/chunk-D8eiyYIV-CW0rPbG2.js +1 -0
  4. package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-BjwfzAN8.js} +1 -1
  5. package/client/assets/index-Bp1owZ-i.js +5101 -0
  6. package/client/assets/index-c63H1pnd.css +2 -0
  7. package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DudYPW7R.js} +1 -1
  8. package/client/assets/material-symbols-outlined-C0dZ3SlO.woff2 +0 -0
  9. package/client/assets/runtime-protocol-vue-BUk5WXSy.js +1 -0
  10. package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
  11. package/client/assets/{vue-C8UuIO9J.js → vue-Kqzpl9Vx.js} +1 -1
  12. package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
  13. package/client/index.html +9 -11
  14. package/package.json +5 -4
  15. package/server/agent/backend/claude-code.ts +34 -0
  16. package/server/agent/backend/fake-echo.ts +370 -0
  17. package/server/agent/backend/index.ts +16 -1
  18. package/server/agent/config.ts +8 -1
  19. package/server/agent/mcpFailureMonitor.ts +167 -0
  20. package/server/agent/mcpPreflight.ts +185 -0
  21. package/server/agent/stream.ts +12 -1
  22. package/server/api/routes/mulmo-script.ts +19 -1
  23. package/server/api/routes/schedulerHandlers.ts +52 -4
  24. package/server/api/routes/sessions.ts +15 -0
  25. package/server/api/routes/skills.ts +263 -0
  26. package/server/events/notifications.ts +19 -91
  27. package/server/index.ts +87 -9
  28. package/server/notifier/macosReminderAdapter.ts +30 -0
  29. package/server/system/announceOptionalDeps.ts +50 -0
  30. package/server/system/config.ts +8 -1
  31. package/server/system/docker.ts +14 -6
  32. package/server/system/env.ts +18 -5
  33. package/server/system/optionalDeps.ts +129 -0
  34. package/server/utils/cli-flags.d.mts +14 -0
  35. package/server/utils/cli-flags.mjs +53 -0
  36. package/server/utils/time.ts +6 -0
  37. package/server/workspace/helps/business.md +2 -2
  38. package/server/workspace/helps/mulmoscript.md +3 -3
  39. package/server/workspace/helps/sandbox.md +2 -2
  40. package/server/workspace/hooks/dispatcher.mjs +1 -1
  41. package/server/workspace/paths.ts +13 -4
  42. package/server/workspace/skills/catalog.ts +355 -0
  43. package/server/workspace/skills/external/catalog.ts +283 -0
  44. package/server/workspace/skills/external/clone.ts +129 -0
  45. package/server/workspace/skills/external/id.ts +194 -0
  46. package/server/workspace/skills/external/install.ts +417 -0
  47. package/server/workspace/skills/external/presets.ts +50 -0
  48. package/server/workspace/skills-preset.ts +29 -17
  49. package/server/workspace/workspace.ts +10 -5
  50. package/src/App.vue +19 -8
  51. package/src/components/RightSidebar.vue +19 -0
  52. package/src/components/StackView.vue +10 -1
  53. package/src/config/apiRoutes.ts +0 -6
  54. package/src/config/roles.ts +2 -0
  55. package/src/lang/de.ts +50 -1
  56. package/src/lang/en.ts +49 -1
  57. package/src/lang/es.ts +49 -1
  58. package/src/lang/fr.ts +49 -1
  59. package/src/lang/ja.ts +49 -1
  60. package/src/lang/ko.ts +49 -1
  61. package/src/lang/pt-BR.ts +49 -1
  62. package/src/lang/zh.ts +49 -1
  63. package/src/plugins/manageSkills/View.vue +795 -30
  64. package/src/plugins/manageSkills/categories.ts +125 -0
  65. package/src/plugins/manageSkills/meta.ts +30 -0
  66. package/src/plugins/markdown/definition.ts +3 -3
  67. package/src/plugins/meta-types.ts +5 -0
  68. package/src/plugins/presentMulmoScript/Preview.vue +3 -3
  69. package/src/plugins/presentMulmoScript/View.vue +157 -33
  70. package/src/plugins/presentMulmoScript/meta.ts +4 -0
  71. package/src/plugins/scheduler/View.vue +45 -9
  72. package/src/plugins/scheduler/calendarDefinition.ts +6 -2
  73. package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
  74. package/src/plugins/spreadsheet/View.vue +3 -3
  75. package/src/types/notification.ts +1 -1
  76. package/src/types/session.ts +6 -0
  77. package/src/types/sse.ts +5 -0
  78. package/src/types/toolCallHistory.ts +7 -0
  79. package/src/utils/agent/eventDispatch.ts +26 -5
  80. package/src/utils/agent/mcpHint.ts +50 -0
  81. package/src/utils/session/sessionEntries.ts +8 -32
  82. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
  83. package/client/assets/chunk-CernVdwh.js +0 -1
  84. package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
  85. package/client/assets/index-BwrlMMHr.js +0 -5005
  86. package/client/assets/index-CvvNuegU.css +0 -2
  87. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  88. package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
  89. package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
  90. package/server/api/routes/notifications.ts +0 -195
  91. package/server/notifier/legacy-adapters.ts +0 -76
  92. package/src/composables/useSelectedResult.ts +0 -49
  93. /package/client/assets/{purify.es-Fx1Nqyry-Dwtk-9WZ.js → purify.es-Fx1Nqyry-B3aL7Uvj.js} +0 -0
  94. /package/client/assets/{typeof-DBp4T-Ny-CSr8wx1e.js → typeof-DBp4T-Ny-Bef7RiR_.js} +0 -0
@@ -0,0 +1,355 @@
1
+ // Skill catalog reader + star (copy-to-active) helper. The other
2
+ // half of the catalog/active split established by #1335 PR-A — the
3
+ // preset-sync writer in `server/workspace/skills-preset.ts`
4
+ // populates `data/skills/catalog/preset/`, and this module is what
5
+ // the UI reads from + writes through when the user ★ Stars an entry
6
+ // to bring it into `.claude/skills/`.
7
+ //
8
+ // Why a separate module from `discovery.ts`: catalog entries are
9
+ // not yet in Claude Code's discovery scope (that's the whole point
10
+ // — they're not in `.claude/skills/`). Treating them as a different
11
+ // shape (CatalogEntry vs Skill) keeps the type system honest about
12
+ // which entries are prompt-active. The two converge once an entry
13
+ // is starred: it gets copied into `.claude/skills/<slug>/`, after
14
+ // which `discoverSkills()` picks it up as a normal project-scope
15
+ // skill on the next listing.
16
+
17
+ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises";
18
+ import path from "node:path";
19
+ import { workspacePath } from "../workspace.js";
20
+ // WORKSPACE_DIRS — relative segments (e.g. "data/skills/catalog/preset").
21
+ // We deliberately do NOT use WORKSPACE_PATHS here: those are absolute
22
+ // paths rooted at the live `workspacePath`, so joining one with a
23
+ // caller-supplied `workspaceRoot` would silently discard `workspaceRoot`
24
+ // (Node `path.join` drops everything before an absolute argument).
25
+ import { WORKSPACE_DIRS } from "../paths.js";
26
+ import { parseSkillFrontmatter } from "./parser.js";
27
+ import { log } from "../../system/logger/index.js";
28
+ import {
29
+ listExternalCatalogEntries,
30
+ readExternalCatalogDetail,
31
+ starExternalCatalogEntry,
32
+ type ExternalCatalogDetailResult,
33
+ type ExternalStarResult,
34
+ } from "./external/catalog.js";
35
+
36
+ // Catalog sources. PR-B shipped `preset` (the `mc-*` skills bundled
37
+ // with the launcher). PR-C adds `external` — skills installed from
38
+ // arbitrary GitHub repos (Anthropic's `skills/` collection ships as
39
+ // the seed). Both sources expose the same `CatalogEntry` shape; the
40
+ // star/preview endpoints branch on `source` to route the request to
41
+ // the matching backing module.
42
+ export type CatalogSource = "preset" | "external";
43
+
44
+ export const CATALOG_SOURCES: readonly CatalogSource[] = ["preset", "external"] as const;
45
+
46
+ export function isCatalogSource(value: unknown): value is CatalogSource {
47
+ return typeof value === "string" && (CATALOG_SOURCES as readonly string[]).includes(value);
48
+ }
49
+
50
+ export interface CatalogEntry {
51
+ slug: string;
52
+ /** The slug doubles as the displayed name today — frontmatter has
53
+ * no separate `name` field. */
54
+ name: string;
55
+ description: string;
56
+ source: CatalogSource;
57
+ /** `<workspace>/.claude/skills/<slug>/` exists. UI uses this to
58
+ * render "★ Starred" instead of "★ Star" and to disable the
59
+ * star button on already-active entries. */
60
+ alreadyActive: boolean;
61
+ /** External entries only: id of the source repo (also the directory
62
+ * name under `data/skills/catalog/external/`). Needed so the UI
63
+ * can group entries by repo and pass `(repoId, skillFolder)` back
64
+ * to the star / preview endpoints. */
65
+ repoId?: string;
66
+ /** External entries only: subdirectory name under
67
+ * `<repoDir>/<skillFolder>/` containing the SKILL.md. `"."`
68
+ * indicates a single-skill-at-root repo (the SKILL.md is directly
69
+ * under the repo dir). */
70
+ skillFolder?: string;
71
+ /** External entries only: source repo URL, surfaced for display. */
72
+ repoUrl?: string;
73
+ }
74
+
75
+ // Maps catalog source → on-disk root for the slug-keyed scan path.
76
+ // External entries live under nested `<external>/<repoId>/<folder>/`
77
+ // and aren't slug-keyed, so they take a different code path entirely
78
+ // (`scanExternalEntries` → delegates to `external/catalog.ts`). The
79
+ // preset branch is kept here for symmetry.
80
+ function catalogDirForSource(source: "preset", workspaceRoot: string): string {
81
+ if (source === "preset") {
82
+ return path.join(workspaceRoot, WORKSPACE_DIRS.skillsCatalogPreset);
83
+ }
84
+ const exhaustive: never = source;
85
+ throw new Error(`unknown catalog source: ${exhaustive as string}`);
86
+ }
87
+
88
+ function activeDir(workspaceRoot: string): string {
89
+ return path.join(workspaceRoot, WORKSPACE_DIRS.claudeSkills);
90
+ }
91
+
92
+ export interface CatalogOptions {
93
+ /** Override the workspace root. Default: live `workspacePath`
94
+ * (`~/mulmoclaude`). Tests point this at a `mkdtempSync` tree so
95
+ * they don't touch the user's real home dir. */
96
+ workspaceRoot?: string;
97
+ }
98
+
99
+ async function isDirectory(absPath: string): Promise<boolean> {
100
+ try {
101
+ const info = await stat(absPath);
102
+ return info.isDirectory();
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ async function readCatalogEntry(slugDir: string, safeName: string, source: "preset", workspaceRoot: string): Promise<CatalogEntry | null> {
109
+ // `slugDir` was built from a `safeSlugName`-laundered name, so
110
+ // joining a fixed `"SKILL.md"` keeps the path inside the catalog
111
+ // tree and stays clear of CodeQL's path-injection trace.
112
+ const skillMdPath = path.join(slugDir, "SKILL.md");
113
+ let raw: string;
114
+ try {
115
+ raw = await readFile(skillMdPath, "utf-8");
116
+ } catch {
117
+ return null;
118
+ }
119
+ const parsed = parseSkillFrontmatter(raw);
120
+ if (!parsed) return null;
121
+ const activeSlugDir = joinUnderRoot(activeDir(workspaceRoot), safeName);
122
+ const alreadyActive = await isDirectory(activeSlugDir);
123
+ return { slug: safeName, name: safeName, description: parsed.description, source, alreadyActive };
124
+ }
125
+
126
+ async function scanCatalogSource(source: "preset", workspaceRoot: string): Promise<CatalogEntry[]> {
127
+ const dir = catalogDirForSource(source, workspaceRoot);
128
+ let entries: string[];
129
+ try {
130
+ entries = await readdir(dir);
131
+ } catch {
132
+ // ENOENT is normal — workspace may be freshly created and the
133
+ // catalog dir hasn't been populated yet (the preset sync runs
134
+ // first, but defensive). Return [].
135
+ return [];
136
+ }
137
+ const results: CatalogEntry[] = [];
138
+ for (const slug of entries) {
139
+ if (slug.startsWith(".")) continue;
140
+ // Slugs come from `readdir`, which CodeQL flags as tainted even
141
+ // though the directory is launcher-managed. `safeSlugName`
142
+ // applies the slug whitelist + a `path.basename` round-trip —
143
+ // CodeQL's recognised path-injection sanitiser. A catalog entry
144
+ // with an unexpected name is skipped rather than crashing the
145
+ // listing.
146
+ const safeName = safeSlugName(slug);
147
+ if (safeName === null) continue;
148
+ const slugDir = joinUnderRoot(dir, safeName);
149
+ if (!(await isDirectory(slugDir))) continue;
150
+ const entry = await readCatalogEntry(slugDir, safeName, source, workspaceRoot);
151
+ if (entry) results.push(entry);
152
+ }
153
+ results.sort((left, right) => left.slug.localeCompare(right.slug));
154
+ return results;
155
+ }
156
+
157
+ async function scanExternalEntries(workspaceRoot: string): Promise<CatalogEntry[]> {
158
+ const entries = await listExternalCatalogEntries({ workspaceRoot });
159
+ return entries.map((entry) => ({
160
+ slug: entry.activeId,
161
+ name: entry.activeId,
162
+ description: entry.description,
163
+ source: "external" as const,
164
+ alreadyActive: entry.alreadyActive,
165
+ repoId: entry.repoId,
166
+ skillFolder: entry.skillFolder,
167
+ repoUrl: entry.repoUrl,
168
+ }));
169
+ }
170
+
171
+ export async function listCatalogEntries(opts: CatalogOptions = {}): Promise<CatalogEntry[]> {
172
+ const workspaceRoot = opts.workspaceRoot ?? workspacePath;
173
+ const preset = await scanCatalogSource("preset", workspaceRoot);
174
+ const external = await scanExternalEntries(workspaceRoot);
175
+ return [...preset, ...external];
176
+ }
177
+
178
+ export interface CatalogEntryDetail {
179
+ slug: string;
180
+ source: CatalogSource;
181
+ description: string;
182
+ /** Full SKILL.md body, post-frontmatter, with leading blank lines
183
+ * trimmed. Used by:
184
+ * - 📖 Preview (rendered as markdown in a modal)
185
+ * - ▶ Run once (fed verbatim into a new chat as the user input). */
186
+ body: string;
187
+ }
188
+
189
+ export type CatalogDetailResult =
190
+ | { kind: "ok"; detail: CatalogEntryDetail }
191
+ | { kind: "not-found"; source: CatalogSource; slug: string }
192
+ | { kind: "invalid-slug"; slug: string };
193
+
194
+ /** Read one catalog entry's SKILL.md and return the description +
195
+ * body. The same `safeSlugName` taint-launder used by the star
196
+ * action gates the path here.
197
+ *
198
+ * External entries are NOT routed through this — they use
199
+ * `(repoId, skillFolder)` as their primary key and go through
200
+ * `readExternalCatalogDetail` instead. The route handler dispatches
201
+ * on `source`. */
202
+ export async function readCatalogEntryDetail(source: "preset", slug: string, opts: CatalogOptions = {}): Promise<CatalogDetailResult> {
203
+ const safeName = safeSlugName(slug);
204
+ if (safeName === null) return { kind: "invalid-slug", slug };
205
+ const workspaceRoot = opts.workspaceRoot ?? workspacePath;
206
+ const slugDir = joinUnderRoot(catalogDirForSource(source, workspaceRoot), safeName);
207
+ if (!(await isDirectory(slugDir))) return { kind: "not-found", source, slug: safeName };
208
+ const skillMdPath = path.join(slugDir, "SKILL.md");
209
+ let raw: string;
210
+ try {
211
+ raw = await readFile(skillMdPath, "utf-8");
212
+ } catch {
213
+ return { kind: "not-found", source, slug: safeName };
214
+ }
215
+ const parsed = parseSkillFrontmatter(raw);
216
+ if (!parsed) return { kind: "not-found", source, slug: safeName };
217
+ return {
218
+ kind: "ok",
219
+ detail: { slug: safeName, source, description: parsed.description, body: parsed.body },
220
+ };
221
+ }
222
+
223
+ /** Read an external catalog entry's SKILL.md, returned in the same
224
+ * `CatalogDetailResult` shape so route handlers can dispatch without
225
+ * shape-juggling. The `slug` field in the OK detail is the derived
226
+ * `activeId` (same value the merged listing emits). */
227
+ export async function readExternalDetailAsCatalog(repoId: string, skillFolder: string, opts: CatalogOptions = {}): Promise<CatalogDetailResult> {
228
+ const workspaceRoot = opts.workspaceRoot ?? workspacePath;
229
+ const result = await readExternalCatalogDetail(repoId, skillFolder, { workspaceRoot });
230
+ return adaptExternalDetail(result, repoId, skillFolder);
231
+ }
232
+
233
+ function adaptExternalDetail(result: ExternalCatalogDetailResult, repoId: string, skillFolder: string): CatalogDetailResult {
234
+ if (result.kind === "ok") {
235
+ return {
236
+ kind: "ok",
237
+ detail: {
238
+ slug: result.detail.activeId,
239
+ source: "external",
240
+ description: result.detail.description,
241
+ body: result.detail.body,
242
+ },
243
+ };
244
+ }
245
+ if (result.kind === "invalid-id") {
246
+ return { kind: "invalid-slug", slug: `${repoId}/${skillFolder}` };
247
+ }
248
+ return { kind: "not-found", source: "external", slug: `${result.repoId}/${result.skillFolder}` };
249
+ }
250
+
251
+ // Slug whitelist matches the convention used by user-authored
252
+ // skills + preset slugs. The slug becomes a directory name under
253
+ // `.claude/skills/`, so we forbid anything that could escape (`..`,
254
+ // path separators, leading dots) or be interpreted as a special
255
+ // shell character. The two `[a-zA-Z0-9_-]` segments around a
256
+ // required leading + trailing alphanumeric look like nested
257
+ // quantifiers to the security/detect-unsafe-regex rule, but each
258
+ // segment can only consume from a single bounded character class
259
+ // (no overlap), so worst-case backtracking is linear — annotate
260
+ // rather than rewrite for clarity.
261
+ // eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking
262
+ const SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
263
+
264
+ /** Sanitise a user-supplied slug into a safe directory-name leaf.
265
+ * Returns `null` for anything that fails the slug whitelist OR is
266
+ * not a basename (i.e. survives `path.basename` round-trip
267
+ * unchanged). Returning a `path.basename` result is the pattern
268
+ * CodeQL recognises as a `js/path-injection` sanitiser — once a
269
+ * slug has been passed through `path.basename`, downstream
270
+ * `path.join` / `stat` / `readFile` calls are no longer flagged.
271
+ *
272
+ * Belt-and-suspenders on top of `SAFE_SLUG_PATTERN`: the regex
273
+ * already rejects every problematic shape, but the basename
274
+ * round-trip catches edge cases the regex might miss on platforms
275
+ * with different separators (Windows `\\`) and lets the type
276
+ * system express "this value has been laundered". */
277
+ function safeSlugName(slug: string): string | null {
278
+ if (!SAFE_SLUG_PATTERN.test(slug)) return null;
279
+ // `path.basename` strips anything that looks like a directory
280
+ // component and is CodeQL's recognised sanitiser for
281
+ // `js/path-injection`. On a slug that already passed the regex
282
+ // this is an identity transform.
283
+ const basename = path.basename(slug);
284
+ if (basename !== slug) return null;
285
+ return basename;
286
+ }
287
+
288
+ /** Compose a path inside `rootDir` using a `safeSlugName`-laundered
289
+ * slug. The taint flow ends at `safeSlugName`, so the joined path
290
+ * is no longer flagged. */
291
+ function joinUnderRoot(rootDir: string, safeName: string): string {
292
+ return path.join(rootDir, safeName);
293
+ }
294
+
295
+ export type StarResult =
296
+ | { kind: "starred"; slug: string }
297
+ | { kind: "not-found"; source: CatalogSource; slug: string }
298
+ | { kind: "already-active"; slug: string }
299
+ | { kind: "invalid-slug"; slug: string };
300
+
301
+ async function copyDirTree(srcDir: string, destDir: string): Promise<void> {
302
+ await mkdir(destDir, { recursive: true });
303
+ const entries = await readdir(srcDir, { withFileTypes: true });
304
+ for (const entry of entries) {
305
+ const srcPath = path.join(srcDir, entry.name);
306
+ const destPath = path.join(destDir, entry.name);
307
+ if (entry.isDirectory()) {
308
+ await copyDirTree(srcPath, destPath);
309
+ } else if (entry.isFile()) {
310
+ await copyFile(srcPath, destPath);
311
+ }
312
+ // Symlinks / sockets / FIFOs are intentionally skipped — the
313
+ // catalog is launcher-managed and shouldn't contain them.
314
+ }
315
+ }
316
+
317
+ /** Copy `data/skills/catalog/preset/<slug>/` → `.claude/skills/<slug>/`.
318
+ * Returns a discriminated result so the route can map to clean
319
+ * HTTP status codes. Slug is laundered via `safeSlugName` (regex
320
+ * whitelist + `path.basename` round-trip) before any `path.join`,
321
+ * which is CodeQL's recognised pattern for clearing
322
+ * `js/path-injection` taint. A separator-bearing or escaping slug
323
+ * yields `invalid-slug` and never reaches the filesystem.
324
+ *
325
+ * External entries take a different code path —
326
+ * `starExternalAsCatalog(repoId, skillFolder)` — because they're
327
+ * keyed by `(repoId, skillFolder)` rather than slug. */
328
+ export async function starCatalogEntry(source: "preset", slug: string, opts: CatalogOptions = {}): Promise<StarResult> {
329
+ const safeName = safeSlugName(slug);
330
+ if (safeName === null) return { kind: "invalid-slug", slug };
331
+ const workspaceRoot = opts.workspaceRoot ?? workspacePath;
332
+ const catalogSlugDir = joinUnderRoot(catalogDirForSource(source, workspaceRoot), safeName);
333
+ const activeSlugDir = joinUnderRoot(activeDir(workspaceRoot), safeName);
334
+ if (!(await isDirectory(catalogSlugDir))) return { kind: "not-found", source, slug: safeName };
335
+ if (await isDirectory(activeSlugDir)) return { kind: "already-active", slug: safeName };
336
+ await copyDirTree(catalogSlugDir, activeSlugDir);
337
+ log.info("skills", "starred catalog entry", { source, slug: safeName });
338
+ return { kind: "starred", slug: safeName };
339
+ }
340
+
341
+ /** Star an external catalog entry, returned in the same `StarResult`
342
+ * shape so the route handler can branch on `source` without
343
+ * diverging downstream. */
344
+ export async function starExternalAsCatalog(repoId: string, skillFolder: string, opts: CatalogOptions = {}): Promise<StarResult> {
345
+ const workspaceRoot = opts.workspaceRoot ?? workspacePath;
346
+ const result = await starExternalCatalogEntry(repoId, skillFolder, { workspaceRoot });
347
+ return adaptExternalStar(result, repoId, skillFolder);
348
+ }
349
+
350
+ function adaptExternalStar(result: ExternalStarResult, repoId: string, skillFolder: string): StarResult {
351
+ if (result.kind === "starred") return { kind: "starred", slug: result.activeId };
352
+ if (result.kind === "already-active") return { kind: "already-active", slug: result.activeId };
353
+ if (result.kind === "invalid-id") return { kind: "invalid-slug", slug: `${repoId}/${skillFolder}` };
354
+ return { kind: "not-found", source: "external", slug: `${result.repoId}/${result.skillFolder}` };
355
+ }
@@ -0,0 +1,283 @@
1
+ // Read + star side of the external-skill catalog (#1383 / #1335 PR-C).
2
+ //
3
+ // Companion to `install.ts` (the writer). This module exposes:
4
+ // - `listExternalCatalogEntries`: enumerate every skill across every
5
+ // installed repo for the catalog listing endpoint.
6
+ // - `readExternalCatalogDetail`: load one entry's description + body
7
+ // for the Preview modal + Run-once action.
8
+ // - `starExternalCatalogEntry`: copy a skill into `.claude/skills/`
9
+ // under its derived `activeId`. Star = fork; the active copy
10
+ // survives later uninstall of the source repo.
11
+ //
12
+ // All filesystem reads are gated through the same `path.basename`
13
+ // round-trip sanitiser the rest of the catalog uses, so CodeQL's
14
+ // `js/path-injection` rule recognises the joined paths as safe.
15
+
16
+ import { copyFile, mkdir, readFile, readdir, stat } from "node:fs/promises";
17
+ import path from "node:path";
18
+
19
+ import { workspacePath } from "../../workspace.js";
20
+ import { WORKSPACE_DIRS } from "../../paths.js";
21
+ import { parseSkillFrontmatter } from "../parser.js";
22
+ import { log } from "../../../system/logger/index.js";
23
+ import { deriveActiveId, isSafeRepoId, safeSkillFolder } from "./id.js";
24
+ import { listInstalledRepos, type InstalledRepo } from "./install.js";
25
+
26
+ const SOURCE_METADATA_FILE = ".source.json";
27
+
28
+ // Folder-name validation is shared with install discovery via
29
+ // `safeSkillFolder` (id.ts) so an accepted install is always
30
+ // listable / star-able — see the comment on that function.
31
+ const safeFolderName = safeSkillFolder;
32
+
33
+ function externalRoot(workspaceRoot: string): string {
34
+ return path.join(workspaceRoot, WORKSPACE_DIRS.skillsCatalog, "external");
35
+ }
36
+
37
+ function activeDir(workspaceRoot: string): string {
38
+ return path.join(workspaceRoot, WORKSPACE_DIRS.claudeSkills);
39
+ }
40
+
41
+ async function isDirectory(absPath: string): Promise<boolean> {
42
+ try {
43
+ return (await stat(absPath)).isDirectory();
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ async function isFile(absPath: string): Promise<boolean> {
50
+ try {
51
+ return (await stat(absPath)).isFile();
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ export interface ExternalCatalogEntry {
58
+ repoId: string;
59
+ /** `"."` indicates the skill lives at the repo root (single-skill
60
+ * repo); otherwise this is the subdirectory under
61
+ * `data/skills/catalog/external/<repoId>/`. */
62
+ skillFolder: string;
63
+ /** The slug the entry takes once Starred (`<owner>-<skillFolder>`
64
+ * or `<owner>-<repo>` for single-skill-at-root). Also used as the
65
+ * display id in the UI listing. */
66
+ activeId: string;
67
+ description: string;
68
+ alreadyActive: boolean;
69
+ /** Repo URL from `.source.json`. Surfaced so the UI can show the
70
+ * origin without an extra round-trip. */
71
+ repoUrl: string;
72
+ }
73
+
74
+ interface ScanContext {
75
+ workspaceRoot: string;
76
+ /** Cached active dir to avoid repeated lookups during a single
77
+ * listing call. */
78
+ activeRoot: string;
79
+ }
80
+
81
+ async function readEntryDescription(skillMd: string): Promise<string | null> {
82
+ let raw: string;
83
+ try {
84
+ raw = await readFile(skillMd, "utf-8");
85
+ } catch {
86
+ return null;
87
+ }
88
+ const parsed = parseSkillFrontmatter(raw);
89
+ return parsed?.description ?? null;
90
+ }
91
+
92
+ async function buildEntry(repo: InstalledRepo, skillFolder: string, sourceDir: string, ctx: ScanContext): Promise<ExternalCatalogEntry | null> {
93
+ const skillMd = path.join(sourceDir, "SKILL.md");
94
+ if (!(await isFile(skillMd))) return null;
95
+ const description = await readEntryDescription(skillMd);
96
+ if (description === null) return null;
97
+ const activeId = deriveActiveId(repo.url, skillFolder === "." ? null : skillFolder);
98
+ if (!activeId) return null;
99
+ const alreadyActive = await isDirectory(path.join(ctx.activeRoot, activeId));
100
+ return {
101
+ repoId: repo.repoId,
102
+ skillFolder,
103
+ activeId,
104
+ description,
105
+ alreadyActive,
106
+ repoUrl: repo.url,
107
+ };
108
+ }
109
+
110
+ async function scanRepoEntries(repo: InstalledRepo, ctx: ScanContext): Promise<ExternalCatalogEntry[]> {
111
+ const repoDir = path.join(externalRoot(ctx.workspaceRoot), repo.repoId);
112
+ if (!(await isDirectory(repoDir))) return [];
113
+ if (await isFile(path.join(repoDir, "SKILL.md"))) {
114
+ const entry = await buildEntry(repo, ".", repoDir, ctx);
115
+ return entry ? [entry] : [];
116
+ }
117
+ let names: string[];
118
+ try {
119
+ names = await readdir(repoDir);
120
+ } catch {
121
+ return [];
122
+ }
123
+ const out: ExternalCatalogEntry[] = [];
124
+ for (const name of names) {
125
+ if (name.startsWith(".") || name === SOURCE_METADATA_FILE) continue;
126
+ const safe = safeFolderName(name);
127
+ if (safe === null) continue;
128
+ const sub = path.join(repoDir, safe);
129
+ if (!(await isDirectory(sub))) continue;
130
+ const entry = await buildEntry(repo, safe, sub, ctx);
131
+ if (entry) out.push(entry);
132
+ }
133
+ out.sort((left, right) => left.skillFolder.localeCompare(right.skillFolder));
134
+ return out;
135
+ }
136
+
137
+ export interface ExternalCatalogOptions {
138
+ workspaceRoot?: string;
139
+ }
140
+
141
+ export async function listExternalCatalogEntries(opts: ExternalCatalogOptions = {}): Promise<ExternalCatalogEntry[]> {
142
+ const workspaceRoot = opts.workspaceRoot ?? workspacePath;
143
+ const repos = await listInstalledRepos({ workspaceRoot });
144
+ const ctx: ScanContext = { workspaceRoot, activeRoot: activeDir(workspaceRoot) };
145
+ const out: ExternalCatalogEntry[] = [];
146
+ for (const repo of repos) {
147
+ const entries = await scanRepoEntries(repo, ctx);
148
+ out.push(...entries);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ export interface ExternalCatalogDetail {
154
+ repoId: string;
155
+ skillFolder: string;
156
+ activeId: string;
157
+ description: string;
158
+ body: string;
159
+ }
160
+
161
+ export type ExternalCatalogDetailResult =
162
+ | { kind: "ok"; detail: ExternalCatalogDetail }
163
+ | { kind: "invalid-id" }
164
+ | { kind: "not-found"; repoId: string; skillFolder: string };
165
+
166
+ interface ResolvedSource {
167
+ repoId: string;
168
+ skillFolder: string;
169
+ sourceDir: string;
170
+ url: string;
171
+ }
172
+
173
+ async function readRepoMetadata(repoDir: string): Promise<{ url: string } | null> {
174
+ try {
175
+ const raw = await readFile(path.join(repoDir, SOURCE_METADATA_FILE), "utf-8");
176
+ const parsed = JSON.parse(raw) as { url?: unknown };
177
+ if (typeof parsed.url !== "string") return null;
178
+ return { url: parsed.url };
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ async function resolveSource(repoIdRaw: string, skillFolderRaw: string, workspaceRoot: string): Promise<ResolvedSource | null> {
185
+ if (!isSafeRepoId(repoIdRaw)) return null;
186
+ const repoId = path.basename(repoIdRaw);
187
+ if (repoId !== repoIdRaw) return null;
188
+ const repoDir = path.join(externalRoot(workspaceRoot), repoId);
189
+ if (!(await isDirectory(repoDir))) return null;
190
+ const meta = await readRepoMetadata(repoDir);
191
+ if (!meta) return null;
192
+ if (skillFolderRaw === ".") {
193
+ if (!(await isFile(path.join(repoDir, "SKILL.md")))) return null;
194
+ return { repoId, skillFolder: ".", sourceDir: repoDir, url: meta.url };
195
+ }
196
+ const skillFolder = safeFolderName(skillFolderRaw);
197
+ if (skillFolder === null) return null;
198
+ const sourceDir = path.join(repoDir, skillFolder);
199
+ if (!(await isDirectory(sourceDir))) return null;
200
+ return { repoId, skillFolder, sourceDir, url: meta.url };
201
+ }
202
+
203
+ export async function readExternalCatalogDetail(
204
+ repoIdRaw: string,
205
+ skillFolderRaw: string,
206
+ opts: ExternalCatalogOptions = {},
207
+ ): Promise<ExternalCatalogDetailResult> {
208
+ const workspaceRoot = opts.workspaceRoot ?? workspacePath;
209
+ const resolved = await resolveSource(repoIdRaw, skillFolderRaw, workspaceRoot);
210
+ if (!resolved) {
211
+ // Distinguish bad shape (rejected upstream) from missing-on-disk.
212
+ if (!isSafeRepoId(repoIdRaw)) return { kind: "invalid-id" };
213
+ if (skillFolderRaw !== "." && safeFolderName(skillFolderRaw) === null) return { kind: "invalid-id" };
214
+ return { kind: "not-found", repoId: repoIdRaw, skillFolder: skillFolderRaw };
215
+ }
216
+ const skillMd = path.join(resolved.sourceDir, "SKILL.md");
217
+ let raw: string;
218
+ try {
219
+ raw = await readFile(skillMd, "utf-8");
220
+ } catch {
221
+ return { kind: "not-found", repoId: resolved.repoId, skillFolder: resolved.skillFolder };
222
+ }
223
+ const parsed = parseSkillFrontmatter(raw);
224
+ if (!parsed) return { kind: "not-found", repoId: resolved.repoId, skillFolder: resolved.skillFolder };
225
+ const activeId = deriveActiveId(resolved.url, resolved.skillFolder === "." ? null : resolved.skillFolder);
226
+ if (!activeId) return { kind: "not-found", repoId: resolved.repoId, skillFolder: resolved.skillFolder };
227
+ return {
228
+ kind: "ok",
229
+ detail: {
230
+ repoId: resolved.repoId,
231
+ skillFolder: resolved.skillFolder,
232
+ activeId,
233
+ description: parsed.description,
234
+ body: parsed.body,
235
+ },
236
+ };
237
+ }
238
+
239
+ async function copyDirTree(srcDir: string, destDir: string): Promise<void> {
240
+ await mkdir(destDir, { recursive: true });
241
+ const entries = await readdir(srcDir, { withFileTypes: true });
242
+ for (const entry of entries) {
243
+ // Skip the metadata sentinel and any hidden / dot-prefixed entry
244
+ // (`.git/`, `.DS_Store`, etc.) — never relevant to the active copy.
245
+ if (entry.name === SOURCE_METADATA_FILE) continue;
246
+ if (entry.name.startsWith(".")) continue;
247
+ // `readdir` returns leaf names only (no separator inside), but
248
+ // round-trip through `path.basename` as defence-in-depth so
249
+ // CodeQL recognises the joined paths as sanitised.
250
+ const safe = path.basename(entry.name);
251
+ if (safe !== entry.name) continue;
252
+ const srcPath = path.join(srcDir, safe);
253
+ const destPath = path.join(destDir, safe);
254
+ if (entry.isDirectory()) {
255
+ await copyDirTree(srcPath, destPath);
256
+ } else if (entry.isFile()) {
257
+ await copyFile(srcPath, destPath);
258
+ }
259
+ }
260
+ }
261
+
262
+ export type ExternalStarResult =
263
+ | { kind: "starred"; activeId: string }
264
+ | { kind: "already-active"; activeId: string }
265
+ | { kind: "not-found"; repoId: string; skillFolder: string }
266
+ | { kind: "invalid-id" };
267
+
268
+ export async function starExternalCatalogEntry(repoIdRaw: string, skillFolderRaw: string, opts: ExternalCatalogOptions = {}): Promise<ExternalStarResult> {
269
+ const workspaceRoot = opts.workspaceRoot ?? workspacePath;
270
+ const resolved = await resolveSource(repoIdRaw, skillFolderRaw, workspaceRoot);
271
+ if (!resolved) {
272
+ if (!isSafeRepoId(repoIdRaw)) return { kind: "invalid-id" };
273
+ if (skillFolderRaw !== "." && safeFolderName(skillFolderRaw) === null) return { kind: "invalid-id" };
274
+ return { kind: "not-found", repoId: repoIdRaw, skillFolder: skillFolderRaw };
275
+ }
276
+ const activeId = deriveActiveId(resolved.url, resolved.skillFolder === "." ? null : resolved.skillFolder);
277
+ if (!activeId) return { kind: "invalid-id" };
278
+ const activeSlugDir = path.join(activeDir(workspaceRoot), activeId);
279
+ if (await isDirectory(activeSlugDir)) return { kind: "already-active", activeId };
280
+ await copyDirTree(resolved.sourceDir, activeSlugDir);
281
+ log.info("skills-external", "starred entry", { repoId: resolved.repoId, skillFolder: resolved.skillFolder, activeId });
282
+ return { kind: "starred", activeId };
283
+ }