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,129 @@
1
+ // Git sparse-checkout helper for the external-skill catalog
2
+ // (#1383 / #1335 PR-C).
3
+ //
4
+ // Responsibilities:
5
+ // - Clone or fetch into a workspace-EXTERNAL scratch dir keyed by
6
+ // hash(url) so multiple installs of the same URL (with different
7
+ // subpaths) share one `.git/`.
8
+ // - Apply a sparse-checkout pattern when `subpath` is provided so
9
+ // huge multi-skill repos don't materialise files we won't use.
10
+ // - Return the resolved commit SHA + cache dir so the install
11
+ // layer can scan for SKILL.md and write its own `.source.json`.
12
+ //
13
+ // The git binary is invoked through `execFile` (no shell) and the
14
+ // runner is injectable via `deps.runGit` so unit tests can fake the
15
+ // filesystem layout without ever shelling out.
16
+
17
+ import { execFile } from "node:child_process";
18
+ import { mkdir, writeFile } from "node:fs/promises";
19
+ import { existsSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import path from "node:path";
22
+ import { promisify } from "node:util";
23
+
24
+ import { ONE_MINUTE_MS } from "../../../utils/time.js";
25
+ import { parseGitHubHttpsUrl, urlCacheKey } from "./id.js";
26
+
27
+ const execFileP = promisify(execFile);
28
+
29
+ const GIT_OP_TIMEOUT_MS = ONE_MINUTE_MS;
30
+
31
+ export interface CloneOptions {
32
+ url: string;
33
+ subpath?: string;
34
+ /** Branch / tag / SHA to fetch. Default: "HEAD". */
35
+ ref?: string;
36
+ }
37
+
38
+ export interface CloneResult {
39
+ /** Absolute path to the populated cache dir (contains `.git/` plus
40
+ * the checked-out tree). */
41
+ cacheDir: string;
42
+ /** Commit SHA pointed to by `HEAD` after fetch. */
43
+ sha: string;
44
+ }
45
+
46
+ export type RunGit = (args: readonly string[]) => Promise<{ stdout: string; stderr: string }>;
47
+
48
+ /** Default git runner: shells out to `git` on PATH with no shell
49
+ * involvement (execFile, not exec). The workspace init already
50
+ * assumes `git` is available (see `server/workspace/workspace.ts`),
51
+ * so this isn't a new dependency. */
52
+ export const defaultRunGit: RunGit = async (args) => execFileP("git", args.slice(), { timeout: GIT_OP_TIMEOUT_MS });
53
+
54
+ /** Default scratch root: `~/.cache/mulmoclaude/sources/`. Kept
55
+ * outside the workspace so `.git/` clutter doesn't end up in the
56
+ * user's workspace git history or Obsidian sync. */
57
+ export function defaultCacheRoot(): string {
58
+ return path.join(homedir(), ".cache", "mulmoclaude", "sources");
59
+ }
60
+
61
+ export interface CloneDeps {
62
+ cacheRoot?: string;
63
+ runGit?: RunGit;
64
+ }
65
+
66
+ /** One-time repo creation: `git init` + `remote add`. No-op once
67
+ * `.git/` exists (re-installs reuse the existing clone). */
68
+ async function initRepoOnce(cacheDir: string, url: string, runGit: RunGit): Promise<void> {
69
+ if (existsSync(path.join(cacheDir, ".git"))) return;
70
+ await runGit(["init", cacheDir]);
71
+ await runGit(["-C", cacheDir, "remote", "add", "origin", url]);
72
+ }
73
+
74
+ /** Apply sparse-checkout config to match the CURRENT request — runs
75
+ * on every call, not just first init. Without this, re-installing
76
+ * the same URL with a different (or absent) `subpath` would keep the
77
+ * stale pattern from the first install, so `git checkout` stays
78
+ * constrained and discovery wrongly reports `no-skills`. Caller
79
+ * guarantees `subpath` is already sanitised. */
80
+ async function applySparseCheckout(cacheDir: string, subpath: string | undefined, runGit: RunGit): Promise<void> {
81
+ if (!subpath) {
82
+ // Lift any constraint left by a previous subpath install.
83
+ await runGit(["-C", cacheDir, "config", "core.sparseCheckout", "false"]);
84
+ return;
85
+ }
86
+ await runGit(["-C", cacheDir, "config", "core.sparseCheckout", "true"]);
87
+ // sparse-checkout pattern is one entry per line; we want the
88
+ // entire subpath subtree.
89
+ const sparseFile = path.join(cacheDir, ".git", "info", "sparse-checkout");
90
+ await mkdir(path.dirname(sparseFile), { recursive: true });
91
+ await writeFile(sparseFile, `${subpath}/*\n`);
92
+ }
93
+
94
+ /** Fetch `ref` at depth 1, check it out, and return the resolved
95
+ * 40-hex SHA. Throws if `rev-parse` yields anything unexpected. */
96
+ async function fetchAndResolveSha(cacheDir: string, ref: string, runGit: RunGit): Promise<string> {
97
+ // `--depth=1` keeps the scratch dir small; we only need the current
98
+ // tree, never history. `--no-tags` avoids pulling release-tag noise.
99
+ await runGit(["-C", cacheDir, "fetch", "--depth=1", "--no-tags", "origin", ref]);
100
+ await runGit(["-C", cacheDir, "checkout", "FETCH_HEAD"]);
101
+ const { stdout: shaRaw } = await runGit(["-C", cacheDir, "rev-parse", "HEAD"]);
102
+ const sha = shaRaw.trim();
103
+ if (!/^[0-9a-f]{40}$/.test(sha)) {
104
+ throw new Error(`git rev-parse returned unexpected output: ${JSON.stringify(shaRaw)}`);
105
+ }
106
+ return sha;
107
+ }
108
+
109
+ /** Initialise (or refresh) a scratch clone for the given URL and
110
+ * return the cache dir + resolved SHA. Idempotent: a second call
111
+ * with the same URL only fetches new commits and re-checks-out. */
112
+ export async function cloneOrUpdate(opts: CloneOptions, deps: CloneDeps = {}): Promise<CloneResult> {
113
+ // Defense-in-depth: the install layer already validates via
114
+ // `deriveRepoId`, but re-checking here keeps the function safe
115
+ // against direct callers (tests / future refactors) so an
116
+ // unvalidated URL can never reach `git`.
117
+ if (!parseGitHubHttpsUrl(opts.url)) {
118
+ throw new Error(`invalid GitHub HTTPS URL: ${opts.url}`);
119
+ }
120
+ const runGit = deps.runGit ?? defaultRunGit;
121
+ const cacheRoot = deps.cacheRoot ?? defaultCacheRoot();
122
+ const cacheDir = path.join(cacheRoot, urlCacheKey(opts.url));
123
+
124
+ await mkdir(cacheDir, { recursive: true });
125
+ await initRepoOnce(cacheDir, opts.url, runGit);
126
+ await applySparseCheckout(cacheDir, opts.subpath, runGit);
127
+ const sha = await fetchAndResolveSha(cacheDir, opts.ref ?? "HEAD", runGit);
128
+ return { cacheDir, sha };
129
+ }
@@ -0,0 +1,194 @@
1
+ // ID derivation for the external-skill catalog (#1383 / #1335 PR-C).
2
+ //
3
+ // Inputs: a GitHub HTTPS URL + optional subpath. Outputs:
4
+ //
5
+ // - `repoId` — the directory name under `data/skills/catalog/external/`.
6
+ // Built from `<owner>-<repo>` so the same repo always
7
+ // resolves to the same folder regardless of which
8
+ // subpath the user picked (subpath determines what
9
+ // ends up INSIDE the dir, not its name).
10
+ // - `activeId` — the directory name under `.claude/skills/`. Built
11
+ // from `<owner>-<skillFolder>` to keep the slash-
12
+ // command flat and reasonably short. When the repo
13
+ // ships a single SKILL.md at root (no skillFolder),
14
+ // the activeId equals the repoId.
15
+ //
16
+ // Both ids run through the same `safeSlug` filter the rest of the
17
+ // catalog uses (regex whitelist + `path.basename` round-trip — CodeQL's
18
+ // recognised path-injection sanitiser, established in PR-B).
19
+
20
+ import { createHash } from "node:crypto";
21
+ import path from "node:path";
22
+
23
+ // Slug whitelist: lowercase alnum + `-`, must start and end with alnum,
24
+ // at least one character. Matches the convention used by `catalog.ts`'s
25
+ // `safeSlugName` but lower-cases the input first since URLs / repo
26
+ // names are case-insensitive on GitHub.
27
+ //
28
+ // The two `[a-z0-9-]` segments around the required leading + trailing
29
+ // alnum look like nested quantifiers to the security/detect-unsafe-regex
30
+ // rule, but each segment can only consume from a single bounded
31
+ // character class — worst-case backtracking is linear.
32
+ // eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping classes
33
+ const SAFE_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
34
+
35
+ function sanitise(raw: string): string | null {
36
+ // Fail-closed denylist FIRST: anything that smells like a path
37
+ // operator is rejected outright. Doing the normalise step below
38
+ // first would silently collapse `..` → `-` and let suspicious
39
+ // input through as benign-looking strings (`../etc` → `etc`).
40
+ if (raw.includes("..") || raw.includes("/") || raw.includes("\\") || raw.includes("\0")) return null;
41
+ // Normalise punctuation to hyphens then trim leading + trailing
42
+ // separators via slice (avoids `/-+$/` which sonar flags as
43
+ // potentially slow even though it's bounded by `$`).
44
+ let lowered = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-");
45
+ while (lowered.startsWith("-")) lowered = lowered.slice(1);
46
+ while (lowered.endsWith("-")) lowered = lowered.slice(0, -1);
47
+ if (!SAFE_SLUG_PATTERN.test(lowered)) return null;
48
+ // `path.basename` round-trip — same launder used elsewhere so CodeQL
49
+ // recognises the result as sanitised when it flows into `path.join`.
50
+ const basename = path.basename(lowered);
51
+ return basename === lowered ? basename : null;
52
+ }
53
+
54
+ export interface ParsedGitHubUrl {
55
+ owner: string;
56
+ repo: string;
57
+ }
58
+
59
+ const GITHUB_HTTPS_RE = /^https:\/\/github\.com\/([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]{1,100})(?:\.git)?\/?$/;
60
+
61
+ /** Parse a GitHub HTTPS URL into owner/repo. v1 only accepts
62
+ * `https://github.com/<owner>/<repo>` (optional `.git` suffix,
63
+ * optional trailing slash). gitlab / SSH / private hosts are out of
64
+ * scope. Returns `null` on any rejection. */
65
+ export function parseGitHubHttpsUrl(url: string): ParsedGitHubUrl | null {
66
+ const match = GITHUB_HTTPS_RE.exec(url);
67
+ if (!match) return null;
68
+ const [, owner, repoRaw] = match;
69
+ // Strip a trailing `.git` if the regex's optional group missed it
70
+ // (it's already handled in the regex, but be defensive).
71
+ const repo = repoRaw.endsWith(".git") ? repoRaw.slice(0, -".git".length) : repoRaw;
72
+ return { owner, repo };
73
+ }
74
+
75
+ /** Derive the repo-level catalog directory name from URL.
76
+ * `<owner>-<repo>`. Returns `null` if the URL fails parsing or the
77
+ * resulting slug isn't safe (e.g. owner / repo contains only
78
+ * punctuation). */
79
+ export function deriveRepoId(url: string): string | null {
80
+ const parsed = parseGitHubHttpsUrl(url);
81
+ if (!parsed) return null;
82
+ return sanitise(`${parsed.owner}-${parsed.repo}`);
83
+ }
84
+
85
+ /** Derive the active-layer directory name from a URL + skillFolder.
86
+ * When `skillFolder` is `null`, the skill is at repo root and the
87
+ * active id equals the repoId. */
88
+ export function deriveActiveId(url: string, skillFolder: string | null): string | null {
89
+ const parsed = parseGitHubHttpsUrl(url);
90
+ if (!parsed) return null;
91
+ if (skillFolder === null) {
92
+ return sanitise(`${parsed.owner}-${parsed.repo}`);
93
+ }
94
+ // Validate skillFolder itself first so a path-traversal-shaped
95
+ // input ("..", "foo/bar") fails closed before composition.
96
+ const folderSafe = sanitise(skillFolder);
97
+ if (folderSafe === null) return null;
98
+ return sanitise(`${parsed.owner}-${folderSafe}`);
99
+ }
100
+
101
+ /** Canonical `https://github.com/<owner>/<repo>` (lowercased, no
102
+ * `.git`/trailing slash) — the identity two URLs share iff they
103
+ * point at the same GitHub repo. `null` if the URL doesn't parse.
104
+ * Used both for cache keying and the install-time collision guard
105
+ * (distinct repos whose `repoId` punctuation-collides must be told
106
+ * apart by their canonical URL, not their lossy id). */
107
+ export function canonicalRepoUrl(url: string): string | null {
108
+ const parsed = parseGitHubHttpsUrl(url);
109
+ return parsed ? `https://github.com/${parsed.owner.toLowerCase()}/${parsed.repo.toLowerCase()}` : null;
110
+ }
111
+
112
+ /** Stable opaque hash of a URL for keying the scratch-clone cache.
113
+ * Canonicalises first so the accepted variants of the same repo
114
+ * (`/o/r`, `/o/r/`, `/o/r.git`, case differences) map to ONE cache
115
+ * dir — otherwise a re-install via a different form would spawn a
116
+ * duplicate clone and uninstall would only drop the last-recorded
117
+ * variant. */
118
+ export function urlCacheKey(url: string): string {
119
+ return createHash("sha256")
120
+ .update(canonicalRepoUrl(url) ?? url)
121
+ .digest("hex")
122
+ .slice(0, 16);
123
+ }
124
+
125
+ // Shape of a `deriveRepoId` output (`<owner>-<repo>` lowercased). The
126
+ // two `[a-z0-9-]` segments around the required leading + trailing
127
+ // alnum look like nested quantifiers but each reads from a single
128
+ // bounded class — worst-case backtracking is linear. Exported as the
129
+ // single source of truth so the catalog reader + installer agree by
130
+ // construction instead of duplicating the literal.
131
+ // eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping classes
132
+ const SAFE_REPO_ID_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
133
+
134
+ /** True when `value` has the shape `deriveRepoId` produces. */
135
+ export function isSafeRepoId(value: string): boolean {
136
+ return SAFE_REPO_ID_RE.test(value);
137
+ }
138
+
139
+ /** Launder a (possibly user-supplied) repo id into a safe directory
140
+ * leaf, or `null` if it fails the shape check or isn't a basename.
141
+ * The `path.basename` round-trip is CodeQL's recognised
142
+ * `js/path-injection` sanitiser — callers MUST compose paths from
143
+ * the returned value (not the raw input) so the taint flow ends
144
+ * here. A regex `.test()` alone is NOT recognised as a sanitiser. */
145
+ export function safeRepoId(raw: string): string | null {
146
+ if (!SAFE_REPO_ID_RE.test(raw)) return null;
147
+ const basename = path.basename(raw);
148
+ return basename === raw ? basename : null;
149
+ }
150
+
151
+ /** Launder a skill-folder leaf name (the directory directly under a
152
+ * repo / subpath that contains `SKILL.md`). This is the SINGLE rule
153
+ * shared by install-time discovery AND read/star addressing — if the
154
+ * two diverged (as they did: install accepted `v1.2` but the read
155
+ * side's stricter regex dropped it), an install could succeed yet be
156
+ * invisible/unaddressable in list/preview/star.
157
+ *
158
+ * Permissive by design (mirrors what install discovery accepts: any
159
+ * non-hidden one-level dir) but still traversal-safe: rejects empty,
160
+ * `.`/`..`, leading-dot (hidden), separators, NUL, and anything that
161
+ * isn't its own `path.basename` (CodeQL `js/path-injection`
162
+ * sanitiser). Dots elsewhere (`v1.2`) are allowed. */
163
+ export function safeSkillFolder(raw: string): string | null {
164
+ if (raw.length === 0 || raw === "." || raw === "..") return null;
165
+ if (raw.startsWith(".")) return null;
166
+ if (raw.includes("/") || raw.includes("\\") || raw.includes("\0")) return null;
167
+ const basename = path.basename(raw);
168
+ return basename === raw ? basename : null;
169
+ }
170
+
171
+ // A single safe path segment: alnum plus `.`, `-`, `_`. `..` is
172
+ // rejected explicitly by the caller before this is consulted, so the
173
+ // dot allowance can't yield a traversal token.
174
+ const SUBPATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
175
+
176
+ /** Sanitise a caller-supplied repo subpath (`POST /external/repos`
177
+ * body). The raw value flows into `path.join(cacheDir, subpath)` and
178
+ * into the git sparse-checkout pattern, so an un-validated `../../etc`
179
+ * / `\0` / newline would escape the scratch dir or inject extra
180
+ * sparse patterns. Returns a normalised `a/b/c` string (no leading or
181
+ * trailing slash, no `.`/`..`/empty segments) or `null` on rejection. */
182
+ export function sanitiseSubpath(raw: string): string | null {
183
+ if (raw.length === 0) return null;
184
+ if (raw.includes("\0") || raw.includes("\\") || raw.includes("\n") || raw.includes("\r")) return null;
185
+ if (raw.startsWith("/")) return null;
186
+ const safe: string[] = [];
187
+ for (const segment of raw.split("/")) {
188
+ if (segment === "" || segment === ".") continue;
189
+ if (segment === ".." || !SUBPATH_SEGMENT_PATTERN.test(segment)) return null;
190
+ safe.push(segment);
191
+ }
192
+ if (safe.length === 0) return null;
193
+ return safe.join("/");
194
+ }