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.
- package/README.md +26 -0
- package/bin/mulmoclaude.js +11 -1
- package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
- package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
- package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
- package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
- package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
- package/client/assets/index-CyBr8Mkr.css +2 -0
- package/client/assets/index-zZIqEbNX.js +5106 -0
- package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
- package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
- package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
- package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
- package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
- package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
- package/client/index.html +10 -10
- package/package.json +9 -8
- package/server/agent/backend/claude-code.ts +34 -0
- package/server/agent/backend/fake-echo.ts +370 -0
- package/server/agent/backend/index.ts +16 -1
- package/server/agent/config.ts +74 -24
- package/server/agent/index.ts +104 -80
- package/server/agent/mcpFailureMonitor.ts +167 -0
- package/server/agent/mcpPreflight.ts +185 -0
- package/server/agent/prompt.ts +50 -359
- package/server/agent/stdioHttpShim.ts +171 -0
- package/server/agent/stream.ts +12 -1
- package/server/api/routes/encore.ts +55 -0
- package/server/api/routes/files.ts +22 -0
- package/server/api/routes/mulmo-script.ts +19 -1
- package/server/api/routes/schedulerHandlers.ts +52 -4
- package/server/api/routes/sessions.ts +15 -0
- package/server/api/routes/skills.ts +263 -0
- package/server/build/dispatcher.mjs +299 -0
- package/server/encore/INVARIANTS.md +272 -0
- package/server/encore/boot.ts +39 -0
- package/server/encore/closure.ts +36 -0
- package/server/encore/cycle.ts +276 -0
- package/server/encore/dispatch.ts +103 -0
- package/server/encore/handlers/amend.ts +99 -0
- package/server/encore/handlers/appendNote.ts +74 -0
- package/server/encore/handlers/defineEncore.ts +42 -0
- package/server/encore/handlers/listTickets.ts +107 -0
- package/server/encore/handlers/markStepDone.ts +41 -0
- package/server/encore/handlers/markTargetSkipped.ts +33 -0
- package/server/encore/handlers/query.ts +138 -0
- package/server/encore/handlers/recordValues.ts +44 -0
- package/server/encore/handlers/resolveNotification.ts +121 -0
- package/server/encore/handlers/setup.ts +81 -0
- package/server/encore/handlers/shared.ts +137 -0
- package/server/encore/handlers/snooze.ts +87 -0
- package/server/encore/handlers/startObligationChat.ts +64 -0
- package/server/encore/handlers/startSetupChat.ts +50 -0
- package/server/encore/lock.ts +61 -0
- package/server/encore/notifier.ts +123 -0
- package/server/encore/obligation.ts +25 -0
- package/server/encore/paths.ts +78 -0
- package/server/encore/reconcile.ts +661 -0
- package/server/encore/tick.ts +191 -0
- package/server/encore/yaml-fm.ts +63 -0
- package/server/events/notifications.ts +19 -91
- package/server/index.ts +94 -9
- package/server/notifier/engine.ts +102 -1
- package/server/notifier/macosReminderAdapter.ts +30 -0
- package/server/notifier/runtime-api.ts +41 -1
- package/server/notifier/types.ts +15 -2
- package/server/plugins/runtime.ts +11 -2
- package/server/prompts/index.ts +39 -0
- package/server/prompts/system/journal-pointer.md +12 -0
- package/server/prompts/system/memory-management-atomic.md +33 -0
- package/server/prompts/system/memory-management-topic.md +60 -0
- package/server/prompts/system/news-concierge.md +24 -0
- package/server/prompts/system/sandbox-tools.md +10 -0
- package/server/prompts/system/sources-context.md +16 -0
- package/server/prompts/system/system.md +91 -0
- package/server/system/announceOptionalDeps.ts +57 -0
- package/server/system/appVersion.ts +34 -0
- package/server/system/config.ts +17 -1
- package/server/system/docker.ts +14 -6
- package/server/system/env.ts +18 -5
- package/server/system/optionalDeps.ts +129 -0
- package/server/utils/cli-flags.d.mts +14 -0
- package/server/utils/cli-flags.mjs +53 -0
- package/server/utils/files/encore-io.ts +111 -0
- package/server/utils/time.ts +6 -0
- package/server/workspace/helps/business.md +2 -2
- package/server/workspace/helps/encore-dsl.md +482 -0
- package/server/workspace/helps/index.md +15 -13
- package/server/workspace/helps/mulmoscript.md +3 -3
- package/server/workspace/helps/sandbox.md +2 -2
- package/server/workspace/hooks/dispatcher.ts +7 -5
- package/server/workspace/hooks/provision.ts +6 -3
- package/server/workspace/paths.ts +13 -4
- package/server/workspace/skills/catalog.ts +355 -0
- package/server/workspace/skills/external/catalog.ts +283 -0
- package/server/workspace/skills/external/clone.ts +129 -0
- package/server/workspace/skills/external/id.ts +194 -0
- package/server/workspace/skills/external/install.ts +417 -0
- package/server/workspace/skills/external/presets.ts +50 -0
- package/server/workspace/skills-preset.ts +29 -17
- package/server/workspace/workspace.ts +10 -5
- package/src/App.vue +37 -8
- package/src/components/FileContentRenderer.vue +102 -9
- package/src/components/JsonEditor.vue +160 -0
- package/src/components/NotificationBell.vue +35 -3
- package/src/components/PluginLauncher.vue +20 -41
- package/src/components/RightSidebar.vue +19 -0
- package/src/components/SettingsMcpTab.vue +58 -11
- package/src/components/SettingsModal.vue +22 -1
- package/src/components/StackView.vue +10 -1
- package/src/components/TodoExplorer.vue +16 -0
- package/src/components/todo/TodoKanbanView.vue +34 -6
- package/src/composables/useNotifications.ts +21 -1
- package/src/config/apiRoutes.ts +0 -6
- package/src/config/mcpCatalog.ts +12 -7
- package/src/config/mcpTypes.ts +5 -0
- package/src/config/roles.ts +52 -15
- package/src/config/systemFileDescriptors.ts +12 -0
- package/src/lang/de.ts +108 -12
- package/src/lang/en.ts +105 -11
- package/src/lang/es.ts +106 -11
- package/src/lang/fr.ts +106 -11
- package/src/lang/ja.ts +104 -11
- package/src/lang/ko.ts +105 -11
- package/src/lang/pt-BR.ts +106 -11
- package/src/lang/zh.ts +103 -11
- package/src/main.ts +1 -0
- package/src/plugins/_generated/metas.ts +4 -0
- package/src/plugins/_generated/registrations.ts +2 -0
- package/src/plugins/_generated/server-bindings.ts +5 -0
- package/src/plugins/encore/EncoreDashboard.vue +504 -0
- package/src/plugins/encore/EncoreRedirect.vue +116 -0
- package/src/plugins/encore/View.vue +36 -0
- package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
- package/src/plugins/encore/defineEncoreMeta.ts +13 -0
- package/src/plugins/encore/index.ts +93 -0
- package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
- package/src/plugins/encore/manageEncoreMeta.ts +36 -0
- package/src/plugins/manageSkills/View.vue +832 -30
- package/src/plugins/manageSkills/categories.ts +125 -0
- package/src/plugins/manageSkills/meta.ts +30 -0
- package/src/plugins/markdown/definition.ts +3 -3
- package/src/plugins/meta-types.ts +5 -0
- package/src/plugins/presentMulmoScript/Preview.vue +3 -3
- package/src/plugins/presentMulmoScript/View.vue +157 -33
- package/src/plugins/presentMulmoScript/meta.ts +4 -0
- package/src/plugins/scheduler/View.vue +45 -9
- package/src/plugins/scheduler/calendarDefinition.ts +6 -2
- package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
- package/src/plugins/skill/View.vue +1 -5
- package/src/plugins/spreadsheet/View.vue +3 -3
- package/src/plugins/spreadsheet/definition.ts +1 -1
- package/src/plugins/textResponse/Preview.vue +14 -1
- package/src/plugins/textResponse/View.vue +39 -24
- package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
- package/src/router/index.ts +11 -0
- package/src/router/pageRoutes.ts +1 -0
- package/src/types/encore-dsl/at-expression.ts +120 -0
- package/src/types/encore-dsl/at-resolver.ts +32 -0
- package/src/types/encore-dsl/cadence.ts +289 -0
- package/src/types/encore-dsl/schema.ts +288 -0
- package/src/types/notification.ts +2 -1
- package/src/types/session.ts +6 -0
- package/src/types/sse.ts +5 -0
- package/src/types/toolCallHistory.ts +7 -0
- package/src/utils/agent/eventDispatch.ts +26 -5
- package/src/utils/agent/mcpHint.ts +50 -0
- package/src/utils/image/htmlSrcAttrs.ts +117 -13
- package/src/utils/session/sessionEntries.ts +8 -32
- package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
- package/client/assets/chunk-CernVdwh.js +0 -1
- package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
- package/client/assets/index-BwrlMMHr.js +0 -5005
- package/client/assets/index-CvvNuegU.css +0 -2
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
- package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
- package/server/api/routes/notifications.ts +0 -195
- package/server/notifier/legacy-adapters.ts +0 -76
- package/server/workspace/hooks/dispatcher.mjs +0 -300
- package/src/composables/useSelectedResult.ts +0 -49
|
@@ -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
|
+
}
|