@schlessera/brain-ui-server 0.27.0 → 0.28.0

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 (58) hide show
  1. package/README.md +1 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +13 -1
  4. package/dist/app.js.map +1 -1
  5. package/dist/brain/client.d.ts +2 -0
  6. package/dist/brain/client.d.ts.map +1 -1
  7. package/dist/brain/client.js +7 -0
  8. package/dist/brain/client.js.map +1 -1
  9. package/dist/config/env.d.ts +2 -0
  10. package/dist/config/env.d.ts.map +1 -1
  11. package/dist/config/env.js +11 -0
  12. package/dist/config/env.js.map +1 -1
  13. package/dist/db/settings.d.ts +8 -0
  14. package/dist/db/settings.d.ts.map +1 -1
  15. package/dist/db/settings.js +14 -0
  16. package/dist/db/settings.js.map +1 -1
  17. package/dist/routes/skills.d.ts +28 -0
  18. package/dist/routes/skills.d.ts.map +1 -0
  19. package/dist/routes/skills.js +173 -0
  20. package/dist/routes/skills.js.map +1 -0
  21. package/dist/routes/tool-permissions.d.ts +17 -0
  22. package/dist/routes/tool-permissions.d.ts.map +1 -0
  23. package/dist/routes/tool-permissions.js +26 -0
  24. package/dist/routes/tool-permissions.js.map +1 -0
  25. package/dist/skills/install.d.ts +77 -0
  26. package/dist/skills/install.d.ts.map +1 -0
  27. package/dist/skills/install.js +273 -0
  28. package/dist/skills/install.js.map +1 -0
  29. package/dist/skills/manager.d.ts +58 -0
  30. package/dist/skills/manager.d.ts.map +1 -0
  31. package/dist/skills/manager.js +208 -0
  32. package/dist/skills/manager.js.map +1 -0
  33. package/dist/ws/bridge.d.ts.map +1 -1
  34. package/dist/ws/bridge.js +9 -0
  35. package/dist/ws/bridge.js.map +1 -1
  36. package/dist/ws/connection.d.ts.map +1 -1
  37. package/dist/ws/connection.js +1 -0
  38. package/dist/ws/connection.js.map +1 -1
  39. package/dist/ws/dispatch.d.ts.map +1 -1
  40. package/dist/ws/dispatch.js +5 -0
  41. package/dist/ws/dispatch.js.map +1 -1
  42. package/dist/ws/host.d.ts +14 -0
  43. package/dist/ws/host.d.ts.map +1 -1
  44. package/dist/ws/host.js +2 -0
  45. package/dist/ws/host.js.map +1 -1
  46. package/package.json +5 -3
  47. package/src/app.ts +18 -0
  48. package/src/brain/client.ts +12 -0
  49. package/src/config/env.ts +13 -0
  50. package/src/db/settings.ts +17 -0
  51. package/src/routes/skills.ts +203 -0
  52. package/src/routes/tool-permissions.ts +37 -0
  53. package/src/skills/install.ts +332 -0
  54. package/src/skills/manager.ts +258 -0
  55. package/src/ws/bridge.ts +11 -0
  56. package/src/ws/connection.ts +1 -0
  57. package/src/ws/dispatch.ts +5 -0
  58. package/src/ws/host.ts +16 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Custom-skill management routes — the Settings pane's CRUD over the brain
