Skip to content

Commit f350442

Browse files
committed
feat: import skills from public GitHub
1 parent 826c60f commit f350442

File tree

13 files changed

+1673
-3
lines changed

13 files changed

+1673
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66
- Web: dynamic OG image cards for skills (name, description, version).
77
- CLI: auto-scan Clawdbot skill roots (per-agent workspaces, shared skills, extraDirs).
8+
- Web: import skills from public GitHub URLs (auto-detect `SKILL.md`, smart file selection, provenance).
89

910
### Fixed
1011
- Web: stabilize skill OG image generation on server runtimes.

convex/_generated/api.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type * as crons from "../crons.js";
1414
import type * as downloads from "../downloads.js";
1515
import type * as githubBackups from "../githubBackups.js";
1616
import type * as githubBackupsNode from "../githubBackupsNode.js";
17+
import type * as githubImport from "../githubImport.js";
1718
import type * as http from "../http.js";
1819
import type * as httpApi from "../httpApi.js";
1920
import type * as httpApiV1 from "../httpApiV1.js";
@@ -22,6 +23,7 @@ import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
2223
import type * as lib_changelog from "../lib/changelog.js";
2324
import type * as lib_embeddings from "../lib/embeddings.js";
2425
import type * as lib_githubBackup from "../lib/githubBackup.js";
26+
import type * as lib_githubImport from "../lib/githubImport.js";
2527
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
2628
import type * as lib_skillPublish from "../lib/skillPublish.js";
2729
import type * as lib_skills from "../lib/skills.js";
@@ -51,6 +53,7 @@ declare const fullApi: ApiFromModules<{
5153
downloads: typeof downloads;
5254
githubBackups: typeof githubBackups;
5355
githubBackupsNode: typeof githubBackupsNode;
56+
githubImport: typeof githubImport;
5457
http: typeof http;
5558
httpApi: typeof httpApi;
5659
httpApiV1: typeof httpApiV1;
@@ -59,6 +62,7 @@ declare const fullApi: ApiFromModules<{
5962
"lib/changelog": typeof lib_changelog;
6063
"lib/embeddings": typeof lib_embeddings;
6164
"lib/githubBackup": typeof lib_githubBackup;
65+
"lib/githubImport": typeof lib_githubImport;
6266
"lib/skillBackfill": typeof lib_skillBackfill;
6367
"lib/skillPublish": typeof lib_skillPublish;
6468
"lib/skills": typeof lib_skills;

convex/githubImport.ts

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { ConvexError, v } from 'convex/values'
2+
import { unzipSync } from 'fflate'
3+
import semver from 'semver'
4+
import { api, internal } from './_generated/api'
5+
import type { Id } from './_generated/dataModel'
6+
import type { ActionCtx } from './_generated/server'
7+
import { action } from './_generated/server'
8+
import { requireUserFromAction } from './lib/access'
9+
import {
10+
buildGitHubImportFileList,
11+
computeDefaultSelectedPaths,
12+
detectGitHubImportCandidates,
13+
fetchGitHubZipBytes,
14+
listTextFilesUnderCandidate,
15+
normalizeRepoPath,
16+
parseGitHubImportUrl,
17+
resolveGitHubCommit,
18+
stripGitHubZipRoot,
19+
suggestDisplayName,
20+
suggestVersion,
21+
} from './lib/githubImport'
22+
import { publishVersionForUser } from './lib/skillPublish'
23+
import { sanitizePath } from './lib/skills'
24+
25+
const MAX_SELECTED_BYTES = 50 * 1024 * 1024
26+
const MAX_UNZIPPED_BYTES = 80 * 1024 * 1024
27+
const MAX_FILE_COUNT = 7_500
28+
const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024
29+
30+
export const previewGitHubImport = action({
31+
args: { url: v.string() },
32+
handler: async (ctx, args) => {
33+
await requireUserFromAction(ctx)
34+
35+
const parsed = parseGitHubImportUrl(args.url)
36+
const resolved = await resolveGitHubCommit(parsed, fetch)
37+
const zipBytes = await fetchGitHubZipBytes(resolved, fetch)
38+
const entries = unzipToEntries(zipBytes)
39+
const stripped = stripGitHubZipRoot(entries)
40+
const candidates = detectGitHubImportCandidates(stripped).filter((candidate) =>
41+
isCandidateUnderResolvedPath(candidate.path, resolved.path),
42+
)
43+
if (candidates.length === 0) throw new ConvexError('No SKILL.md found in this repo')
44+
45+
return {
46+
resolved,
47+
candidates: candidates.map((candidate) => ({
48+
path: candidate.path,
49+
readmePath: candidate.readmePath,
50+
name: candidate.name ?? null,
51+
description: candidate.description ?? null,
52+
})),
53+
}
54+
},
55+
})
56+
57+
export const previewGitHubImportCandidate = action({
58+
args: { url: v.string(), candidatePath: v.string() },
59+
handler: async (ctx, args) => {
60+
const { userId } = await requireUserFromAction(ctx)
61+
62+
const parsed = parseGitHubImportUrl(args.url)
63+
const resolved = await resolveGitHubCommit(parsed, fetch)
64+
const zipBytes = await fetchGitHubZipBytes(resolved, fetch)
65+
const entries = unzipToEntries(zipBytes)
66+
const stripped = stripGitHubZipRoot(entries)
67+
68+
const normalizedCandidatePath = normalizeRepoPath(args.candidatePath)
69+
if (!isCandidateUnderResolvedPath(normalizedCandidatePath, resolved.path)) {
70+
throw new ConvexError('Candidate path is outside the requested import scope')
71+
}
72+
73+
const candidates = detectGitHubImportCandidates(stripped).filter((candidate) =>
74+
isCandidateUnderResolvedPath(candidate.path, resolved.path),
75+
)
76+
77+
const candidate = candidates.find((item) => item.path === normalizedCandidatePath)
78+
if (!candidate) throw new ConvexError('Candidate not found')
79+
80+
const files = listTextFilesUnderCandidate(stripped, candidate.path)
81+
const defaultSelectedPaths = computeDefaultSelectedPaths({ candidate, files })
82+
const fileList = buildGitHubImportFileList({
83+
candidate,
84+
files,
85+
defaultSelectedPaths,
86+
})
87+
88+
const baseForNaming = candidate.path ? (candidate.path.split('/').at(-1) ?? '') : resolved.repo
89+
const suggestedDisplayName = suggestDisplayName(candidate, baseForNaming)
90+
91+
const rawSlugBase = sanitizeSlug(candidate.path ? baseForNaming : resolved.repo)
92+
const suggestedSlug = await suggestAvailableSlug(ctx, userId, rawSlugBase)
93+
94+
const existing = await ctx.runQuery(api.skills.getBySlug, { slug: suggestedSlug })
95+
const existingLatest =
96+
existing?.skill && existing.skill.ownerUserId === userId
97+
? (existing.latestVersion?.version ?? null)
98+
: null
99+
const suggestedVersion = suggestVersion(existingLatest)
100+
101+
return {
102+
resolved,
103+
candidate: {
104+
path: candidate.path,
105+
readmePath: candidate.readmePath,
106+
name: candidate.name ?? null,
107+
description: candidate.description ?? null,
108+
},
109+
defaults: {
110+
selectedPaths: defaultSelectedPaths,
111+
slug: suggestedSlug,
112+
displayName: suggestedDisplayName,
113+
version: suggestedVersion,
114+
tags: ['latest'],
115+
},
116+
files: fileList,
117+
}
118+
},
119+
})
120+
121+
export const importGitHubSkill = action({
122+
args: {
123+
url: v.string(),
124+
commit: v.string(),
125+
candidatePath: v.string(),
126+
selectedPaths: v.array(v.string()),
127+
slug: v.optional(v.string()),
128+
displayName: v.optional(v.string()),
129+
version: v.optional(v.string()),
130+
tags: v.optional(v.array(v.string())),
131+
},
132+
handler: async (ctx, args) => {
133+
const { userId } = await requireUserFromAction(ctx)
134+
135+
const parsed = parseGitHubImportUrl(args.url)
136+
const resolved = await resolveGitHubCommit(parsed, fetch)
137+
if (!/^[a-f0-9]{40}$/i.test(args.commit)) throw new ConvexError('Invalid commit')
138+
if (args.commit.toLowerCase() !== resolved.commit.toLowerCase()) {
139+
throw new ConvexError('Import is out of date. Re-run preview.')
140+
}
141+
142+
const normalizedCandidatePath = normalizeRepoPath(args.candidatePath)
143+
if (!isCandidateUnderResolvedPath(normalizedCandidatePath, resolved.path)) {
144+
throw new ConvexError('Candidate path is outside the requested import scope')
145+
}
146+
147+
const zipBytes = await fetchGitHubZipBytes(resolved, fetch)
148+
const entries = stripGitHubZipRoot(unzipToEntries(zipBytes))
149+
150+
const candidates = detectGitHubImportCandidates(entries).filter((candidate) =>
151+
isCandidateUnderResolvedPath(candidate.path, resolved.path),
152+
)
153+
const candidate = candidates.find((item) => item.path === normalizedCandidatePath)
154+
if (!candidate) throw new ConvexError('Candidate not found')
155+
156+
const filesUnderCandidate = listTextFilesUnderCandidate(entries, candidate.path)
157+
const byPath = new Map(filesUnderCandidate.map((file) => [file.path, file.bytes]))
158+
159+
const selected = Array.from(
160+
new Set(args.selectedPaths.map((path) => normalizeRepoPath(path)).filter(Boolean)),
161+
)
162+
if (selected.length === 0) throw new ConvexError('No files selected')
163+
164+
const candidateRoot = candidate.path ? `${candidate.path}/` : ''
165+
const normalizedReadmePath = normalizeRepoPath(candidate.readmePath)
166+
if (!selected.includes(normalizedReadmePath)) {
167+
throw new ConvexError('SKILL.md must be selected')
168+
}
169+
170+
let totalBytes = 0
171+
const storedFiles: Array<{
172+
path: string
173+
size: number
174+
storageId: Id<'_storage'>
175+
sha256: string
176+
contentType?: string
177+
}> = []
178+
179+
for (const path of selected.sort()) {
180+
if (candidateRoot && !path.startsWith(candidateRoot)) {
181+
throw new ConvexError('Selected file is outside the chosen skill folder')
182+
}
183+
184+
const bytes = byPath.get(path)
185+
if (!bytes) continue
186+
totalBytes += bytes.byteLength
187+
if (totalBytes > MAX_SELECTED_BYTES) throw new ConvexError('Selected files exceed 50MB limit')
188+
189+
const relPath = candidateRoot ? path.slice(candidateRoot.length) : path
190+
const sanitized = sanitizePath(relPath)
191+
if (!sanitized) throw new ConvexError('Invalid file paths')
192+
193+
const sha256 = await sha256Hex(bytes)
194+
const safeBytes = new Uint8Array(bytes)
195+
const storageId = await ctx.storage.store(new Blob([safeBytes], { type: 'text/plain' }))
196+
storedFiles.push({
197+
path: sanitized,
198+
size: bytes.byteLength,
199+
storageId,
200+
sha256,
201+
contentType: 'text/plain',
202+
})
203+
}
204+
205+
if (storedFiles.length === 0) throw new ConvexError('No files selected')
206+
207+
const slugBase = (args.slug ?? '').trim().toLowerCase()
208+
const displayName = (args.displayName ?? '').trim()
209+
const tags = (args.tags ?? ['latest']).map((tag) => tag.trim()).filter(Boolean)
210+
const version = (args.version ?? '').trim()
211+
212+
if (!slugBase) throw new ConvexError('Slug required')
213+
if (!displayName) throw new ConvexError('Display name required')
214+
if (!version || !semver.valid(version)) throw new ConvexError('Version must be valid semver')
215+
216+
const result = await publishVersionForUser(ctx, userId, {
217+
slug: slugBase,
218+
displayName,
219+
version,
220+
changelog: '',
221+
tags,
222+
files: storedFiles,
223+
source: {
224+
kind: 'github',
225+
url: resolved.originalUrl,
226+
repo: `${resolved.owner}/${resolved.repo}`,
227+
ref: resolved.ref,
228+
commit: resolved.commit,
229+
path: candidate.path,
230+
importedAt: Date.now(),
231+
},
232+
})
233+
234+
return { ok: true, slug: slugBase, version, ...result }
235+
},
236+
})
237+
238+
function unzipToEntries(zipBytes: Uint8Array) {
239+
const entries = unzipSync(zipBytes)
240+
const out: Record<string, Uint8Array> = {}
241+
const rawPaths = Object.keys(entries)
242+
if (rawPaths.length > MAX_FILE_COUNT) throw new ConvexError('Repo archive has too many files')
243+
let totalBytes = 0
244+
for (const [rawPath, bytes] of Object.entries(entries)) {
245+
const normalizedPath = normalizeZipPath(rawPath)
246+
if (!normalizedPath) continue
247+
if (isJunkPath(normalizedPath)) continue
248+
if (!bytes) continue
249+
if (bytes.byteLength > MAX_SINGLE_FILE_BYTES) continue
250+
totalBytes += bytes.byteLength
251+
if (totalBytes > MAX_UNZIPPED_BYTES) throw new ConvexError('Repo archive is too large')
252+
out[normalizedPath] = bytes
253+
}
254+
return out
255+
}
256+
257+
function isCandidateUnderResolvedPath(candidatePath: string, resolvedPath: string) {
258+
const root = normalizeRepoPath(resolvedPath)
259+
if (!root) return true
260+
if (!candidatePath) return false
261+
if (candidatePath === root) return true
262+
return candidatePath.startsWith(`${root}/`)
263+
}
264+
265+
function sanitizeSlug(value: string) {
266+
return value
267+
.trim()
268+
.toLowerCase()
269+
.replace(/[^a-z0-9-]+/g, '-')
270+
.replace(/^-+/, '')
271+
.replace(/-+$/, '')
272+
.replace(/--+/g, '-')
273+
}
274+
275+
async function suggestAvailableSlug(ctx: ActionCtx, userId: Id<'users'>, base: string) {
276+
const cleaned = sanitizeSlug(base)
277+
if (!cleaned) throw new ConvexError('Could not derive slug')
278+
for (let i = 0; i < 50; i += 1) {
279+
const candidate = i === 0 ? cleaned : `${cleaned}-${i + 1}`
280+
const existing = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug: candidate })
281+
if (!existing) return candidate
282+
if (existing.ownerUserId === userId) return candidate
283+
}
284+
throw new ConvexError('Could not find an available slug')
285+
}
286+
287+
async function sha256Hex(bytes: Uint8Array) {
288+
const normalized = new Uint8Array(bytes)
289+
const digest = await crypto.subtle.digest('SHA-256', normalized.buffer)
290+
return toHex(new Uint8Array(digest))
291+
}
292+
293+
function toHex(bytes: Uint8Array) {
294+
let out = ''
295+
for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
296+
return out
297+
}
298+
299+
function normalizeZipPath(path: string) {
300+
const normalized = path
301+
.replaceAll('\u0000', '')
302+
.replaceAll('\\', '/')
303+
.trim()
304+
.replace(/^\.\/+/, '')
305+
.replace(/^\/+/, '')
306+
if (!normalized) return ''
307+
if (normalized.includes('..')) return ''
308+
return normalized
309+
}
310+
311+
function isJunkPath(path: string) {
312+
const normalized = path.toLowerCase()
313+
if (normalized.startsWith('__macosx/')) return true
314+
if (normalized.endsWith('/.ds_store')) return true
315+
if (normalized === '.ds_store') return true
316+
return false
317+
}

0 commit comments

Comments
 (0)