3
+ * repo's canonical skill home (see skills/manager.ts for the store contract).
4
+ *
5
+ * Every mutation is followed by `brain skills sync`, which re-materializes
6
+ * the per-agent integration dirs (.claude/skills, .pi/skills, codex, gemini)
7
+ * and the AGENTS.md index block — so a change here reaches EVERY backend's
8
+ * next turn/session with no restart. A failed sync degrades to a `warning`
9
+ * in the response (the canonical write already happened; the next nightly
10
+ * sync repairs the links) instead of failing the request.
11
+ *
12
+ * Mount BEHIND the /api auth guard: skills instruct the agent.
13
+ */
14
+
15
+ import { Hono } from "hono";
16
+ import type { Logger } from "@opentelemetry/api-logs";
17
+ import {
18
+ createSkillManager,
19
+ SkillConflictError,
20
+ SkillNotFoundError,
21
+ SkillValidationError,
22
+ } from "../skills/manager.js";
23
+ import {
24
+ InstallError,
25
+ installSkillsFromGitHub,
26
+ installSkillsFromZip,
27
+ MAX_ARCHIVE_BYTES,
28
+ parseGitHubSource,
29
+ type Fetcher,
30
+ } from "../skills/install.js";
31
+ import { resolveGitHubToken } from "../config/env.js";
32
+
33
+ export interface SkillRoutesDeps {
34
+ brainPath: string;
35
+ /** Runs `brain skills sync`; resolves with a human-readable summary. */
36
+ syncSkills: () => Promise<string>;
37
+ log?: Logger;
38
+ /** Test seam — GitHub zipball fetcher. */
39
+ fetcher?: Fetcher;
40
+ /** Test seam — token source; default reads GITHUB_TOKEN at call time. */
41
+ githubToken?: () => string | undefined;
42
+ }
43
+
44
+ export function createSkillRoutes(deps: SkillRoutesDeps): Hono {
45
+ const manager = createSkillManager(deps.brainPath);
46
+
47
+ function errorResponse(err: unknown): { status: 400 | 404 | 409 | 500; message: string } {
48
+ if (err instanceof SkillValidationError) return { status: 400, message: err.message };
49
+ if (err instanceof SkillNotFoundError) return { status: 404, message: err.message };
50
+ if (err instanceof SkillConflictError) return { status: 409, message: err.message };
51
+ return { status: 500, message: err instanceof Error ? err.message : "Skill operation failed." };
52
+ }
53
+
54
+ async function syncAfterMutation(): Promise<string | undefined> {
55
+ try {
56
+ await deps.syncSkills();
57
+ return undefined;
58
+ } catch (err) {
59
+ const message = err instanceof Error ? err.message : String(err);
60
+ deps.log?.emit({
61
+ severityText: "WARN",
62
+ body: "brain skills sync failed after a skill mutation; agent links may lag until the next sync",
63
+ attributes: { error: message },
64
+ });
65
+ return `Skill saved, but \`brain skills sync\` failed: ${message}`;
66
+ }
67
+ }
68
+
69
+ const githubToken = deps.githubToken ?? resolveGitHubToken;
70
+
71
+ return new Hono()
72
+ .get("/skills", (c) => c.json({ skills: manager.list() }))
73
+ .post("/skills/install/zip", async (c) => {
74
+ let file: File | null = null;
75
+ let overwrite = false;
76
+ try {
77
+ const form = await c.req.formData();
78
+ const entry = form.get("file");
79
+ file = entry instanceof File ? entry : null;
80
+ overwrite = form.get("overwrite") === "true";
81
+ } catch {
82
+ return c.json({ error: "Send multipart/form-data with a `file` field." }, 400);
83
+ }
84
+ if (!file) return c.json({ error: "Missing `file` (a .zip archive)." }, 400);
85
+ if (file.size > MAX_ARCHIVE_BYTES) {
86
+ return c.json({ error: `Archive exceeds ${MAX_ARCHIVE_BYTES / 1024 / 1024}MB.` }, 400);
87
+ }
88
+ try {
89
+ const outcomes = installSkillsFromZip(
90
+ { brainPath: deps.brainPath },
91
+ new Uint8Array(await file.arrayBuffer()),
92
+ { overwrite }
93
+ );
94
+ const warning = outcomes.some((o) => o.status !== "skipped")
95
+ ? await syncAfterMutation()
96
+ : undefined;
97
+ return c.json({ outcomes, ...(warning ? { warning } : {}) });
98
+ } catch (err) {
99
+ if (err instanceof InstallError) return c.json({ error: err.message }, 400);
100
+ const { status, message } = errorResponse(err);
101
+ return c.json({ error: message }, status);
102
+ }
103
+ })
104
+ .post("/skills/install/github", async (c) => {
105
+ const body = (await c.req.json().catch(() => null)) as {
106
+ source?: unknown;
107
+ ref?: unknown;
108
+ overwrite?: unknown;
109
+ } | null;
110
+ if (typeof body?.source !== "string" || !body.source.trim()) {
111
+ return c.json(
112
+ { error: "Body needs `source`: owner/repo or a github.com URL." },
113
+ 400
114
+ );
115
+ }
116
+ const parsed = parseGitHubSource(body.source);
117
+ if (!parsed) {
118
+ return c.json(
119
+ { error: "Could not parse the source — use owner/repo or a github.com URL." },
120
+ 400
121
+ );
122
+ }
123
+ if (typeof body.ref === "string" && body.ref.trim()) parsed.ref = body.ref.trim();
124
+ try {
125
+ const outcomes = await installSkillsFromGitHub({ brainPath: deps.brainPath }, parsed, {
126
+ overwrite: body.overwrite === true,
127
+ token: githubToken(),
128
+ ...(deps.fetcher ? { fetcher: deps.fetcher } : {}),
129
+ });
130
+ const warning = outcomes.some((o) => o.status !== "skipped")
131
+ ? await syncAfterMutation()
132
+ : undefined;
133
+ return c.json({ outcomes, ...(warning ? { warning } : {}) });
134
+ } catch (err) {
135
+ if (err instanceof InstallError) return c.json({ error: err.message }, 400);
136
+ const { status, message } = errorResponse(err);
137
+ return c.json({ error: message }, status);
138
+ }
139
+ })
140
+ .get("/skills/:name", (c) => {
141
+ try {
142
+ return c.json(manager.get(c.req.param("name")));
143
+ } catch (err) {
144
+ const { status, message } = errorResponse(err);
145
+ return c.json({ error: message }, status);
146
+ }
147
+ })
148
+ .post("/skills", async (c) => {
149
+ const body = (await c.req.json().catch(() => null)) as {
150
+ name?: unknown;
151
+ content?: unknown;
152
+ } | null;
153
+ if (typeof body?.name !== "string" || typeof body?.content !== "string") {
154
+ return c.json({ error: "Body needs string `name` and `content`." }, 400);
155
+ }
156
+ try {
157
+ const entry = manager.create(body.name, body.content);
158
+ const warning = await syncAfterMutation();
159
+ return c.json({ skill: entry, ...(warning ? { warning } : {}) }, 201);
160
+ } catch (err) {
161
+ const { status, message } = errorResponse(err);
162
+ return c.json({ error: message }, status);
163
+ }
164
+ })
165
+ .put("/skills/:name", async (c) => {
166
+ const body = (await c.req.json().catch(() => null)) as { content?: unknown } | null;
167
+ if (typeof body?.content !== "string") {
168
+ return c.json({ error: "Body needs string `content`." }, 400);
169
+ }
170
+ try {
171
+ const entry = manager.update(c.req.param("name"), body.content);
172
+ const warning = await syncAfterMutation();
173
+ return c.json({ skill: entry, ...(warning ? { warning } : {}) });
174
+ } catch (err) {
175
+ const { status, message } = errorResponse(err);
176
+ return c.json({ error: message }, status);
177
+ }
178
+ })
179
+ .post("/skills/:name/enabled", async (c) => {
180
+ const body = (await c.req.json().catch(() => null)) as { enabled?: unknown } | null;
181
+ if (typeof body?.enabled !== "boolean") {
182
+ return c.json({ error: "Body needs boolean `enabled`." }, 400);
183
+ }
184
+ try {
185
+ const entry = manager.setEnabled(c.req.param("name"), body.enabled);
186
+ const warning = await syncAfterMutation();
187
+ return c.json({ skill: entry, ...(warning ? { warning } : {}) });
188
+ } catch (err) {
189
+ const { status, message } = errorResponse(err);
190
+ return c.json({ error: message }, status);
191
+ }
192
+ })
193
+ .delete("/skills/:name", async (c) => {
194
+ try {
195
+ manager.remove(c.req.param("name"));
196
+ const warning = await syncAfterMutation();
197
+ return c.json({ ok: true, ...(warning ? { warning } : {}) });
198
+ } catch (err) {
199
+ const { status, message } = errorResponse(err);
200
+ return c.json({ error: message }, status);
201
+ }
202
+ });
203
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Management surface for the user's remembered "always allow" tool grants —
3
+ * the Settings screen's view of what the approval cards' "Always allow"
4
+ * button has accumulated, and the way to take a grant back.
5
+ *
6
+ * The grants themselves are consulted by the ws bridge (an auto-allowed tool
7
+ * never raises a card); this surface only lists and removes. Mount BEHIND
8
+ * the /api auth guard: the list shapes what the agent can do unprompted.
9
+ */
10
+
11
+ import { Hono } from "hono";
12
+ import type { Database } from "bun:sqlite";
13
+ import type { Logger } from "@opentelemetry/api-logs";
14
+ import { getAutoAllowedTools, setAutoAllowedTools } from "../db/settings.js";
15
+
16
+ export function createToolPermissionRoutes(deps: {
17
+ db: Database;
18
+ log?: Logger;
19
+ }): Hono {
20
+ const { db, log } = deps;
21
+ return new Hono()
22
+ .get("/tool-permissions", (c) =>
23
+ c.json({ tools: getAutoAllowedTools(db, log) })
24
+ )
25
+ .delete("/tool-permissions/:tool", (c) => {
26
+ const tool = decodeURIComponent(c.req.param("tool"));
27
+ const current = getAutoAllowedTools(db, log);
28
+ if (!current.includes(tool)) {
29
+ return c.json({ error: "Not an auto-allowed tool." }, 404);
30
+ }
31
+ setAutoAllowedTools(
32
+ db,
33
+ current.filter((t) => t !== tool)
34
+ );
35
+ return c.json({ tools: getAutoAllowedTools(db, log) });
36
+ });
37
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Skill installation from archives — the "install" half of the Settings →
3
+ * Skills surface: a user-uploaded ZIP, or a GitHub repository (public, or
4
+ * private via the deployment's GITHUB_TOKEN) fetched as a zipball. Both run
5
+ * through ONE pipeline so they share the same guarantees:
6
+ *
7
+ * - A skill is any directory in the archive containing a SKILL.md (the
8
+ * archive root included). One archive may carry several skills.
9
+ * - The INSTALLED name comes from the SKILL.md frontmatter (validated like a
10
+ * created skill), not from whatever the archive called the folder.
11
+ * - Zip-slip is structurally impossible: entry paths are normalized and any
12
+ * `..`, absolute path, backslash, or NUL rejects the archive; files are
13
+ * written only under `.agents/skills/<name>/`, staged then swapped so a
14
+ * half-written skill is never discoverable.
15
+ * - Caps: archive ≤ 20 MB compressed, ≤ 50 MB inflated, ≤ 400 files per
16
+ * skill, per-file ≤ 10 MB.
17
+ * - Conflicts are SKIPPED by default and reported; `overwrite: true` may
18
+ * replace an existing CUSTOM skill (enabled or disabled) but can never
19
+ * touch a package skill — those are symlinks and stay refused.
20
+ */
21
+
22
+ import { unzipSync } from "fflate";
23
+ import { existsSync, lstatSync, mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
24
+ import { dirname, join } from "path";
25
+ import matter from "gray-matter";
26
+
27
+ export const MAX_ARCHIVE_BYTES = 20 * 1024 * 1024;
28
+ export const MAX_INFLATED_BYTES = 50 * 1024 * 1024;
29
+ export const MAX_FILES_PER_SKILL = 400;
30
+ export const MAX_FILE_BYTES = 10 * 1024 * 1024;
31
+
32
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
33
+
34
+ export class InstallError extends Error {}
35
+
36
+ export interface InstallOutcome {
37
+ /** Installed-as name (frontmatter), or the archive folder for failures. */
38
+ name: string;
39
+ status: "installed" | "replaced" | "skipped";
40
+ reason?: string;
41
+ files?: number;
42
+ }
43
+
44
+ export interface InstallOptions {
45
+ overwrite?: boolean;
46
+ /** Only consider entries under this archive subpath (GitHub `tree` URLs). */
47
+ subpath?: string;
48
+ }
49
+
50
+ export interface InstallDeps {
51
+ brainPath: string;
52
+ }
53
+
54
+ /** A safe, normalized archive path or null. Rejects slip vectors outright. */
55
+ function safeEntryPath(raw: string): string | null {
56
+ if (raw.includes("\\") || raw.includes("\0") || raw.startsWith("/")) return null;
57
+ const parts = raw.split("/").filter((p) => p !== "" && p !== ".");
58
+ if (parts.some((p) => p === ".." || p.length > 128)) return null;
59
+ return parts.join("/");
60
+ }
61
+
62
+ function existsAs(p: string): "dir" | "symlink" | null {
63
+ try {
64
+ const st = lstatSync(p);
65
+ return st.isSymbolicLink() ? "symlink" : st.isDirectory() ? "dir" : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The shared core: install every skill found among already-unzipped entries.
73
+ * Throws InstallError for archive-level problems; per-skill problems land as
74
+ * skipped outcomes.
75
+ */
76
+ export function installSkillsFromEntries(
77
+ deps: InstallDeps,
78
+ entries: Record<string, Uint8Array>,
79
+ opts: InstallOptions = {}
80
+ ): InstallOutcome[] {
81
+ // Normalize + guard every entry, enforcing the inflated cap.
82
+ const files = new Map<string, Uint8Array>();
83
+ let inflated = 0;
84
+ for (const [raw, bytes] of Object.entries(entries)) {
85
+ if (raw.endsWith("/")) continue; // directory marker
86
+ const path = safeEntryPath(raw);
87
+ if (path === null) {
88
+ throw new InstallError(`Archive contains an unsafe path: ${JSON.stringify(raw)}`);
89
+ }
90
+ if (bytes.length > MAX_FILE_BYTES) {
91
+ throw new InstallError(`${path} exceeds ${MAX_FILE_BYTES / 1024 / 1024}MB.`);
92
+ }
93
+ inflated += bytes.length;
94
+ if (inflated > MAX_INFLATED_BYTES) {
95
+ throw new InstallError("Archive inflates past the 50MB cap.");
96
+ }
97
+ files.set(path, bytes);
98
+ }
99
+
100
+ // Candidate skill roots: every directory (or the root) holding a SKILL.md,
101
+ // optionally filtered to a subpath.
102
+ const prefix = opts.subpath ? opts.subpath.replace(/^\/+|\/+$/g, "") + "/" : "";
103
+ const roots = [...files.keys()]
104
+ .filter((p) => p === "SKILL.md" || p.endsWith("/SKILL.md"))
105
+ .map((p) => (p === "SKILL.md" ? "" : p.slice(0, -"/SKILL.md".length)))
106
+ .filter((root) => (prefix ? (root + "/").startsWith(prefix) : true))
107
+ .sort();
108
+ if (roots.length === 0) {
109
+ throw new InstallError(
110
+ "No skill found: no directory containing a SKILL.md" +
111
+ (opts.subpath ? ` under "${opts.subpath}"` : "") +
112
+ "."
113
+ );
114
+ }
115
+
116
+ const enabledDir = join(deps.brainPath, ".agents", "skills");
117
+ const disabledDir = join(deps.brainPath, ".agents", "skills-disabled");
118
+ mkdirSync(enabledDir, { recursive: true });
119
+
120
+ const outcomes: InstallOutcome[] = [];
121
+ for (const root of roots) {
122
+ const rootPrefix = root === "" ? "" : root + "/";
123
+ const label = root || "(archive root)";
124
+ // Each file belongs to its DEEPEST skill root, so nested skills never
125
+ // double-install into their parent.
126
+ const deeper = roots.filter((r) => r !== root && r.startsWith(rootPrefix));
127
+ const skillFiles = new Map<string, Uint8Array>();
128
+ for (const [path, bytes] of files) {
129
+ if (!path.startsWith(rootPrefix)) continue;
130
+ if (deeper.some((d) => path.startsWith(d + "/"))) continue;
131
+ skillFiles.set(path.slice(rootPrefix.length), bytes);
132
+ }
133
+
134
+ outcomes.push(installOne(label, skillFiles, enabledDir, disabledDir, opts));
135
+ }
136
+ return outcomes;
137
+ }
138
+
139
+ function installOne(
140
+ label: string,
141
+ skillFiles: Map<string, Uint8Array>,
142
+ enabledDir: string,
143
+ disabledDir: string,
144
+ opts: InstallOptions
145
+ ): InstallOutcome {
146
+ const skillMd = skillFiles.get("SKILL.md");
147
+ if (!skillMd) return { name: label, status: "skipped", reason: "SKILL.md unreadable" };
148
+
149
+ let data: Record<string, unknown>;
150
+ try {
151
+ data = matter(Buffer.from(skillMd).toString("utf-8")).data as Record<string, unknown>;
152
+ } catch (e) {
153
+ return {
154
+ name: label,
155
+ status: "skipped",
156
+ reason: `frontmatter does not parse: ${e instanceof Error ? e.message : String(e)}`,
157
+ };
158
+ }
159
+ const name = typeof data.name === "string" ? data.name : "";
160
+ if (!NAME_PATTERN.test(name)) {
161
+ return {
162
+ name: label,
163
+ status: "skipped",
164
+ reason: "frontmatter `name` missing or not lowercase kebab-case",
165
+ };
166
+ }
167
+ if (typeof data.description !== "string" || !data.description.trim()) {
168
+ return { name, status: "skipped", reason: "frontmatter has no `description`" };
169
+ }
170
+ if (skillFiles.size > MAX_FILES_PER_SKILL) {
171
+ return { name, status: "skipped", reason: `more than ${MAX_FILES_PER_SKILL} files` };
172
+ }
173
+
174
+ const target = join(enabledDir, name);
175
+ const disabledTarget = join(disabledDir, name);
176
+ if (existsAs(target) === "symlink") {
177
+ return {
178
+ name,
179
+ status: "skipped",
180
+ reason: "a built-in (package) skill has this name; rename yours in the frontmatter",
181
+ };
182
+ }
183
+ const existing = existsAs(target) === "dir" || existsAs(disabledTarget) === "dir";
184
+ if (existing && !opts.overwrite) {
185
+ return { name, status: "skipped", reason: "already installed (enable overwrite to replace)" };
186
+ }
187
+
188
+ // Stage into a temp sibling, then swap — a half-written skill dir must
189
+ // never be what an agent discovers.
190
+ const staging = join(enabledDir, `.install-${name}-${process.pid}`);
191
+ rmSync(staging, { recursive: true, force: true });
192
+ try {
193
+ for (const [rel, bytes] of skillFiles) {
194
+ const dest = join(staging, rel);
195
+ mkdirSync(dirname(dest), { recursive: true });
196
+ writeFileSync(dest, bytes);
197
+ }
198
+ rmSync(target, { recursive: true, force: true });
199
+ if (existsSync(disabledTarget)) rmSync(disabledTarget, { recursive: true, force: true });
200
+ renameSync(staging, target);
201
+ return { name, status: existing ? "replaced" : "installed", files: skillFiles.size };
202
+ } catch (e) {
203
+ rmSync(staging, { recursive: true, force: true });
204
+ return {
205
+ name,
206
+ status: "skipped",
207
+ reason: `write failed: ${e instanceof Error ? e.message : String(e)}`,
208
+ };
209
+ }
210
+ }
211
+
212
+ /** Install every skill found in an uploaded ZIP archive. */
213
+ export function installSkillsFromZip(
214
+ deps: InstallDeps,
215
+ archive: Uint8Array,
216
+ opts: InstallOptions = {}
217
+ ): InstallOutcome[] {
218
+ if (archive.length > MAX_ARCHIVE_BYTES) {
219
+ throw new InstallError(`Archive exceeds ${MAX_ARCHIVE_BYTES / 1024 / 1024}MB.`);
220
+ }
221
+ let entries: Record<string, Uint8Array>;
222
+ try {
223
+ entries = unzipSync(archive);
224
+ } catch (e) {
225
+ throw new InstallError(
226
+ `Not a readable ZIP archive: ${e instanceof Error ? e.message : String(e)}`
227
+ );
228
+ }
229
+ return installSkillsFromEntries(deps, entries, opts);
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // GitHub
234
+ // ---------------------------------------------------------------------------
235
+
236
+ export interface GitHubSource {
237
+ owner: string;
238
+ repo: string;
239
+ ref?: string;
240
+ /** Repo subpath to search for skills (from a /tree/<ref>/<path> URL). */
241
+ subpath?: string;
242
+ }
243
+
244
+ /**
245
+ * Parse "owner/repo", a github.com URL, or a /tree/<ref>/<subpath> URL.
246
+ * The ref/subpath split is heuristic for URLs (a ref containing "/" cannot
247
+ * be told apart from a path); pass an explicit ref for those.
248
+ */
249
+ export function parseGitHubSource(input: string): GitHubSource | null {
250
+ const trimmed = input.trim();
251
+ const bare = /^([\w.-]+)\/([\w.-]+)$/.exec(trimmed);
252
+ if (bare) return { owner: bare[1]!, repo: bare[2]!.replace(/\.git$/, "") };
253
+ let url: URL;
254
+ try {
255
+ url = new URL(trimmed);
256
+ } catch {
257
+ return null;
258
+ }
259
+ if (url.hostname !== "github.com" && url.hostname !== "www.github.com") return null;
260
+ const parts = url.pathname.split("/").filter(Boolean);
261
+ if (parts.length < 2) return null;
262
+ const owner = parts[0]!;
263
+ const repo = parts[1]!.replace(/\.git$/, "");
264
+ if (parts.length >= 4 && (parts[2] === "tree" || parts[2] === "blob")) {
265
+ const ref = parts[3]!;
266
+ const subpath = parts.slice(4).join("/") || undefined;
267
+ return { owner, repo, ref, ...(subpath ? { subpath } : {}) };
268
+ }
269
+ return { owner, repo };
270
+ }
271
+
272
+ /** Injectable for tests; production passes globalThis.fetch. */
273
+ export type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
274
+
275
+ /**
276
+ * Download a repo zipball (the API endpoint — works for private repos with a
277
+ * token, follows the codeload redirect) and install through the shared
278
+ * pipeline. GitHub zipballs prefix every entry with `owner-repo-sha/`; that
279
+ * wrapper folder is stripped before candidate discovery so subpaths from
280
+ * /tree/ URLs match repo-relative paths.
281
+ */
282
+ export async function installSkillsFromGitHub(
283
+ deps: InstallDeps,
284
+ source: GitHubSource,
285
+ opts: { overwrite?: boolean; token?: string; fetcher?: Fetcher } = {}
286
+ ): Promise<InstallOutcome[]> {
287
+ const fetcher = opts.fetcher ?? fetch;
288
+ const ref = source.ref ? `/${encodeURIComponent(source.ref)}` : "";
289
+ const url = `https://api.github.com/repos/${encodeURIComponent(source.owner)}/${encodeURIComponent(source.repo)}/zipball${ref}`;
290
+ const res = await fetcher(url, {
291
+ headers: {
292
+ Accept: "application/vnd.github+json",
293
+ "User-Agent": "brain-kit-ui",
294
+ ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
295
+ },
296
+ redirect: "follow",
297
+ });
298
+ if (res.status === 404) {
299
+ throw new InstallError(
300
+ `GitHub says 404 for ${source.owner}/${source.repo}` +
301
+ (opts.token
302
+ ? "."
303
+ : " — if the repository is private, set GITHUB_TOKEN on the server.")
304
+ );
305
+ }
306
+ if (!res.ok) {
307
+ throw new InstallError(`GitHub zipball fetch failed: HTTP ${res.status}`);
308
+ }
309
+ const buf = new Uint8Array(await res.arrayBuffer());
310
+ if (buf.length > MAX_ARCHIVE_BYTES) {
311
+ throw new InstallError(`Repository archive exceeds ${MAX_ARCHIVE_BYTES / 1024 / 1024}MB.`);
312
+ }
313
+
314
+ let entries: Record<string, Uint8Array>;
315
+ try {
316
+ entries = unzipSync(buf);
317
+ } catch (e) {
318
+ throw new InstallError(
319
+ `GitHub returned an unreadable archive: ${e instanceof Error ? e.message : String(e)}`
320
+ );
321
+ }
322
+ const stripped: Record<string, Uint8Array> = {};
323
+ for (const [key, bytes] of Object.entries(entries)) {
324
+ const slash = key.indexOf("/");
325
+ if (slash === -1) continue; // nothing lives outside the wrapper dir
326
+ stripped[key.slice(slash + 1)] = bytes;
327
+ }
328
+ return installSkillsFromEntries(deps, stripped, {
329
+ ...(opts.overwrite !== undefined ? { overwrite: opts.overwrite } : {}),
330
+ ...(source.subpath ? { subpath: source.subpath } : {}),
331
+ });
332
+ }