diff --git a/convex/githubImport.test.ts b/convex/githubImport.test.ts index b14a961d..f474e6e5 100644 --- a/convex/githubImport.test.ts +++ b/convex/githubImport.test.ts @@ -1,9 +1,35 @@ /* @vitest-environment node */ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { internal } from "./_generated/api"; import { __test } from "./githubImport"; import { buildGitHubZipForTests } from "./lib/githubImport"; +vi.mock("./_generated/api", () => ({ + internal: { + githubIdentity: { + getGitHubProviderAccountIdInternal: Symbol("getGitHubProviderAccountIdInternal"), + }, + skills: { + getSkillBySlugInternal: Symbol("getSkillBySlugInternal"), + }, + }, +})); + +const originalGitHubToken = process.env.GITHUB_TOKEN; + describe("githubImport", () => { + beforeEach(() => { + delete process.env.GITHUB_TOKEN; + }); + + afterEach(() => { + if (originalGitHubToken) { + process.env.GITHUB_TOKEN = originalGitHubToken; + } else { + delete process.env.GITHUB_TOKEN; + } + }); + it("formats storage failure message with file context", () => { const message = __test.buildStoreFailureMessage("skill/SKILL.md", 123, new Error("disk full")); expect(message).toBe('Failed to store file "skill/SKILL.md" (123 bytes). disk full'); @@ -13,6 +39,15 @@ describe("githubImport", () => { expect(__test.buildPublishFailureMessage(new Error("slug exists"))).toBe( "Import failed during publish: slug exists. Check skill format, slug availability, and try again.", ); + expect( + __test.buildPublishFailureMessage( + new Error( + 'Uncaught ConvexError: Publisher handle "@local-owner" is already claimed at ensurePersonalPublisherForUser (../../convex/lib/publishers.ts:235:4)', + ), + ), + ).toBe( + 'Import failed during publish: Publisher handle "@local-owner" is already claimed. Check skill format, slug availability, and try again.', + ); expect(__test.buildPublishFailureMessage("unexpected")).toBe( "Import failed during publish: unexpected. Check skill format, slug availability, and try again.", ); @@ -33,4 +68,525 @@ describe("githubImport", () => { "demo-repo/skill/notes.md", ]); }); + + it("uses publish-supported text extensions for tree path imports", () => { + expect(__test.isPreviewFetchableTextPath("skill/SKILL.md")).toBe(true); + expect(__test.isPreviewFetchableTextPath("skill/icon.svg")).toBe(true); + expect(__test.isPreviewFetchableTextPath("skill/styles.scss")).toBe(true); + expect(__test.isPreviewFetchableTextPath("skill/install.ps1")).toBe(true); + expect(__test.isPreviewFetchableTextPath("skill/config.conf")).toBe(true); + expect(__test.isPreviewFetchableTextPath("skill/binary.exe")).toBe(false); + }); + + it("rejects a public repo owned by another GitHub account before repo lookup", async () => { + const ctx = { + runQuery: vi.fn().mockResolvedValue("123"), + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 123, + login: "vyctorbrzezowski", + avatar_url: "https://avatars.githubusercontent.com/u/123?v=4", + }), + }); + + await expect( + __test.requireOwnedPublicGitHubRepoForImport( + ctx as never, + "users:1" as never, + "someone-else", + "public-skill", + fetchMock as never, + ), + ).rejects.toThrow(/owned by your GitHub account/i); + + expect(ctx.runQuery).toHaveBeenCalledWith( + internal.githubIdentity.getGitHubProviderAccountIdInternal, + { userId: "users:1" }, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/user/123", + expect.objectContaining({ + headers: expect.objectContaining({ "User-Agent": "clawhub/github-import" }), + }), + ); + }); + + it("rejects a public repo when GitHub metadata owner id does not match the signed-in user", async () => { + const ctx = { + runQuery: vi.fn().mockResolvedValue("123"), + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + login: "vyctorbrzezowski", + avatar_url: "https://avatars.githubusercontent.com/u/123?v=4", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + name: "public-skill", + full_name: "vyctorbrzezowski/public-skill", + private: false, + visibility: "public", + owner: { id: 456, login: "vyctorbrzezowski" }, + }), + }); + + await expect( + __test.requireOwnedPublicGitHubRepoForImport( + ctx as never, + "users:1" as never, + "vyctorbrzezowski", + "public-skill", + fetchMock as never, + ), + ).rejects.toThrow(/owned by your GitHub account/i); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/repos/vyctorbrzezowski/public-skill", + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it("rejects direct URL preview from another public GitHub owner before repo lookup", async () => { + const ctx = { + runQuery: vi.fn().mockResolvedValue("123"), + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 123, + login: "vyctorbrzezowski", + avatar_url: "https://avatars.githubusercontent.com/u/123?v=4", + }), + }); + + await expect( + __test.previewGitHubImportForUser( + ctx as never, + "users:1" as never, + { url: "https://github.com/someone-else/public-skill" }, + fetchMock as never, + ), + ).rejects.toThrow(/owned by your GitHub account/i); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/user/123", + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it("rejects direct URL candidate preview from another public GitHub owner before repo lookup", async () => { + const ctx = { + runQuery: vi.fn().mockResolvedValue("123"), + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 123, + login: "vyctorbrzezowski", + avatar_url: "https://avatars.githubusercontent.com/u/123?v=4", + }), + }); + + await expect( + __test.previewGitHubImportCandidateForUser( + ctx as never, + "users:1" as never, + { + url: "https://github.com/someone-else/public-skill", + candidatePath: "", + }, + fetchMock as never, + ), + ).rejects.toThrow(/owned by your GitHub account/i); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/user/123", + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it("rejects direct URL publish from another public GitHub owner before repo lookup", async () => { + const ctx = { + runQuery: vi.fn().mockResolvedValue("123"), + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 123, + login: "vyctorbrzezowski", + avatar_url: "https://avatars.githubusercontent.com/u/123?v=4", + }), + }); + + await expect( + __test.importGitHubSkillForUser( + ctx as never, + "users:1" as never, + { + url: "https://github.com/someone-else/public-skill", + commit: "a".repeat(40), + candidatePath: "", + selectedPaths: ["SKILL.md"], + slug: "public-skill", + displayName: "Public Skill", + version: "1.0.0", + tags: ["latest"], + acceptLicenseTerms: true, + }, + fetchMock as never, + ), + ).rejects.toThrow(/owned by your GitHub account/i); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/user/123", + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it("lists only owned public skill file candidates", async () => { + const ctx = { + runQuery: vi.fn().mockResolvedValue("123"), + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + login: "vyctorbrzezowski", + avatar_url: "https://avatars.githubusercontent.com/u/123?v=4", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + name: "clawhub", + full_name: "vyctorbrzezowski/clawhub", + html_url: "https://github.com/vyctorbrzezowski/clawhub", + default_branch: "main", + pushed_at: "2026-05-27T00:00:00Z", + updated_at: "2026-05-27T00:00:00Z", + language: "TypeScript", + fork: false, + archived: false, + disabled: false, + private: false, + visibility: "public", + owner: { id: 123, login: "vyctorbrzezowski" }, + }, + { + name: "docs", + full_name: "vyctorbrzezowski/docs", + html_url: "https://github.com/vyctorbrzezowski/docs", + default_branch: "main", + pushed_at: "2026-05-26T00:00:00Z", + updated_at: "2026-05-26T00:00:00Z", + fork: false, + archived: false, + disabled: false, + private: false, + visibility: "public", + owner: { id: 123, login: "vyctorbrzezowski" }, + }, + { + name: "forked-skill", + full_name: "vyctorbrzezowski/forked-skill", + default_branch: "main", + fork: true, + archived: false, + disabled: false, + private: false, + visibility: "public", + owner: { id: 123, login: "vyctorbrzezowski" }, + }, + { + name: "archived-skill", + full_name: "vyctorbrzezowski/archived-skill", + default_branch: "main", + fork: false, + archived: true, + disabled: false, + private: false, + visibility: "public", + owner: { id: 123, login: "vyctorbrzezowski" }, + }, + { + name: "private-skill", + private: true, + visibility: "private", + owner: { id: 123, login: "vyctorbrzezowski" }, + }, + { + name: "org-skill", + private: false, + visibility: "public", + owner: { id: 456, login: "openclaw" }, + }, + ], + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + truncated: false, + tree: [ + { path: "SKILL.md", type: "blob" }, + { path: "skills/copilot/SKILL.md", type: "blob" }, + { path: "legacy/skills.md", type: "blob" }, + { path: ".agents/skills/internal/SKILL.md", type: "blob" }, + { path: "README.md", type: "blob" }, + { path: "skill.md", type: "tree" }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + truncated: false, + tree: [ + { path: "README.md", type: "blob" }, + { path: "guides/usage.md", type: "blob" }, + ], + }), + }); + + const result = await __test.listOwnedPublicGitHubReposForUser( + ctx as never, + "users:1" as never, + { page: 1, perPage: 30 }, + fetchMock as never, + ); + + expect(result.account.login).toBe("vyctorbrzezowski"); + expect(result.account.avatarUrl).toBe("https://avatars.githubusercontent.com/u/123?v=4"); + expect(result.repos).toEqual([ + expect.objectContaining({ + owner: "vyctorbrzezowski", + name: "clawhub", + repoName: "clawhub", + repoFullName: "vyctorbrzezowski/clawhub", + fullName: "vyctorbrzezowski/clawhub", + htmlUrl: "https://github.com/vyctorbrzezowski/clawhub", + candidatePath: "", + skillPath: "SKILL.md", + importable: true, + }), + expect.objectContaining({ + owner: "vyctorbrzezowski", + name: "copilot", + repoName: "clawhub", + repoFullName: "vyctorbrzezowski/clawhub", + fullName: "vyctorbrzezowski/clawhub/skills/copilot", + htmlUrl: "https://github.com/vyctorbrzezowski/clawhub/tree/main/skills/copilot", + candidatePath: "skills/copilot", + skillPath: "skills/copilot/SKILL.md", + importable: true, + }), + expect.objectContaining({ + owner: "vyctorbrzezowski", + name: "legacy", + repoName: "clawhub", + repoFullName: "vyctorbrzezowski/clawhub", + fullName: "vyctorbrzezowski/clawhub/legacy", + htmlUrl: "https://github.com/vyctorbrzezowski/clawhub/tree/main/legacy", + candidatePath: "legacy", + skillPath: "legacy/skills.md", + importable: true, + }), + ]); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.github.com/users/vyctorbrzezowski/repos?type=owner&sort=pushed&direction=desc&per_page=30&page=1", + expect.objectContaining({ headers: expect.any(Object) }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + "https://api.github.com/repos/vyctorbrzezowski/clawhub/git/trees/main?recursive=1", + expect.objectContaining({ headers: expect.any(Object) }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + "https://api.github.com/repos/vyctorbrzezowski/docs/git/trees/main?recursive=1", + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it("uses GitHub code search for owned skill file discovery when a token is configured", async () => { + process.env.GITHUB_TOKEN = "github-token"; + const ctx = { + runQuery: vi.fn().mockResolvedValue("123"), + }; + const ownedRepo = { + name: "skills", + full_name: "vyctorbrzezowski/skills", + html_url: "https://github.com/vyctorbrzezowski/skills", + default_branch: "main", + pushed_at: "2026-05-27T00:00:00Z", + updated_at: "2026-05-27T00:00:00Z", + fork: false, + archived: false, + disabled: false, + private: false, + visibility: "public", + owner: { id: 123, login: "vyctorbrzezowski" }, + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + login: "vyctorbrzezowski", + avatar_url: "https://avatars.githubusercontent.com/u/123?v=4", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + items: [ + { path: "SKILL.md", repository: ownedRepo }, + { path: "tools/review/SKILL.md", repository: ownedRepo }, + { path: ".agents/skills/internal/SKILL.md", repository: ownedRepo }, + { + path: "SKILL.md", + repository: { + ...ownedRepo, + name: "forked", + full_name: "vyctorbrzezowski/forked", + fork: true, + }, + }, + { path: "README.md", repository: ownedRepo }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + items: [{ path: "legacy/skills.md", repository: ownedRepo }], + }), + }); + + const result = await __test.listOwnedPublicGitHubReposForUser( + ctx as never, + "users:1" as never, + { page: 1, perPage: 30 }, + fetchMock as never, + ); + + expect(result.repos).toEqual([ + expect.objectContaining({ + name: "skills", + repoName: "skills", + candidatePath: "", + skillPath: "SKILL.md", + }), + expect.objectContaining({ + name: "review", + repoName: "skills", + candidatePath: "tools/review", + skillPath: "tools/review/SKILL.md", + }), + expect.objectContaining({ + name: "legacy", + repoName: "skills", + candidatePath: "legacy", + skillPath: "legacy/skills.md", + }), + ]); + expect(fetchMock).toHaveBeenCalledTimes(3); + const searchUrl = new URL(fetchMock.mock.calls[1]?.[0] as string); + expect(searchUrl.pathname).toBe("/search/code"); + expect(searchUrl.searchParams.get("q")).toBe("filename:SKILL.md user:vyctorbrzezowski"); + const legacySearchUrl = new URL(fetchMock.mock.calls[2]?.[0] as string); + expect(legacySearchUrl.pathname).toBe("/search/code"); + expect(legacySearchUrl.searchParams.get("q")).toBe("filename:skills.md user:vyctorbrzezowski"); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer github-token" }), + }), + ); + }); + + it("falls back to the repo archive when GitHub truncates the discovery tree", async () => { + const ctx = { + runQuery: vi.fn().mockResolvedValue("123"), + }; + const zip = buildGitHubZipForTests({ + "large-repo/tools/review/SKILL.md": "# Review", + "large-repo/tools/review/notes.md": "notes", + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + login: "vyctorbrzezowski", + avatar_url: "https://avatars.githubusercontent.com/u/123?v=4", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + name: "large-repo", + full_name: "vyctorbrzezowski/large-repo", + html_url: "https://github.com/vyctorbrzezowski/large-repo", + default_branch: "main", + fork: false, + archived: false, + disabled: false, + private: false, + visibility: "public", + owner: { id: 123, login: "vyctorbrzezowski" }, + }, + ], + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + truncated: true, + tree: [{ path: "README.md", type: "blob" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + headers: { get: () => null }, + arrayBuffer: async () => zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength), + }); + + const result = await __test.listOwnedPublicGitHubReposForUser( + ctx as never, + "users:1" as never, + { page: 1, perPage: 30 }, + fetchMock as never, + ); + + expect(result.repos).toEqual([ + expect.objectContaining({ + name: "review", + repoName: "large-repo", + candidatePath: "tools/review", + skillPath: "tools/review/SKILL.md", + }), + ]); + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + "https://codeload.github.com/vyctorbrzezowski/large-repo/zip/main", + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); }); diff --git a/convex/githubImport.ts b/convex/githubImport.ts index e5cb96a3..1f160552 100644 --- a/convex/githubImport.ts +++ b/convex/githubImport.ts @@ -11,6 +11,7 @@ import { computeDefaultSelectedPaths, detectGitHubImportCandidates, fetchGitHubZipBytes, + isGitHubSkillFilePath, listTextFilesUnderCandidate, normalizeRepoPath, parseGitHubImportUrl, @@ -20,104 +21,207 @@ import { suggestVersion, } from "./lib/githubImport"; import { publishVersionForUser } from "./lib/skillPublish"; -import { isMacJunkPath, sanitizePath } from "./lib/skills"; +import { isMacJunkPath, isTextFile, sanitizePath } from "./lib/skills"; const MAX_SELECTED_BYTES = 50 * 1024 * 1024; const MAX_UNZIPPED_BYTES = 80 * 1024 * 1024; const MAX_FILE_COUNT = 7_500; const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024; +const MAX_FALLBACK_DISCOVERY_REPOS_PER_PAGE = 30; +const GITHUB_API = "https://api.github.com"; +const OWNED_PUBLIC_REPO_ONLY_ERROR = + "You can only import public repositories owned by your GitHub account."; + +type GitHubUserPayload = { + id?: unknown; + login?: unknown; + avatar_url?: unknown; +}; + +type GitHubRepoPayload = { + name?: unknown; + full_name?: unknown; + html_url?: unknown; + default_branch?: unknown; + pushed_at?: unknown; + updated_at?: unknown; + language?: unknown; + fork?: unknown; + archived?: unknown; + disabled?: unknown; + private?: unknown; + visibility?: unknown; + owner?: { + id?: unknown; + login?: unknown; + }; +}; + +type GitHubTreePayload = { + tree?: unknown; + truncated?: unknown; +}; + +type GitHubCodeSearchPayload = { + items?: unknown; +}; + +type GitHubCodeSearchItemPayload = { + path?: unknown; + repository?: GitHubRepoPayload; +}; + +type GitHubTreeEntryPayload = { + path?: unknown; + type?: unknown; + sha?: unknown; + size?: unknown; +}; + +type GitHubIdentityForImport = { + providerAccountId: string; + login: string; + avatarUrl: string | null; +}; + +type OwnedPublicRepoListItem = { + owner: string; + name: string; + repoName: string; + repoFullName: string; + fullName: string; + htmlUrl: string; + defaultBranch: string | null; + pushedAt: string | null; + updatedAt: string | null; + language: string | null; + fork: boolean; + archived: boolean; + disabled: boolean; + visibility: "public"; + importable: boolean; + unavailableReason: string | null; + candidatePath: string; + skillPath: string; +}; + +export const listOwnedPublicGitHubRepos = action({ + args: { + query: v.optional(v.string()), + page: v.optional(v.number()), + perPage: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const { userId } = await requireUserFromAction(ctx); + return listOwnedPublicGitHubReposForUser(ctx, userId, args, fetch); + }, +}); export const previewGitHubImport = action({ args: { url: v.string() }, handler: async (ctx, args) => { - await requireUserFromAction(ctx); - - const parsed = parseGitHubImportUrl(args.url); - const resolved = await resolveGitHubCommit(parsed, fetch); - const zipBytes = await fetchGitHubZipBytes(resolved, fetch); - const entries = unzipToEntries(zipBytes); - const stripped = stripGitHubZipRoot(entries); - const candidates = detectGitHubImportCandidates(stripped).filter((candidate) => - isCandidateUnderResolvedPath(candidate.path, resolved.path), - ); - if (candidates.length === 0) throw new ConvexError("No SKILL.md found in this repo"); - - return { - resolved, - candidates: candidates.map((candidate) => ({ - path: candidate.path, - readmePath: candidate.readmePath, - name: candidate.name ?? null, - description: candidate.description ?? null, - })), - }; + const { userId } = await requireUserFromAction(ctx); + return previewGitHubImportForUser(ctx, userId, args, fetch); }, }); +async function previewGitHubImportForUser( + ctx: Pick, + userId: Id<"users">, + args: { url: string }, + fetcher: typeof fetch, +) { + const parsed = parseGitHubImportUrl(args.url); + await requireOwnedPublicGitHubRepoForImport(ctx, userId, parsed.owner, parsed.repo, fetcher); + const resolved = await resolveGitHubCommit(parsed, fetcher); + const entries = await fetchResolvedGitHubEntries(resolved, fetcher); + const candidates = detectGitHubImportCandidates(entries).filter((candidate) => + isCandidateUnderResolvedPath(candidate.path, resolved.path), + ); + if (candidates.length === 0) throw new ConvexError("No SKILL.md or skills.md found in this repo"); + + return { + resolved, + candidates: candidates.map((candidate) => ({ + path: candidate.path, + readmePath: candidate.readmePath, + name: candidate.name ?? null, + description: candidate.description ?? null, + })), + }; +} + export const previewGitHubImportCandidate = action({ args: { url: v.string(), candidatePath: v.string() }, handler: async (ctx, args) => { const { userId } = await requireUserFromAction(ctx); - - const parsed = parseGitHubImportUrl(args.url); - const resolved = await resolveGitHubCommit(parsed, fetch); - const zipBytes = await fetchGitHubZipBytes(resolved, fetch); - const entries = unzipToEntries(zipBytes); - const stripped = stripGitHubZipRoot(entries); - - const normalizedCandidatePath = normalizeRepoPath(args.candidatePath); - if (!isCandidateUnderResolvedPath(normalizedCandidatePath, resolved.path)) { - throw new ConvexError("Candidate path is outside the requested import scope"); - } - - const candidates = detectGitHubImportCandidates(stripped).filter((candidate) => - isCandidateUnderResolvedPath(candidate.path, resolved.path), - ); - - const candidate = candidates.find((item) => item.path === normalizedCandidatePath); - if (!candidate) throw new ConvexError("Candidate not found"); - - const files = listTextFilesUnderCandidate(stripped, candidate.path); - const defaultSelectedPaths = computeDefaultSelectedPaths({ candidate, files }); - const fileList = buildGitHubImportFileList({ - candidate, - files, - defaultSelectedPaths, - }); - - const baseForNaming = candidate.path ? (candidate.path.split("/").at(-1) ?? "") : resolved.repo; - const suggestedDisplayName = suggestDisplayName(candidate, baseForNaming); - - const rawSlugBase = sanitizeSlug(candidate.path ? baseForNaming : resolved.repo); - const suggestedSlug = await suggestAvailableSlug(ctx, userId, rawSlugBase); - - const existing = await ctx.runQuery(api.skills.getBySlug, { slug: suggestedSlug }); - const existingLatest = - existing?.skill && existing.skill.ownerUserId === userId - ? (existing.latestVersion?.version ?? null) - : null; - const suggestedVersion = suggestVersion(existingLatest); - - return { - resolved, - candidate: { - path: candidate.path, - readmePath: candidate.readmePath, - name: candidate.name ?? null, - description: candidate.description ?? null, - }, - defaults: { - selectedPaths: defaultSelectedPaths, - slug: suggestedSlug, - displayName: suggestedDisplayName, - version: suggestedVersion, - tags: ["latest"], - }, - files: fileList, - }; + return previewGitHubImportCandidateForUser(ctx, userId, args, fetch); }, }); +async function previewGitHubImportCandidateForUser( + ctx: ActionCtx, + userId: Id<"users">, + args: { url: string; candidatePath: string }, + fetcher: typeof fetch, +) { + const parsed = parseGitHubImportUrl(args.url); + await requireOwnedPublicGitHubRepoForImport(ctx, userId, parsed.owner, parsed.repo, fetcher); + const resolved = await resolveGitHubCommit(parsed, fetcher); + const entries = await fetchResolvedGitHubEntries(resolved, fetcher); + + const normalizedCandidatePath = normalizeRepoPath(args.candidatePath); + if (!isCandidateUnderResolvedPath(normalizedCandidatePath, resolved.path)) { + throw new ConvexError("Candidate path is outside the requested import scope"); + } + + const candidates = detectGitHubImportCandidates(entries).filter((candidate) => + isCandidateUnderResolvedPath(candidate.path, resolved.path), + ); + + const candidate = candidates.find((item) => item.path === normalizedCandidatePath); + if (!candidate) throw new ConvexError("Candidate not found"); + + const files = listTextFilesUnderCandidate(entries, candidate.path); + const defaultSelectedPaths = computeDefaultSelectedPaths({ candidate, files }); + const fileList = buildGitHubImportFileList({ + candidate, + files, + defaultSelectedPaths, + }); + + const baseForNaming = candidate.path ? (candidate.path.split("/").at(-1) ?? "") : resolved.repo; + const suggestedDisplayName = suggestDisplayName(candidate, baseForNaming); + + const rawSlugBase = sanitizeSlug(candidate.path ? baseForNaming : resolved.repo); + const suggestedSlug = await suggestAvailableSlug(ctx, userId, rawSlugBase); + + const existing = await ctx.runQuery(api.skills.getBySlug, { slug: suggestedSlug }); + const existingLatest = + existing?.skill && existing.skill.ownerUserId === userId + ? (existing.latestVersion?.version ?? null) + : null; + const suggestedVersion = suggestVersion(existingLatest); + + return { + resolved, + candidate: { + path: candidate.path, + readmePath: candidate.readmePath, + name: candidate.name ?? null, + description: candidate.description ?? null, + }, + defaults: { + selectedPaths: defaultSelectedPaths, + slug: suggestedSlug, + displayName: suggestedDisplayName, + version: suggestedVersion, + tags: ["latest"], + }, + files: fileList, + }; +} + export const importGitHubSkill = action({ args: { url: v.string(), @@ -128,131 +232,633 @@ export const importGitHubSkill = action({ displayName: v.optional(v.string()), version: v.optional(v.string()), tags: v.optional(v.array(v.string())), + icon: v.optional(v.string()), + acceptLicenseTerms: v.boolean(), }, handler: async (ctx, args) => { const { userId } = await requireUserFromAction(ctx); - - const parsed = parseGitHubImportUrl(args.url); - const resolved = await resolveGitHubCommit(parsed, fetch); - if (!/^[a-f0-9]{40}$/i.test(args.commit)) throw new ConvexError("Invalid commit"); - if (args.commit.toLowerCase() !== resolved.commit.toLowerCase()) { - throw new ConvexError("Import is out of date. Re-run preview."); - } - - const normalizedCandidatePath = normalizeRepoPath(args.candidatePath); - if (!isCandidateUnderResolvedPath(normalizedCandidatePath, resolved.path)) { - throw new ConvexError("Candidate path is outside the requested import scope"); - } - - const zipBytes = await fetchGitHubZipBytes(resolved, fetch); - const entries = stripGitHubZipRoot(unzipToEntries(zipBytes)); - - const candidates = detectGitHubImportCandidates(entries).filter((candidate) => - isCandidateUnderResolvedPath(candidate.path, resolved.path), - ); - const candidate = candidates.find((item) => item.path === normalizedCandidatePath); - if (!candidate) throw new ConvexError("Candidate not found"); - - const filesUnderCandidate = listTextFilesUnderCandidate(entries, candidate.path); - const byPath = new Map(filesUnderCandidate.map((file) => [file.path, file.bytes])); - - const selected = Array.from( - new Set(args.selectedPaths.map((path) => normalizeRepoPath(path)).filter(Boolean)), - ); - if (selected.length === 0) throw new ConvexError("No files selected"); - - const candidateRoot = candidate.path ? `${candidate.path}/` : ""; - const normalizedReadmePath = normalizeRepoPath(candidate.readmePath); - if (!selected.includes(normalizedReadmePath)) { - throw new ConvexError("SKILL.md must be selected"); - } - - let totalBytes = 0; - const storedFiles: Array<{ - path: string; - size: number; - storageId: Id<"_storage">; - sha256: string; - contentType?: string; - }> = []; - - for (const path of selected.sort()) { - if (candidateRoot && !path.startsWith(candidateRoot)) { - throw new ConvexError("Selected file is outside the chosen skill folder"); - } - - const bytes = byPath.get(path); - if (!bytes) continue; - totalBytes += bytes.byteLength; - if (totalBytes > MAX_SELECTED_BYTES) - throw new ConvexError("Selected files exceed 50MB limit"); - - const relPath = candidateRoot ? path.slice(candidateRoot.length) : path; - const sanitized = sanitizePath(relPath); - if (!sanitized) throw new ConvexError("Invalid file paths"); - - const sha256 = await sha256Hex(bytes); - const safeBytes = new Uint8Array(bytes); - let storageId: Id<"_storage">; - try { - storageId = await ctx.storage.store(new Blob([safeBytes], { type: "text/plain" })); - } catch (error) { - throw new ConvexError(buildStoreFailureMessage(sanitized, bytes.byteLength, error)); - } - storedFiles.push({ - path: sanitized, - size: bytes.byteLength, - storageId, - sha256, - contentType: "text/plain", - }); - } - - if (storedFiles.length === 0) throw new ConvexError("No files selected"); - - const slugBase = (args.slug ?? "").trim().toLowerCase(); - const displayName = (args.displayName ?? "").trim(); - const tags = (args.tags ?? ["latest"]).map((tag) => tag.trim()).filter(Boolean); - const version = (args.version ?? "").trim(); - - if (!slugBase) throw new ConvexError("Slug required"); - if (!displayName) throw new ConvexError("Display name required"); - if (!version || !semver.valid(version)) throw new ConvexError("Version must be valid semver"); - - const sourceProvenance = { - kind: "github" as const, - url: resolved.originalUrl, - repo: `${resolved.owner}/${resolved.repo}`, - ref: resolved.ref, - commit: resolved.commit, - path: candidate.path, - importedAt: Date.now(), - }; - - let result: Awaited>; - try { - result = await publishVersionForUser( - ctx, - userId, - { - slug: slugBase, - displayName, - version, - changelog: "", - tags, - files: storedFiles, - source: sourceProvenance, - }, - { sourceProvenance }, - ); - } catch (error) { - throw new ConvexError(buildPublishFailureMessage(error)); - } - - return { ok: true, slug: slugBase, version, ...result }; + return importGitHubSkillForUser(ctx, userId, args, fetch); }, }); +async function importGitHubSkillForUser( + ctx: ActionCtx, + userId: Id<"users">, + args: { + url: string; + commit: string; + candidatePath: string; + selectedPaths: string[]; + slug?: string; + displayName?: string; + version?: string; + tags?: string[]; + icon?: string; + acceptLicenseTerms: boolean; + }, + fetcher: typeof fetch, +) { + if (!args.acceptLicenseTerms) { + throw new ConvexError("MIT-0 license terms must be accepted to publish skills"); + } + + const parsed = parseGitHubImportUrl(args.url); + await requireOwnedPublicGitHubRepoForImport(ctx, userId, parsed.owner, parsed.repo, fetcher); + const resolved = await resolveGitHubCommit(parsed, fetcher); + if (!/^[a-f0-9]{40}$/i.test(args.commit)) throw new ConvexError("Invalid commit"); + if (args.commit.toLowerCase() !== resolved.commit.toLowerCase()) { + throw new ConvexError("Import is out of date. Re-run preview."); + } + + const normalizedCandidatePath = normalizeRepoPath(args.candidatePath); + if (!isCandidateUnderResolvedPath(normalizedCandidatePath, resolved.path)) { + throw new ConvexError("Candidate path is outside the requested import scope"); + } + + const entries = await fetchResolvedGitHubEntries(resolved, fetcher); + + const candidates = detectGitHubImportCandidates(entries).filter((candidate) => + isCandidateUnderResolvedPath(candidate.path, resolved.path), + ); + const candidate = candidates.find((item) => item.path === normalizedCandidatePath); + if (!candidate) throw new ConvexError("Candidate not found"); + + const filesUnderCandidate = listTextFilesUnderCandidate(entries, candidate.path); + const byPath = new Map(filesUnderCandidate.map((file) => [file.path, file.bytes])); + + const selected = Array.from( + new Set(args.selectedPaths.map((path) => normalizeRepoPath(path)).filter(Boolean)), + ); + if (selected.length === 0) throw new ConvexError("No files selected"); + + const candidateRoot = candidate.path ? `${candidate.path}/` : ""; + const normalizedReadmePath = normalizeRepoPath(candidate.readmePath); + if (!selected.includes(normalizedReadmePath)) { + throw new ConvexError("The skill file must be selected"); + } + + let totalBytes = 0; + const storedFiles: Array<{ + path: string; + size: number; + storageId: Id<"_storage">; + sha256: string; + contentType?: string; + }> = []; + + for (const path of selected.sort()) { + if (candidateRoot && !path.startsWith(candidateRoot)) { + throw new ConvexError("Selected file is outside the chosen skill folder"); + } + + const bytes = byPath.get(path); + if (!bytes) continue; + totalBytes += bytes.byteLength; + if (totalBytes > MAX_SELECTED_BYTES) throw new ConvexError("Selected files exceed 50MB limit"); + + const relPath = candidateRoot ? path.slice(candidateRoot.length) : path; + const sanitized = sanitizePath(relPath); + if (!sanitized) throw new ConvexError("Invalid file paths"); + + const sha256 = await sha256Hex(bytes); + const safeBytes = new Uint8Array(bytes); + let storageId: Id<"_storage">; + try { + storageId = await ctx.storage.store(new Blob([safeBytes], { type: "text/plain" })); + } catch (error) { + throw new ConvexError(buildStoreFailureMessage(sanitized, bytes.byteLength, error)); + } + storedFiles.push({ + path: sanitized, + size: bytes.byteLength, + storageId, + sha256, + contentType: "text/plain", + }); + } + + if (storedFiles.length === 0) throw new ConvexError("No files selected"); + + const slugBase = (args.slug ?? "").trim().toLowerCase(); + const displayName = (args.displayName ?? "").trim(); + const tags = (args.tags ?? ["latest"]).map((tag) => tag.trim()).filter(Boolean); + const version = (args.version ?? "").trim(); + + if (!slugBase) throw new ConvexError("Slug required"); + if (!displayName) throw new ConvexError("Display name required"); + if (!version || !semver.valid(version)) throw new ConvexError("Version must be valid semver"); + + const sourceProvenance = { + kind: "github" as const, + url: resolved.originalUrl, + repo: `${resolved.owner}/${resolved.repo}`, + ref: resolved.ref, + commit: resolved.commit, + path: candidate.path, + importedAt: Date.now(), + }; + + let result: Awaited>; + try { + result = await publishVersionForUser( + ctx, + userId, + { + slug: slugBase, + displayName, + version, + changelog: "", + tags, + icon: args.icon?.trim() || undefined, + files: storedFiles, + source: sourceProvenance, + }, + { sourceProvenance }, + ); + } catch (error) { + throw new ConvexError(buildPublishFailureMessage(error)); + } + + return { ok: true, slug: slugBase, version, ...result }; +} + +async function listOwnedPublicGitHubReposForUser( + ctx: Pick, + userId: Id<"users">, + args: { query?: string; page?: number; perPage?: number }, + fetcher: typeof fetch, +) { + const identity = await requireCurrentGitHubIdentity(ctx, userId, fetcher); + const page = clampInteger(args.page ?? 1, 1, 100); + const perPage = clampInteger(args.perPage ?? 30, 1, 100); + const query = normalizeRepoSearchQuery(args.query ?? ""); + + if (hasGitHubApiToken()) { + const searchResult = await listOwnedPublicSkillCandidatesWithCodeSearch( + identity, + { query, page, perPage }, + fetcher, + ); + return { + account: { login: identity.login, avatarUrl: identity.avatarUrl }, + page, + perPage, + ...searchResult, + }; + } + + const fallbackPerPage = Math.min(perPage, MAX_FALLBACK_DISCOVERY_REPOS_PER_PAGE); + const url = new URL(`${GITHUB_API}/users/${encodeURIComponent(identity.login)}/repos`); + url.searchParams.set("type", "owner"); + url.searchParams.set("sort", "pushed"); + url.searchParams.set("direction", "desc"); + url.searchParams.set("per_page", String(fallbackPerPage)); + url.searchParams.set("page", String(page)); + + const response = await fetcher(url.toString(), { headers: buildGitHubHeaders() }); + if (!response.ok) throwGitHubApiError(response.status); + + const payload = (await response.json()) as unknown; + if (!Array.isArray(payload)) throw new ConvexError("GitHub repository lookup failed"); + + const repos = payload + .map((repo) => toOwnedPublicRepoListItem(repo as GitHubRepoPayload, identity)) + .filter((repo): repo is OwnedPublicRepoListItem => Boolean(repo)) + .filter((repo) => repo.importable); + + const skillCandidates: OwnedPublicRepoListItem[] = []; + for (const repo of repos) { + const candidates = await listSkillCandidatesForRepo(repo, fetcher); + skillCandidates.push(...candidates); + } + + const filteredSkillCandidates = skillCandidates.filter((repo) => { + if (!query) return true; + return ( + repo.name.toLowerCase().includes(query) || + repo.fullName.toLowerCase().includes(query) || + repo.candidatePath.toLowerCase().includes(query) || + repo.skillPath.toLowerCase().includes(query) + ); + }); + + return { + account: { login: identity.login, avatarUrl: identity.avatarUrl }, + page, + perPage: fallbackPerPage, + hasMore: payload.length === fallbackPerPage, + repos: filteredSkillCandidates, + }; +} + +async function requireOwnedPublicGitHubRepoForImport( + ctx: Pick, + userId: Id<"users">, + owner: string, + repo: string, + fetcher: typeof fetch, +) { + const identity = await requireCurrentGitHubIdentity(ctx, userId, fetcher); + if (owner.toLowerCase() !== identity.login.toLowerCase()) { + throw new ConvexError(OWNED_PUBLIC_REPO_ONLY_ERROR); + } + + const metadata = await fetchGitHubRepoMetadata(owner, repo, fetcher); + assertOwnedPublicGitHubRepoMetadata(metadata, identity); + if (metadata.archived === true) { + throw new ConvexError("Archived GitHub repositories cannot be imported."); + } + if (metadata.disabled === true) { + throw new ConvexError("Disabled GitHub repositories cannot be imported."); + } + if (metadata.fork === true) { + throw new ConvexError("Forked GitHub repositories cannot be imported."); + } + return metadata; +} + +async function requireCurrentGitHubIdentity( + ctx: Pick, + userId: Id<"users">, + fetcher: typeof fetch, +): Promise { + const providerAccountId = await ctx.runQuery( + internal.githubIdentity.getGitHubProviderAccountIdInternal, + { userId }, + ); + if (!providerAccountId) throw new ConvexError("GitHub account required"); + assertGitHubNumericId(providerAccountId); + + const response = await fetcher(`${GITHUB_API}/user/${providerAccountId}`, { + headers: buildGitHubHeaders(), + }); + if (!response.ok) throwGitHubApiError(response.status); + + const payload = (await response.json()) as GitHubUserPayload; + const login = typeof payload.login === "string" ? payload.login.trim() : ""; + const avatarUrl = typeof payload.avatar_url === "string" ? payload.avatar_url.trim() : ""; + const payloadId = stringifyGitHubNumericId(payload.id); + if (!login || payloadId !== providerAccountId) { + throw new ConvexError("GitHub account lookup failed"); + } + + return { providerAccountId, login, avatarUrl: avatarUrl || null }; +} + +async function fetchGitHubRepoMetadata(owner: string, repo: string, fetcher: typeof fetch) { + const url = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const response = await fetcher(url, { headers: buildGitHubHeaders() }); + if (!response.ok) { + if (response.status === 404) throw new ConvexError(OWNED_PUBLIC_REPO_ONLY_ERROR); + throwGitHubApiError(response.status); + } + return (await response.json()) as GitHubRepoPayload; +} + +function assertOwnedPublicGitHubRepoMetadata( + repo: GitHubRepoPayload, + identity: GitHubIdentityForImport, +) { + const ownerId = stringifyGitHubNumericId(repo.owner?.id); + const ownerLogin = typeof repo.owner?.login === "string" ? repo.owner.login.trim() : ""; + const visibility = typeof repo.visibility === "string" ? repo.visibility : ""; + const isPublicVisibility = visibility ? visibility === "public" : true; + if ( + repo.private !== false || + !isPublicVisibility || + ownerId !== identity.providerAccountId || + ownerLogin.toLowerCase() !== identity.login.toLowerCase() + ) { + throw new ConvexError(OWNED_PUBLIC_REPO_ONLY_ERROR); + } +} + +function stringifyGitHubNumericId(value: unknown) { + if (typeof value === "number" && Number.isInteger(value)) return String(value); + if (typeof value === "string" && /^[0-9]+$/.test(value)) return value; + return ""; +} + +function toOwnedPublicRepoListItem( + repo: GitHubRepoPayload, + identity: GitHubIdentityForImport, +): OwnedPublicRepoListItem | null { + try { + assertOwnedPublicGitHubRepoMetadata(repo, identity); + } catch { + return null; + } + + const name = typeof repo.name === "string" ? repo.name.trim() : ""; + if (!name) return null; + + const owner = typeof repo.owner?.login === "string" ? repo.owner.login.trim() : identity.login; + const fullName = typeof repo.full_name === "string" ? repo.full_name.trim() : `${owner}/${name}`; + const archived = repo.archived === true; + const disabled = repo.disabled === true; + const fork = repo.fork === true; + const unavailableReason = archived + ? "Archived repositories cannot be imported." + : disabled + ? "Disabled repositories cannot be imported." + : fork + ? "Forked repositories cannot be imported." + : null; + + return { + owner, + name, + repoName: name, + repoFullName: fullName, + fullName, + htmlUrl: typeof repo.html_url === "string" ? repo.html_url : `https://github.com/${fullName}`, + defaultBranch: typeof repo.default_branch === "string" ? repo.default_branch : null, + pushedAt: typeof repo.pushed_at === "string" ? repo.pushed_at : null, + updatedAt: typeof repo.updated_at === "string" ? repo.updated_at : null, + language: typeof repo.language === "string" ? repo.language : null, + fork, + archived, + disabled, + visibility: "public", + importable: !archived && !disabled && !fork, + unavailableReason, + candidatePath: "", + skillPath: "SKILL.md", + }; +} + +async function listSkillCandidatesForRepo(repo: OwnedPublicRepoListItem, fetcher: typeof fetch) { + if (!repo.defaultBranch) return []; + + const tree = await fetchGitHubRepoTreeResult(repo.owner, repo.name, repo.defaultBranch, fetcher); + if (!tree) return []; + if (tree.truncated) return listSkillCandidatesFromArchive(repo, fetcher); + + const skillPaths = tree.entries + .map((entry) => normalizeSkillTreePath(entry)) + .filter((path): path is string => Boolean(path)); + + return skillPaths.map((skillPath) => toOwnedPublicSkillCandidate(repo, skillPath)); +} + +async function listOwnedPublicSkillCandidatesWithCodeSearch( + identity: GitHubIdentityForImport, + args: { query: string; page: number; perPage: number }, + fetcher: typeof fetch, +) { + const results = await Promise.all( + ["SKILL.md", "skills.md"].map(async (filename) => { + const url = new URL(`${GITHUB_API}/search/code`); + const searchParts = [`filename:${filename}`, `user:${identity.login}`]; + if (args.query) searchParts.push(args.query); + url.searchParams.set("q", searchParts.join(" ")); + url.searchParams.set("per_page", String(args.perPage)); + url.searchParams.set("page", String(args.page)); + + const response = await fetcher(url.toString(), { headers: buildGitHubHeaders() }); + if (!response.ok) throwGitHubApiError(response.status); + + const payload = (await response.json()) as GitHubCodeSearchPayload; + const items = Array.isArray(payload.items) ? payload.items : []; + return { items, hasMore: items.length === args.perPage }; + }), + ); + + const candidates = dedupeOwnedPublicSkillCandidates( + results + .flatMap((result) => result.items) + .map((item) => toOwnedPublicSkillCandidateFromSearchItem(item, identity)) + .filter((candidate): candidate is OwnedPublicRepoListItem => Boolean(candidate)), + ); + + return { + hasMore: results.some((result) => result.hasMore), + repos: candidates, + }; +} + +function toOwnedPublicSkillCandidateFromSearchItem( + item: unknown, + identity: GitHubIdentityForImport, +) { + const searchItem = item as GitHubCodeSearchItemPayload; + const skillPath = normalizeSkillTreePath({ path: searchItem.path, type: "blob" }); + if (!skillPath || !searchItem.repository) return null; + + const repo = toOwnedPublicRepoListItem(searchItem.repository, identity); + if (!repo?.importable) return null; + return toOwnedPublicSkillCandidate(repo, skillPath); +} + +async function listSkillCandidatesFromArchive( + repo: OwnedPublicRepoListItem, + fetcher: typeof fetch, +) { + if (!repo.defaultBranch) return []; + const zipBytes = await fetchGitHubZipBytes( + { + owner: repo.owner, + repo: repo.name, + ref: repo.defaultBranch, + commit: repo.defaultBranch, + path: "", + repoUrl: repo.htmlUrl, + originalUrl: repo.htmlUrl, + }, + fetcher, + ); + const entries = stripGitHubZipRoot(unzipToEntries(zipBytes)); + return detectGitHubImportCandidates(entries).map((candidate) => + toOwnedPublicSkillCandidate(repo, candidate.readmePath), + ); +} + +async function fetchResolvedGitHubEntries( + resolved: Awaited>, + fetcher: typeof fetch, +) { + if (resolved.path) return fetchGitHubPathEntries(resolved, fetcher); + + const zipBytes = await fetchGitHubZipBytes(resolved, fetcher); + return stripGitHubZipRoot(unzipToEntries(zipBytes)); +} + +async function fetchGitHubPathEntries( + resolved: Awaited>, + fetcher: typeof fetch, +) { + const tree = await fetchGitHubRepoTree(resolved.owner, resolved.repo, resolved.commit, fetcher); + if (!tree) throw new ConvexError("GitHub tree is too large"); + + const root = normalizeRepoPath(resolved.path); + const prefix = `${root}/`; + const blobEntries = tree + .map((entry) => toImportableTreeBlob(entry, prefix)) + .filter((entry): entry is { path: string; sha: string; size: number } => Boolean(entry)); + + if (blobEntries.length > MAX_FILE_COUNT) throw new ConvexError("Repo folder has too many files"); + + const out: Record = {}; + let totalBytes = 0; + for (const entry of blobEntries) { + if (entry.size > MAX_SINGLE_FILE_BYTES) continue; + const bytes = await fetchGitHubBlobBytes(resolved.owner, resolved.repo, entry.sha, fetcher); + if (bytes.byteLength > MAX_SINGLE_FILE_BYTES) continue; + totalBytes += bytes.byteLength; + if (totalBytes > MAX_UNZIPPED_BYTES) throw new ConvexError("Repo folder is too large"); + out[entry.path] = bytes; + } + + return out; +} + +function toImportableTreeBlob(entry: GitHubTreeEntryPayload, prefix: string) { + if (entry.type !== "blob" || typeof entry.path !== "string" || typeof entry.sha !== "string") { + return null; + } + const path = normalizeRepoPath(entry.path); + if (!path || !path.startsWith(prefix)) return null; + if (isMacJunkPath(path)) return null; + if (!isPreviewFetchableTextPath(path)) return null; + const size = typeof entry.size === "number" && Number.isFinite(entry.size) ? entry.size : 0; + return { path, sha: entry.sha, size }; +} + +async function fetchGitHubBlobBytes( + owner: string, + repo: string, + sha: string, + fetcher: typeof fetch, +) { + const url = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/blobs/${encodeURIComponent(sha)}`; + const response = await fetcher(url, { headers: buildGitHubRawHeaders() }); + if (!response.ok) throwGitHubApiError(response.status); + return new Uint8Array(await response.arrayBuffer()); +} + +function isPreviewFetchableTextPath(path: string) { + return isTextFile(path); +} + +async function fetchGitHubRepoTree( + owner: string, + repo: string, + defaultBranch: string, + fetcher: typeof fetch, +): Promise { + const result = await fetchGitHubRepoTreeResult(owner, repo, defaultBranch, fetcher); + if (!result || result.truncated) return null; + return result.entries; +} + +async function fetchGitHubRepoTreeResult( + owner: string, + repo: string, + defaultBranch: string, + fetcher: typeof fetch, +): Promise<{ entries: GitHubTreeEntryPayload[]; truncated: boolean } | null> { + const url = new URL( + `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(defaultBranch)}`, + ); + url.searchParams.set("recursive", "1"); + + const response = await fetcher(url.toString(), { headers: buildGitHubHeaders() }); + if (response.status === 404 || response.status === 409) return null; + if (!response.ok) throwGitHubApiError(response.status); + + const payload = (await response.json()) as GitHubTreePayload; + if (!Array.isArray(payload.tree)) return null; + + return { + entries: payload.tree as GitHubTreeEntryPayload[], + truncated: payload.truncated === true, + }; +} + +function normalizeSkillTreePath(entry: GitHubTreeEntryPayload) { + if (entry.type !== "blob" || typeof entry.path !== "string") return null; + const path = normalizeRepoPath(entry.path); + if (!path) return null; + if (path.split("/").some((segment) => segment.startsWith("."))) return null; + return isGitHubSkillFilePath(path) ? path : null; +} + +function dedupeOwnedPublicSkillCandidates(candidates: OwnedPublicRepoListItem[]) { + const seen = new Set(); + const out: OwnedPublicRepoListItem[] = []; + for (const candidate of candidates) { + const key = `${candidate.repoFullName.toLowerCase()}::${candidate.skillPath.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(candidate); + } + return out; +} + +function toOwnedPublicSkillCandidate(repo: OwnedPublicRepoListItem, skillPath: string) { + const candidatePath = skillPath.split("/").slice(0, -1).join("/"); + const candidateName = candidatePath ? (candidatePath.split("/").at(-1) ?? repo.name) : repo.name; + const htmlUrl = candidatePath + ? `${repo.htmlUrl}/tree/${encodeURIComponent(repo.defaultBranch ?? "HEAD")}/${candidatePath + .split("/") + .map(encodeURIComponent) + .join("/")}` + : repo.htmlUrl; + + return { + ...repo, + name: candidateName, + repoName: repo.repoName, + repoFullName: repo.repoFullName, + fullName: candidatePath ? `${repo.fullName}/${candidatePath}` : repo.fullName, + htmlUrl, + candidatePath, + skillPath, + }; +} + +function buildGitHubHeaders() { + const headers: Record = { + Accept: "application/vnd.github+json", + "User-Agent": "clawhub/github-import", + }; + const token = process.env.GITHUB_TOKEN; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + +function hasGitHubApiToken() { + return Boolean(process.env.GITHUB_TOKEN?.trim()); +} + +function buildGitHubRawHeaders() { + const headers = buildGitHubHeaders(); + headers.Accept = "application/vnd.github.raw"; + return headers; +} + +function assertGitHubNumericId(providerAccountId: string) { + if (!/^[0-9]+$/.test(providerAccountId)) { + throw new ConvexError("GitHub account lookup failed"); + } +} + +function throwGitHubApiError(status: number): never { + if (status === 403 || status === 429) { + throw new ConvexError("GitHub API rate limit exceeded — please try again in a few minutes"); + } + throw new ConvexError("GitHub account lookup failed"); +} + +function clampInteger(value: number, min: number, max: number) { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, Math.trunc(value))); +} + +function normalizeRepoSearchQuery(query: string) { + return query.trim().toLowerCase(); +} + function unzipToEntries(zipBytes: Uint8Array) { const entries = unzipSync(zipBytes); const out: Record = {}; @@ -332,16 +938,33 @@ function toErrorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } +function toUserFacingErrorMessage(error: unknown) { + return toErrorMessage(error) + .replace(/^Uncaught ConvexError:\s*/, "") + .split(/\s+at\s+/)[0] + .trim(); +} + function buildStoreFailureMessage(path: string, sizeBytes: number, error: unknown) { return `Failed to store file "${path}" (${sizeBytes} bytes). ${toErrorMessage(error)}`; } function buildPublishFailureMessage(error: unknown) { - return `Import failed during publish: ${toErrorMessage(error)}. Check skill format, slug availability, and try again.`; + return `Import failed during publish: ${toUserFacingErrorMessage(error)}. Check skill format, slug availability, and try again.`; } export const __test = { + assertOwnedPublicGitHubRepoMetadata, buildPublishFailureMessage, buildStoreFailureMessage, + importGitHubSkillForUser, + isPreviewFetchableTextPath, + listOwnedPublicGitHubReposForUser, + listSkillCandidatesForRepo, + previewGitHubImportCandidateForUser, + previewGitHubImportForUser, + requireOwnedPublicGitHubRepoForImport, + toOwnedPublicSkillCandidate, + toOwnedPublicRepoListItem, unzipToEntries, }; diff --git a/convex/lib/githubImport.test.ts b/convex/lib/githubImport.test.ts index 46575f3e..835690d3 100644 --- a/convex/lib/githubImport.test.ts +++ b/convex/lib/githubImport.test.ts @@ -77,6 +77,22 @@ describe("github import", () => { }); }); + it("parses legacy skills.md blob urls and derives folder path", () => { + expect(parseGitHubImportUrl("https://github.com/a/b/blob/main/skills/foo/skills.md")).toEqual({ + owner: "a", + repo: "b", + ref: "main", + path: "skills/foo", + originalUrl: "https://github.com/a/b/blob/main/skills/foo/skills.md", + }); + }); + + it("rejects blob urls that do not point to a skill file", () => { + expect(() => + parseGitHubImportUrl("https://github.com/a/b/blob/main/skills/foo/README.md"), + ).toThrow(/SKILL\.md or skills\.md/i); + }); + it("strips single top-level folder from GitHub zip entries", () => { const zip = buildGitHubZipForTests({ "repo-1/skill/SKILL.md": "Body", @@ -106,10 +122,11 @@ describe("github import", () => { expect(candidates[0]?.name).toBe("demo"); }); - it("detects multiple candidates and supports skills.md", () => { + it("detects SKILL.md and legacy skills.md candidates", () => { const zip = buildGitHubZipForTests({ "repo-1/alpha/SKILL.md": `---\nname: Alpha\n---\nBody`, "repo-1/beta/skills.md": `---\nname: Beta\n---\nBody`, + "repo-1/gamma/README.md": `---\nname: Gamma\n---\nBody`, "repo-1/readme.md": "x", }); const stripped = stripGitHubZipRoot(unzipSync(zip)); diff --git a/convex/lib/githubImport.ts b/convex/lib/githubImport.ts index eaa590c9..51e5a82d 100644 --- a/convex/lib/githubImport.ts +++ b/convex/lib/githubImport.ts @@ -37,7 +37,7 @@ export type GitHubImportFileEntry = { const MAX_REDIRECTS = 6; const GITHUB_HOST = "github.com"; const CODELOAD_HOST = "codeload.github.com"; -const SKILL_FILENAMES = ["skill.md", "skills.md"]; +const SKILL_FILENAMES = new Set(["skill.md", "skills.md"]); export function parseGitHubImportUrl(input: string): GitHubImportUrl { const rawUrl = input.trim(); @@ -82,6 +82,9 @@ export function parseGitHubImportUrl(input: string): GitHubImportUrl { if (kind === "blob") { if (!rest) throw new Error("Missing path in GitHub URL"); if (!normalizedRest) throw new Error("Invalid path in GitHub URL"); + if (!isGitHubSkillFilePath(normalizedRest)) { + throw new Error("GitHub file URL must point to SKILL.md or skills.md"); + } const dir = normalizedRest.split("/").slice(0, -1).join("/"); return { owner, repo, ref, path: dir || undefined, originalUrl }; } @@ -121,10 +124,7 @@ export async function resolveGitHubCommit( async function resolveRefCommit(parsed: GitHubImportUrl, ref: string, fetcher: typeof fetch) { const apiUrl = `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/commits/${encodeURIComponent(ref)}`; const response = await fetcher(apiUrl, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "clawhub/github-import", - }, + headers: buildGitHubImportHeaders(), }); if (!response.ok) throw new Error("GitHub ref not found"); const body = (await response.json()) as { sha?: unknown }; @@ -136,7 +136,10 @@ async function resolveRefCommit(parsed: GitHubImportUrl, ref: string, fetcher: t async function resolveHeadCommit(parsed: GitHubImportUrl, fetcher: typeof fetch) { let url = `https://${GITHUB_HOST}/${parsed.owner}/${parsed.repo}/archive/HEAD.zip`; for (let i = 0; i < MAX_REDIRECTS; i += 1) { - const response = await fetcher(url, { redirect: "manual" }); + const response = await fetcher(url, { + headers: buildGitHubImportHeaders(), + redirect: "manual", + }); const location = response.headers.get("location"); if (!location) break; const next = new URL(location, url); @@ -161,7 +164,7 @@ export async function fetchGitHubZipBytes( const maxZipBytes = limits?.maxZipBytes ?? 25 * 1024 * 1024; const url = `https://${CODELOAD_HOST}/${resolved.owner}/${resolved.repo}/zip/${resolved.commit}`; const response = await fetcher(url, { - headers: { "User-Agent": "clawhub/github-import" }, + headers: buildGitHubImportHeaders(), }); if (!response.ok) throw new Error("GitHub archive download failed"); @@ -200,6 +203,16 @@ export async function fetchGitHubZipBytes( return out; } +function buildGitHubImportHeaders() { + const headers: Record = { + Accept: "application/vnd.github+json", + "User-Agent": "clawhub/github-import", + }; + const token = process.env.GITHUB_TOKEN; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + export type ZipEntryMap = Record; export function buildGitHubZipForTests(entries: Record) { @@ -230,9 +243,7 @@ export function detectGitHubImportCandidates(entries: ZipEntryMap): GitHubImport const candidates: GitHubImportCandidate[] = []; for (const path of Object.keys(entries)) { const normalized = normalizeRepoPath(path); - const lower = normalized.toLowerCase(); - const isSkill = SKILL_FILENAMES.some((name) => lower === name || lower.endsWith(`/${name}`)); - if (!isSkill) continue; + if (!isGitHubSkillFilePath(normalized)) continue; const dir = normalized.split("/").slice(0, -1).join("/"); const readmePath = normalized; const raw = new TextDecoder().decode(entries[path] ?? new Uint8Array()); @@ -250,6 +261,12 @@ export function detectGitHubImportCandidates(entries: ZipEntryMap): GitHubImport return uniqCandidates(candidates); } +export function isGitHubSkillFilePath(path: string) { + const normalized = normalizeRepoPath(path); + const filename = normalized.split("/").at(-1)?.toLowerCase() ?? ""; + return SKILL_FILENAMES.has(filename); +} + function uniqCandidates(candidates: GitHubImportCandidate[]) { const seen = new Set(); const out: GitHubImportCandidate[] = []; diff --git a/docs/skill-format.md b/docs/skill-format.md index 72d04a02..dd4e0a75 100644 --- a/docs/skill-format.md +++ b/docs/skill-format.md @@ -13,7 +13,7 @@ A skill is a folder. Required: -- `SKILL.md` (or `skill.md`) +- `SKILL.md` (or `skill.md`; legacy `skills.md` is also accepted) Optional: @@ -21,6 +21,13 @@ Optional: - `.clawhubignore` (ignore patterns for publishing, legacy `.clawdhubignore`) - `.gitignore` (also honored) +## GitHub import + +The web GitHub importer is stricter than local publish/sync. It only discovers +`SKILL.md` or legacy `skills.md` files in public, non-fork repositories owned by +the signed-in GitHub account. It does not import private repos, forks, +archived/disabled repos, or third-party public repos. + Local install metadata (written by the CLI): - `/.clawhub/origin.json` (legacy `.clawdhub`) diff --git a/public/github-import-hero-art.png b/public/github-import-hero-art.png new file mode 100644 index 00000000..541ae064 Binary files /dev/null and b/public/github-import-hero-art.png differ diff --git a/specs/github-import.md b/specs/github-import.md index 283224d6..0c4172f2 100644 --- a/specs/github-import.md +++ b/specs/github-import.md @@ -1,12 +1,20 @@ --- -summary: "Feature spec: import a skill from a public GitHub URL (auto-detect SKILL.md, selective file upload, provenance)." +summary: "Feature spec: import skills from owned public GitHub repos (auto-detect SKILL.md, selective file upload, provenance)." read_when: - Adding GitHub import (web + API) - Reviewing safety limits (SSRF/zip-bombs) - Implementing provenance + canonical-claim flows --- -# GitHub import (public repos) +# GitHub import (owned public repos) + +Import is restricted to public repositories owned by the signed-in user's +current GitHub account. Server-side validation must compare the repository +owner's immutable GitHub numeric id with the caller's GitHub +`providerAccountId` before previewing candidates or downloading archives. + +Do not allow importing another user's public repository through the dashboard, +repo picker, or manual `/import` URL path. ## CLI @@ -26,7 +34,9 @@ clawhub package publish owner/repo --dry-run --json This keeps package metadata zero-config where possible and auto-populates GitHub provenance. -Goal: paste a GitHub URL → auto-detect skill → preview files → publish (selective) → persist provenance. +Goal: choose one detected `SKILL.md` or legacy `skills.md` candidate from the +signed-in user's owned public GitHub repositories, then preview files → publish +(selective) → persist provenance. Non-goal (v1): private repos (no OAuth/PAT support). @@ -39,15 +49,39 @@ Related: Upload page: “Import from GitHub” mode. +Use a functional picker, not a marketing landing page. The first viewport should +make the GitHub import job obvious: account, search, detected skill rows, and +the review state after selection. Design references can use hero-level presence, +but the control surface remains the product. + Flow: -1. URL input -2. Detect skill candidates (SKILL.md) +1. Scan the signed-in user's owned public repos +2. List only detected skill candidates (`SKILL.md` or legacy `skills.md`) 3. If multiple candidates: choose one 4. File picker: check/uncheck; smart-select referenced files 5. Confirm slug/name/version/tags 6. Import → publish +Manual URL import is not part of the dashboard picker. Backend preview/import +still accepts the older repo root, tree path, and blob path shapes for +internal/API callers, but only when the URL's repository is owned by the +signed-in user's GitHub account. Blocking third-party public repo imports is an +intentional product/security boundary for new import attempts; it does not +migrate or alter skills that were already published. + +Picker details: + +- Search is the primary control. +- Rows represent importable skill candidates, not raw repositories. +- A root skill file row uses the repo name. +- A nested skill file row uses the containing folder/project name. +- Rows also show the source repository name. +- Search only appears when there are more than 10 detected candidates. +- Repos without `SKILL.md` or legacy `skills.md`, private repos, forks, repos + owned by someone else, archived repos, and disabled repos do not appear. +- Do not show private repo prompts, org switchers, or OAuth permission upsells. + ## Accepted URLs Allowlist: `https://github.com/...` only. @@ -61,7 +95,7 @@ Supported shapes: Normalization: - Strip query/hash for fetch. -- From `blob/.../SKILL.md` derive `path` as parent folder. +- From `blob/.../SKILL.md` or `blob/.../skills.md` derive `path` as parent folder. - If `ref` missing: use `HEAD`. Reject: @@ -72,16 +106,43 @@ Reject: ## Fetch strategy (public) -Download archive: +Before archive download or preview: + +- Resolve the caller's GitHub `providerAccountId` from `authAccounts`. +- Fetch the current GitHub login by immutable numeric id. +- Fetch repository metadata from `GET /repos/{owner}/{repo}`. +- Reject unless `private === false`, `visibility === "public"` when present, + and `repo.owner.id === providerAccountId`. + +Picker discovery: + +- When a server `GITHUB_TOKEN` is configured, discover candidates with GitHub + Code Search (`filename:SKILL.md user:` and + `filename:skills.md user:`) and filter every result through the + owned-public repo validation above. +- Do not recursively scan every public repository on page load when Code Search + is available. +- Without a token, use a bounded repo-page fallback and recursive tree scans only + for that bounded page. +- If GitHub reports a truncated recursive tree, fall back to archive candidate + detection for that repository instead of silently omitting it. + +Preview/import archive: - `https://github.com///archive/.zip` - Follow redirects. Final redirect usually pins a commit via `codeload.github.com/.../zip/`. -Unzip server-side (Node or Convex node action). Scan for skill candidates. +Unzip server-side (Node or Convex node action). Scan for skill candidates and +selected files. Skill candidate definition: -- Any folder containing `SKILL.md` or `skill.md` (also accept `skills.md` for compatibility). +- Any repo root or folder containing a real `SKILL.md` file or legacy + `skills.md` file. +- A `blob/.../SKILL.md` or `blob/.../skills.md` URL targets that file's parent + folder. +- Do not treat README files, package metadata, repository names, or inferred + project folders as importable candidates. - Treat repo root as a folder too. Multiple skills: @@ -93,7 +154,7 @@ Multiple skills: Defaults: -- Always select `SKILL.md` (or chosen readme file). +- Always select the detected skill file. - Prefer selecting only within chosen skill folder; allow “include out-of-folder refs” if explicitly toggled. Referenced file expansion: @@ -125,7 +186,7 @@ Server publishes using existing pipeline: - Text-only enforced (see `docs/skill-format.md`). - Total ≤ 50MB (selected set). -- Must include `SKILL.md` (or accepted variant). +- Must include the detected skill file. Suggested defaults (UI): @@ -166,13 +227,18 @@ Future: canonical-claim ## API sketch (internal actions) -Two-step (recommended): +Primary picker flow: -- `previewGitHubImport(url)` → `{ commit, candidates:[...], files:[...], defaults:{...} }` -- `importGitHubSkill({ url, commit, candidatePath, selectedPaths, slug, displayName, version, tags })` +- `listOwnedPublicGitHubRepos({ page, perPage, query? })` → detected owned + public candidates. +- `previewGitHubImportCandidate(...)` → commit, selected-file preview, and + suggested publish defaults. +- `importGitHubSkill(...)` → publish the selected candidate from a pinned commit. Notes: +- `previewGitHubImport(url)` remains available for internal/API callers, but the + dashboard picker must not expose arbitrary public URL import. - `importGitHubSkill` should re-fetch by pinned `commit` (not floating branch), to avoid TOCTOU. - Validate `selectedPaths` subset of fetched archive manifest. @@ -198,7 +264,7 @@ Rate limits: Error UX: -- “No SKILL.md found.” +- “No SKILL.md or skills.md found.” - “Multiple skills found; pick one.” - “Repo too large / too many files.” - “Selected files exceed 50MB.” @@ -206,8 +272,9 @@ Error UX: ## Manual test checklist - Repo root skill (`SKILL.md` at root). -- Nested skill (`skills/foo/SKILL.md`). -- Multi-skill repo (two SKILL.md). -- SKILL.md references `docs/usage.md` + images; smart-select picks `.md` and referenced text files; ignores external links. +- Legacy root skill (`skills.md` at root). +- Nested skill (`skills/foo/SKILL.md` or `skills/foo/skills.md`). +- Multi-skill repo (two skill files). +- Skill file references `docs/usage.md` + images; smart-select picks `.md` and referenced text files; ignores external links. - Huge repo → clean “too large” error. - Redirect pinning → import stores commit sha in provenance. diff --git a/src/__tests__/import.route.test.tsx b/src/__tests__/import.route.test.tsx index 714264ff..4e1b56c9 100644 --- a/src/__tests__/import.route.test.tsx +++ b/src/__tests__/import.route.test.tsx @@ -9,18 +9,18 @@ vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn(), })); -const previewImport = vi.fn(); const previewCandidate = vi.fn(); const importSkill = vi.fn(); -const useQueryMock = vi.fn(); +const listOwnedRepos = vi.fn(); +const useQueriesMock = vi.fn(); const useAuthStatusMock = vi.fn(); let useActionCallCount = 0; vi.mock("convex/react", () => ({ ConvexReactClient: class {}, - useQuery: (...args: unknown[]) => useQueryMock(...args), + useQueries: (...args: unknown[]) => useQueriesMock(...args), useAction: () => { - const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3]; + const action = [listOwnedRepos, previewCandidate, importSkill][useActionCallCount % 3]; useActionCallCount += 1; return action; }, @@ -32,10 +32,10 @@ vi.mock("../lib/useAuthStatus", () => ({ describe("Import route", () => { beforeEach(() => { - previewImport.mockReset(); + listOwnedRepos.mockReset(); previewCandidate.mockReset(); importSkill.mockReset(); - useQueryMock.mockReset(); + useQueriesMock.mockReset(); useAuthStatusMock.mockReset(); useActionCallCount = 0; @@ -45,18 +45,31 @@ describe("Import route", () => { me: { _id: "users:1", handle: "me" }, }); - useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { - if (args === "skip") return undefined; - return null; - }); + useQueriesMock.mockReturnValue({}); - previewImport.mockResolvedValue({ - candidates: [ + listOwnedRepos.mockResolvedValue({ + account: { login: "me", avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4" }, + page: 1, + perPage: 50, + hasMore: false, + repos: [ { - path: "skill", - readmePath: "skill/SKILL.md", - name: "Taken Skill", - description: null, + owner: "octo", + name: "repo", + repoName: "repo", + repoFullName: "octo/repo", + fullName: "octo/repo", + htmlUrl: "https://github.com/octo/repo", + candidatePath: "skill", + skillPath: "skill/SKILL.md", + pushedAt: "2026-05-27T00:00:00Z", + updatedAt: "2026-05-27T00:00:00Z", + language: "TypeScript", + fork: false, + archived: false, + disabled: false, + importable: true, + unavailableReason: null, }, ], }); @@ -107,42 +120,432 @@ describe("Import route", () => { expect(screen.queryByText(/sign in to import/i)).toBeNull(); }); - it("blocks import preflight when slug availability reports a collision", async () => { - useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { - if (args === "skip") return undefined; - if ( - args && - typeof args === "object" && - "slug" in (args as Record) && - (args as Record).slug === "taken-skill" - ) { - return { - available: false, - reason: "taken", - message: "Slug is already taken. Choose a different slug.", - url: "/alice/taken-skill", - }; - } - return null; + it("auto-appends a slug suffix when the default slug is unavailable", async () => { + useQueriesMock.mockImplementation((queries: Record) => { + return Object.fromEntries( + Object.entries(queries).map(([key, query]) => [ + key, + query.args.slug === "taken-skill" + ? { + available: false, + reason: "taken", + message: "Slug is already taken. Choose a different slug.", + url: "/alice/taken-skill", + } + : { + available: true, + reason: "available", + message: null, + url: null, + }, + ]), + ); }); render(); - fireEvent.change(screen.getByPlaceholderText("https://github.com/owner/repo"), { - target: { value: "https://github.com/octo/repo" }, - }); - fireEvent.click(screen.getByRole("button", { name: /detect/i })); + await screen.findByRole("checkbox"); + fireEvent.click(screen.getByRole("button", { name: /review selected/i })); await waitFor(() => { - expect(previewImport).toHaveBeenCalled(); - expect(previewCandidate).toHaveBeenCalled(); + expect(previewCandidate).toHaveBeenCalledWith({ + url: "https://github.com/octo/repo", + candidatePath: "skill", + }); }); - expect( - await screen.findByText(/Slug is already taken\. Choose a different slug\./i), - ).toBeTruthy(); - expect(screen.getByRole("link", { name: "/alice/taken-skill" })).toBeTruthy(); - expect( - screen.getByRole("button", { name: /import \+ publish/i }).getAttribute("disabled"), - ).not.toBeNull(); + await waitFor(() => { + expect((screen.getByLabelText("Slug") as HTMLInputElement).value).toBe("taken-skill-2"); + }); + }); + + it("preserves natural numeric slug endings when de-duping review drafts", async () => { + listOwnedRepos.mockResolvedValueOnce({ + account: { login: "me", avatarUrl: null }, + page: 1, + perPage: 100, + hasMore: false, + repos: [ + { + owner: "octo", + name: "gpt-4-a", + repoName: "gpt-4-a", + repoFullName: "octo/gpt-4-a", + fullName: "octo/gpt-4-a", + htmlUrl: "https://github.com/octo/gpt-4-a", + candidatePath: "", + skillPath: "SKILL.md", + pushedAt: null, + updatedAt: null, + language: null, + fork: false, + archived: false, + disabled: false, + importable: true, + unavailableReason: null, + }, + { + owner: "octo", + name: "gpt-4-b", + repoName: "gpt-4-b", + repoFullName: "octo/gpt-4-b", + fullName: "octo/gpt-4-b", + htmlUrl: "https://github.com/octo/gpt-4-b", + candidatePath: "", + skillPath: "SKILL.md", + pushedAt: null, + updatedAt: null, + language: null, + fork: false, + archived: false, + disabled: false, + importable: true, + unavailableReason: null, + }, + ], + }); + previewCandidate.mockResolvedValue({ + resolved: { + owner: "octo", + repo: "gpt-4", + ref: "main", + commit: "abcdef1234567890", + path: "", + repoUrl: "https://github.com/octo/gpt-4", + originalUrl: "https://github.com/octo/gpt-4", + }, + candidate: { + path: "", + readmePath: "SKILL.md", + name: "GPT-4", + description: null, + }, + defaults: { + selectedPaths: ["SKILL.md"], + slug: "gpt-4", + displayName: "GPT-4", + version: "1.0.0", + tags: ["latest"], + }, + files: [{ path: "SKILL.md", size: 120, defaultSelected: true }], + }); + + render(); + await waitFor(() => { + expect(screen.getAllByRole("checkbox")).toHaveLength(2); + }); + fireEvent.click(screen.getByRole("button", { name: /review selected/i })); + + await waitFor(() => { + const values = screen + .getAllByLabelText("Slug") + .map((input) => (input as HTMLInputElement).value); + expect(values).toEqual(["gpt-4", "gpt-4-2"]); + }); + }); + + it("uses collision-free query keys for similar repo names", async () => { + const queryKeySets: string[][] = []; + listOwnedRepos.mockResolvedValueOnce({ + account: { login: "me", avatarUrl: null }, + page: 1, + perPage: 100, + hasMore: false, + repos: [ + { + owner: "octo", + name: "foo-bar", + repoName: "foo-bar", + repoFullName: "octo/foo-bar", + fullName: "octo/foo-bar", + htmlUrl: "https://github.com/octo/foo-bar", + candidatePath: "", + skillPath: "SKILL.md", + pushedAt: null, + updatedAt: null, + language: null, + fork: false, + archived: false, + disabled: false, + importable: true, + unavailableReason: null, + }, + { + owner: "octo", + name: "foo_bar", + repoName: "foo_bar", + repoFullName: "octo/foo_bar", + fullName: "octo/foo_bar", + htmlUrl: "https://github.com/octo/foo_bar", + candidatePath: "", + skillPath: "SKILL.md", + pushedAt: null, + updatedAt: null, + language: null, + fork: false, + archived: false, + disabled: false, + importable: true, + unavailableReason: null, + }, + ], + }); + previewCandidate.mockImplementation((args: { url: string }) => + Promise.resolve({ + resolved: { + owner: "octo", + repo: args.url.split("/").at(-1) ?? "repo", + ref: "main", + commit: "abcdef1234567890", + path: "", + repoUrl: args.url, + originalUrl: args.url, + }, + candidate: { + path: "", + readmePath: "SKILL.md", + name: args.url.split("/").at(-1) ?? "Repo", + description: null, + }, + defaults: { + selectedPaths: ["SKILL.md"], + slug: args.url.includes("foo_bar") ? "foo-bar-two" : "foo-bar-one", + displayName: args.url.split("/").at(-1) ?? "Repo", + version: "1.0.0", + tags: ["latest"], + }, + files: [{ path: "SKILL.md", size: 120, defaultSelected: true }], + }), + ); + useQueriesMock.mockImplementation((queries: Record) => { + queryKeySets.push(Object.keys(queries)); + return Object.fromEntries( + Object.entries(queries).map(([key]) => [ + key, + { available: true, reason: "available", message: null, url: null }, + ]), + ); + }); + + render(); + await waitFor(() => { + expect(screen.getAllByRole("checkbox")).toHaveLength(2); + }); + fireEvent.click(screen.getByRole("button", { name: /review selected/i })); + + await waitFor(() => { + const keys = queryKeySets.find((set) => set.length === 2); + expect(keys).toBeTruthy(); + expect(new Set(keys).size).toBe(2); + }); + }); + + it("can load more GitHub discovery pages", async () => { + listOwnedRepos + .mockResolvedValueOnce({ + account: { login: "me", avatarUrl: null }, + page: 1, + perPage: 100, + hasMore: true, + repos: [ + { + owner: "octo", + name: "bounded-skill", + repoName: "bounded-skill", + repoFullName: "octo/bounded-skill", + fullName: "octo/bounded-skill", + htmlUrl: "https://github.com/octo/bounded-skill", + candidatePath: "", + skillPath: "SKILL.md", + pushedAt: null, + updatedAt: null, + language: null, + fork: false, + archived: false, + disabled: false, + importable: true, + unavailableReason: null, + }, + ], + }) + .mockResolvedValueOnce({ + account: { login: "me", avatarUrl: null }, + page: 2, + perPage: 100, + hasMore: false, + repos: [ + { + owner: "octo", + name: "later-skill", + repoName: "later-skill", + repoFullName: "octo/later-skill", + fullName: "octo/later-skill", + htmlUrl: "https://github.com/octo/later-skill", + candidatePath: "", + skillPath: "SKILL.md", + pushedAt: null, + updatedAt: null, + language: null, + fork: false, + archived: false, + disabled: false, + importable: true, + unavailableReason: null, + }, + ], + }); + + render(); + + expect(await screen.findByText("bounded-skill")).toBeTruthy(); + expect(listOwnedRepos).toHaveBeenNthCalledWith(1, { + page: 1, + perPage: 100, + query: undefined, + }); + fireEvent.click(screen.getByRole("button", { name: /load more/i })); + expect(await screen.findByText("later-skill")).toBeTruthy(); + expect(listOwnedRepos).toHaveBeenNthCalledWith(2, { + page: 2, + perPage: 100, + query: undefined, + }); + const checkboxes = screen.getAllByRole("checkbox") as HTMLInputElement[]; + expect(checkboxes.every((checkbox) => checkbox.checked)).toBe(true); + }); + + it("passes search text to GitHub discovery", async () => { + listOwnedRepos + .mockResolvedValueOnce({ + account: { login: "me", avatarUrl: null }, + page: 1, + perPage: 100, + hasMore: true, + repos: [ + { + owner: "octo", + name: "first-skill", + repoName: "first-skill", + repoFullName: "octo/first-skill", + fullName: "octo/first-skill", + htmlUrl: "https://github.com/octo/first-skill", + candidatePath: "", + skillPath: "SKILL.md", + pushedAt: null, + updatedAt: null, + language: null, + fork: false, + archived: false, + disabled: false, + importable: true, + unavailableReason: null, + }, + ], + }) + .mockResolvedValueOnce({ + account: { login: "me", avatarUrl: null }, + page: 1, + perPage: 100, + hasMore: false, + repos: [ + { + owner: "octo", + name: "later-skill", + repoName: "later-skill", + repoFullName: "octo/later-skill", + fullName: "octo/later-skill", + htmlUrl: "https://github.com/octo/later-skill", + candidatePath: "", + skillPath: "SKILL.md", + pushedAt: null, + updatedAt: null, + language: null, + fork: false, + archived: false, + disabled: false, + importable: true, + unavailableReason: null, + }, + ], + }); + + render(); + + expect(await screen.findByText("first-skill")).toBeTruthy(); + fireEvent.change(screen.getByPlaceholderText("Search..."), { target: { value: "later" } }); + + await waitFor(() => { + expect(listOwnedRepos).toHaveBeenNthCalledWith(2, { + page: 1, + perPage: 100, + query: "later", + }); + }); + expect(await screen.findByText("later-skill")).toBeTruthy(); + }); + + it("preserves backend default file selection when publishing", async () => { + useQueriesMock.mockImplementation((queries: Record) => { + return Object.fromEntries( + Object.entries(queries).map(([key]) => [ + key, + { available: true, reason: "available", message: null, url: null }, + ]), + ); + }); + previewCandidate.mockResolvedValueOnce({ + resolved: { + owner: "octo", + repo: "repo", + ref: "main", + commit: "abcdef1234567890", + path: "skill", + repoUrl: "https://github.com/octo/repo", + originalUrl: "https://github.com/octo/repo", + }, + candidate: { + path: "skill", + readmePath: "skill/SKILL.md", + name: "Default Skill", + description: null, + }, + defaults: { + selectedPaths: ["skill/SKILL.md"], + slug: "default-skill", + displayName: "Default Skill", + version: "1.0.0", + tags: ["latest"], + }, + files: [ + { path: "skill/SKILL.md", size: 120, defaultSelected: true }, + { path: "skill/extra.md", size: 80, defaultSelected: false }, + ], + }); + importSkill.mockResolvedValue({ slug: "default-skill" }); + + render(); + await screen.findByRole("checkbox"); + fireEvent.click(screen.getByRole("button", { name: /review selected/i })); + await screen.findByDisplayValue("default-skill"); + fireEvent.click(screen.getByLabelText(/I have the rights/i)); + fireEvent.click(screen.getByRole("button", { name: /publish selected/i })); + + await waitFor(() => { + expect(importSkill).toHaveBeenCalledWith( + expect.objectContaining({ + selectedPaths: ["skill/SKILL.md"], + }), + ); + }); + }); + + it("surfaces preview errors instead of staying in the loading state", async () => { + previewCandidate.mockRejectedValueOnce(new Error("GitHub tree is too large")); + + render(); + await screen.findByRole("checkbox"); + fireEvent.click(screen.getByRole("button", { name: /review selected/i })); + + expect(await screen.findByText(/GitHub tree is too large/i)).toBeTruthy(); + expect(screen.queryByText(/Setting up your skills/i)).toBeNull(); }); }); diff --git a/src/__tests__/skills-publish-route.test.tsx b/src/__tests__/skills-publish-route.test.tsx index 47a74c5d..894224d7 100644 --- a/src/__tests__/skills-publish-route.test.tsx +++ b/src/__tests__/skills-publish-route.test.tsx @@ -1,10 +1,12 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { getFunctionName } from "convex/server"; import { strToU8, zipSync } from "fflate"; +import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Upload } from "../routes/skills/publish"; vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, to }: { children: ReactNode; to: string }) => {children}, createFileRoute: () => (config: { component: unknown }) => config, useNavigate: () => vi.fn(), useSearch: () => useSearchMock(), diff --git a/src/lib/convexError.ts b/src/lib/convexError.ts index 84680981..976cd458 100644 --- a/src/lib/convexError.ts +++ b/src/lib/convexError.ts @@ -19,6 +19,9 @@ function cleanupConvexMessage(message: string) { .replace(/\[Request ID:[^\]]*\]\s*/g, "") .replace(/^Server Error Called by client\s*/i, "") .replace(/^ConvexError:\s*/i, "") + .replace(/^Uncaught ConvexError:\s*/i, "") + .replace(/:\s*Uncaught ConvexError:\s*/i, ": ") + .replace(/\s+at\s+[A-Za-z_$./(][\s\S]*$/i, "") .trim(); } diff --git a/src/routes/-dashboard.test.tsx b/src/routes/-dashboard.test.tsx index a7d32567..6f72c712 100644 --- a/src/routes/-dashboard.test.tsx +++ b/src/routes/-dashboard.test.tsx @@ -434,7 +434,7 @@ describe("Dashboard rows", () => { }); expect( - (await screen.findByRole("link", { name: "Publish a Skill" })).getAttribute("href"), + (await screen.findByRole("link", { name: "Publish manually" })).getAttribute("href"), ).toBe("/skills/publish?ownerHandle=clawkit"); }); diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 4c1f9bdc..88e00532 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { usePaginatedQuery, useQuery } from "convex/react"; -import { AlertTriangle, Box, Loader2, Package, Plus, Settings } from "lucide-react"; +import { AlertTriangle, Box, Download, Loader2, Package, Plus, Settings } from "lucide-react"; import { useState } from "react"; import { api } from "../../convex/_generated/api"; import type { Doc } from "../../convex/_generated/dataModel"; @@ -201,14 +201,19 @@ export function Dashboard() { Welcome to ClawHub

- You're signed in as @{ownerHandle}. Get started by publishing your first skill or - plugin. + You're signed in as @{ownerHandle}. Import a public GitHub repo or publish manually.

{publisherSelector} -
+
+ +
+ + +
{skills.length === 0 ? (
diff --git a/src/routes/import.tsx b/src/routes/import.tsx index 90b9a706..4d2e4ddc 100644 --- a/src/routes/import.tsx +++ b/src/routes/import.tsx @@ -1,22 +1,85 @@ -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; -import { useAction, useQuery } from "convex/react"; -import { useMemo, useState } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { + PLATFORM_SKILL_LICENSE, + PLATFORM_SKILL_LICENSE_NAME, +} from "clawhub-schema/licenseConstants"; +import { useAction, useQueries } from "convex/react"; +import { + Check, + CheckCircle2, + ChevronDown, + CircleX, + Copy, + Eye, + ExternalLink, + ListChecks, + Lock, + RefreshCw, + Rocket, + Search, +} from "lucide-react"; +import { + type ReactNode, + type SVGProps, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { toast } from "sonner"; import { api } from "../../convex/_generated/api"; +import { copyText } from "../components/InstallCopyButton"; import { Container } from "../components/layout/Container"; import { SignInPrompt } from "../components/SignInPrompt"; import { ImportGitHubSkeleton } from "../components/skeletons/ProtectedPageSkeletons"; -import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; -import { Card } from "../components/ui/card"; +import { Card, CardContent, CardTitle } from "../components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "../components/ui/dropdown-menu"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../components/ui/tooltip"; import { getUserFacingConvexError } from "../lib/convexError"; +import { getClawHubSiteUrl, SITE_NAME } from "../lib/site"; +import { + ALLOWED_LUCIDE_ICON_NAMES, + ALLOWED_LUCIDE_ICONS, + makeLucideIconValue, +} from "../lib/skillIcon"; import { getPublicSlugCollision } from "../lib/slugCollision"; import { formatBytes } from "../lib/uploadUtils"; import { useAuthStatus } from "../lib/useAuthStatus"; export const Route = createFileRoute("/import")({ + head: () => { + const siteUrl = getClawHubSiteUrl(); + const title = `Import from GitHub | ${SITE_NAME}`; + const description = + "Import SKILL.md and skills.md files from your public GitHub repositories into ClawHub."; + + return { + links: [ + { + rel: "canonical", + href: `${siteUrl}/import`, + }, + ], + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:type", content: "website" }, + { property: "og:url", content: `${siteUrl}/import` }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + ], + }; + }, component: ImportGitHub, }); @@ -48,167 +111,503 @@ type CandidatePreview = { files: Array<{ path: string; size: number; defaultSelected: boolean }>; }; +type OwnedGitHubRepo = { + owner: string; + name: string; + repoName: string; + repoFullName: string; + fullName: string; + htmlUrl: string; + candidatePath: string; + skillPath: string; + pushedAt: string | null; + updatedAt: string | null; + language: string | null; + fork: boolean; + archived: boolean; + disabled: boolean; + importable: boolean; + unavailableReason: string | null; +}; + +type ReviewDraft = { + repo: OwnedGitHubRepo; + preview: CandidatePreview; + selected: Record; + slug: string; + displayName: string; + version: string; + tags: string; + iconName: string | null; +}; + +type SlugAvailabilityResult = + | { + available: boolean; + reason: "available" | "taken" | "reserved"; + message: string | null; + url: string | null; + } + | null + | undefined + | Error; + +type PublishResultRow = { + key: string; + name: string; + ok: boolean; + slug?: string; + message?: string; +}; + +const OPENCLAW_SKILLS_DISCORD_URL = + "https://discord.com/channels/1456350064065904867/1456891440897724637"; +const PUBLIC_CLAWHUB_SITE_URL = "https://clawhub.ai"; +const LOCAL_SHARE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]); +const GITHUB_REPO_PAGE_SIZE = 100; +const DEV_MOCK_SKILL_NAMES = [ + "agent-release-notes", + "audit-brief", + "branch-cleanup", + "context-pack", + "daily-standup", + "deploy-smoke", + "docs-review", + "handoff-writer", + "issue-triage", + "launch-checklist", + "migration-plan", + "pr-comment-sweeper", + "release-captain", + "security-pass", + "test-designer", +]; const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export function ImportGitHub() { const { isAuthenticated, isLoading, me } = useAuthStatus(); - const previewImport = useAction(api.githubImport.previewGitHubImport); + const listOwnedRepos = useAction(api.githubImport.listOwnedPublicGitHubRepos); const previewCandidate = useAction(api.githubImport.previewGitHubImportCandidate); const importSkill = useAction(api.githubImport.importGitHubSkill); - const navigate = useNavigate(); - - const [url, setUrl] = useState(""); - const [candidates, setCandidates] = useState([]); - const [selectedCandidatePath, setSelectedCandidatePath] = useState(null); - const [preview, setPreview] = useState(null); - const [selected, setSelected] = useState>({}); - - const [slug, setSlug] = useState(""); - const [displayName, setDisplayName] = useState(""); - const [version, setVersion] = useState("0.1.0"); - const [tags, setTags] = useState("latest"); + const [selectedRepoKeys, setSelectedRepoKeys] = useState>({}); + const [reviewQueue, setReviewQueue] = useState([]); + const [reviewDrafts, setReviewDrafts] = useState>({}); + const [expandedDraftKeys, setExpandedDraftKeys] = useState>({}); + const [acceptedLicenseTerms, setAcceptedLicenseTerms] = useState(false); + const [publishResults, setPublishResults] = useState([]); + const [repos, setRepos] = useState([]); + const [accountLogin, setAccountLogin] = useState(null); + const [accountAvatarUrl, setAccountAvatarUrl] = useState(null); + const [repoSearch, setRepoSearch] = useState(""); + const [repoListPage, setRepoListPage] = useState(1); + const [repoListQuery, setRepoListQuery] = useState(""); + const [hasMoreRepos, setHasMoreRepos] = useState(false); + const [repoListStatus, setRepoListStatus] = useState(null); + const [repoListError, setRepoListError] = useState(null); + const [isRepoListBusy, setIsRepoListBusy] = useState(false); + const [reviewLoadStatus, setReviewLoadStatus] = useState(null); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [isBusy, setIsBusy] = useState(false); - const trimmedSlug = slug.trim(); - const slugAvailability = useQuery( - api.skills.checkSlugAvailability, - isAuthenticated && trimmedSlug && SLUG_PATTERN.test(trimmedSlug) - ? { slug: trimmedSlug.toLowerCase() } - : "skip", - ) as - | { - available: boolean; - reason: "available" | "taken" | "reserved"; - message: string | null; - url: string | null; - } - | null - | undefined; - const slugCollision = useMemo( + const repoLoadSeq = useRef(0); + const reposRef = useRef([]); + + const visibleRepos = useMemo(() => { + const query = repoSearch.trim().toLowerCase(); + return repos.filter((repo) => { + if (!query) return true; + return ( + repo.name.toLowerCase().includes(query) || + repo.repoName.toLowerCase().includes(query) || + repo.fullName.toLowerCase().includes(query) || + repo.repoFullName.toLowerCase().includes(query) + ); + }); + }, [repoSearch, repos]); + const showRepoSearch = repos.length > 10 || hasMoreRepos || repoSearch.trim().length > 0; + const selectedRepoItems = useMemo( + () => repos.filter((repo) => selectedRepoKeys[getRepoKey(repo)]), + [repos, selectedRepoKeys], + ); + const orderedDrafts = useMemo( () => - getPublicSlugCollision({ - slug: trimmedSlug, - result: slugAvailability, + reviewQueue + .map((repo) => reviewDrafts[getRepoKey(repo)]) + .filter((draft): draft is ReviewDraft => Boolean(draft)), + [reviewDrafts, reviewQueue], + ); + const publishSucceeded = + publishResults.length > 0 && + publishResults.length === orderedDrafts.length && + publishResults.every((result) => result.ok); + const isReviewing = reviewQueue.length > 0; + const isReviewLoading = isReviewing && orderedDrafts.length < reviewQueue.length; + + const slugQueries = useMemo(() => { + const queries: Record< + string, + { query: typeof api.skills.checkSlugAvailability; args: { slug: string } } + > = {}; + for (const draft of orderedDrafts) { + const slug = draft.slug.trim().toLowerCase(); + if (slug && SLUG_PATTERN.test(slug)) { + queries[toSlugQueryKey(getRepoKey(draft.repo))] = { + query: api.skills.checkSlugAvailability, + args: { slug }, + }; + } + } + return queries; + }, [orderedDrafts]); + const slugResults = useQueries(slugQueries) as Record; + + const duplicateSlugKeys = useMemo(() => { + const seen = new Map(); + const duplicates = new Set(); + for (const draft of orderedDrafts) { + const key = getRepoKey(draft.repo); + const slug = draft.slug.trim().toLowerCase(); + if (!slug) continue; + const firstKey = seen.get(slug); + if (firstKey) { + duplicates.add(firstKey); + duplicates.add(key); + } else { + seen.set(slug, key); + } + } + return duplicates; + }, [orderedDrafts]); + + const reviewIssuesByKey = useMemo(() => { + const issues: Record = {}; + for (const draft of orderedDrafts) { + const key = getRepoKey(draft.repo); + issues[key] = getDraftIssues({ + draft, + slugResult: slugResults[toSlugQueryKey(key)], + isDuplicateSlug: duplicateSlugKeys.has(key), + }); + } + return issues; + }, [duplicateSlugKeys, orderedDrafts, slugResults]); + + const hasReviewIssues = useMemo( + () => Object.values(reviewIssuesByKey).some((issues) => issues.length > 0), + [reviewIssuesByKey], + ); + const hasPendingSlugChecks = useMemo( + () => + orderedDrafts.some((draft) => { + const slug = draft.slug.trim().toLowerCase(); + return ( + slug && + SLUG_PATTERN.test(slug) && + slugResults[toSlugQueryKey(getRepoKey(draft.repo))] === undefined + ); }), - [slugAvailability, trimmedSlug], + [orderedDrafts, slugResults], + ); + const canPublish = + orderedDrafts.length > 0 && + orderedDrafts.length === reviewQueue.length && + acceptedLicenseTerms && + !hasReviewIssues && + !hasPendingSlugChecks && + !isBusy; + const publishStatus = getPublishStatus({ + acceptedLicenseTerms, + hasPendingSlugChecks, + reviewIssuesByKey, + status, + }); + + const loadRepos = useCallback( + async (options?: { query?: string; page?: number; append?: boolean }) => { + const query = options?.query?.trim() ?? ""; + const page = options?.page ?? 1; + const append = options?.append ?? false; + const requestId = repoLoadSeq.current + 1; + repoLoadSeq.current = requestId; + + setIsRepoListBusy(true); + setRepoListError(null); + setRepoListStatus(null); + try { + const result = await listOwnedRepos({ + page, + perPage: GITHUB_REPO_PAGE_SIZE, + query: query || undefined, + }); + if (requestId !== repoLoadSeq.current) return; + + const fetchedRepos = (result.repos ?? []) as OwnedGitHubRepo[]; + const nextRepos = append + ? mergeRepoLists(reposRef.current, fetchedRepos) + : expandDevMockSkillRepos(fetchedRepos); + const accountLoginValue = + typeof result.account?.login === "string" && result.account.login.trim() + ? result.account.login.trim() + : null; + const accountAvatarValue = + typeof result.account?.avatarUrl === "string" && result.account.avatarUrl.trim() + ? result.account.avatarUrl.trim() + : null; + setAccountLogin(accountLoginValue); + setAccountAvatarUrl(accountAvatarValue); + reposRef.current = nextRepos; + setRepos(nextRepos); + setRepoListPage(page); + setRepoListQuery(query); + setHasMoreRepos(result.hasMore); + setSelectedRepoKeys((current) => { + return Object.fromEntries( + nextRepos.map((repo) => { + const key = getRepoKey(repo); + return [key, current[key] ?? true]; + }), + ); + }); + if (nextRepos.length === 0) { + setRepoListStatus(query ? "No matching skills." : "No skills found."); + } else { + setRepoListStatus(null); + } + } catch (e) { + if (requestId !== repoLoadSeq.current) return; + setRepoListError(getUserFacingConvexError(e, "Could not load GitHub repos")); + } finally { + if (requestId === repoLoadSeq.current) setIsRepoListBusy(false); + } + }, + [listOwnedRepos], ); - const selectedCount = useMemo(() => Object.values(selected).filter(Boolean).length, [selected]); - const selectedBytes = useMemo(() => { - if (!preview) return 0; - let total = 0; - for (const file of preview.files) { - if (selected[file.path]) total += file.size; - } - return total; - }, [preview, selected]); + useEffect(() => { + if (!isAuthenticated) return undefined; + const timer = window.setTimeout( + () => { + void loadRepos({ query: repoSearch }); + }, + repoSearch.trim() ? 250 : 0, + ); + return () => window.clearTimeout(timer); + }, [isAuthenticated, loadRepos, repoSearch]); - const detect = async () => { - setError(null); - setStatus(null); - setPreview(null); - setCandidates([]); - setSelectedCandidatePath(null); - setSelected({}); - setIsBusy(true); - try { - const result = await previewImport({ url: url.trim() }); - const items = (result.candidates ?? []) as Candidate[]; - setCandidates(items); - if (items.length === 1) { - const only = items[0]; - if (only) await loadCandidate(only.path); - } else { - setStatus(`Found ${items.length} skills. Pick one.`); + useEffect(() => { + if (orderedDrafts.length === 0) return; + setReviewDrafts((current) => { + const used = new Set(); + let changed = false; + const next = { ...current }; + for (const draft of orderedDrafts) { + const key = getRepoKey(draft.repo); + const slug = draft.slug.trim().toLowerCase(); + if (!slug || !SLUG_PATTERN.test(slug)) continue; + if (used.has(slug)) { + const replacement = nextNumericSlug(slug, used); + next[key] = { ...draft, slug: replacement }; + used.add(replacement); + changed = true; + } else { + used.add(slug); + } } - } catch (e) { - setError(getUserFacingConvexError(e, "Preview failed")); - } finally { - setIsBusy(false); - } + return changed ? next : current; + }); + }, [orderedDrafts]); + + useEffect(() => { + if (orderedDrafts.length === 0) return; + setReviewDrafts((current) => { + let changed = false; + const next = { ...current }; + const used = new Set( + Object.values(current) + .map((draft) => draft.slug.trim().toLowerCase()) + .filter(Boolean), + ); + for (const draft of orderedDrafts) { + const key = getRepoKey(draft.repo); + const result = slugResults[toSlugQueryKey(key)]; + if (!result || result instanceof Error || result.available) continue; + const replacement = nextNumericSlug(draft.slug, used); + next[key] = { ...draft, slug: replacement }; + used.add(replacement); + changed = true; + } + return changed ? next : current; + }); + }, [orderedDrafts, slugResults]); + + const toggleRepoSelection = (repo: OwnedGitHubRepo) => { + const key = getRepoKey(repo); + setSelectedRepoKeys((current) => ({ ...current, [key]: !current[key] })); }; - const loadCandidate = async (candidatePath: string) => { + const allVisibleReposSelected = + visibleRepos.length > 0 && visibleRepos.every((repo) => selectedRepoKeys[getRepoKey(repo)]); + + const toggleAllVisibleRepos = () => { + setSelectedRepoKeys((current) => { + const next = { ...current }; + for (const repo of visibleRepos) { + next[getRepoKey(repo)] = !allVisibleReposSelected; + } + return next; + }); + }; + + const updateDraft = (key: string, patch: Partial) => { + setReviewDrafts((current) => { + const draft = current[key]; + if (!draft) return current; + return { ...current, [key]: { ...draft, ...patch } }; + }); + }; + + const updateDraftSelection = (key: string, path: string) => { + setReviewDrafts((current) => { + const draft = current[key]; + if (!draft) return current; + if (path === draft.preview.candidate.readmePath) return current; + return { + ...current, + [key]: { + ...draft, + selected: { ...draft.selected, [path]: !draft.selected[path] }, + }, + }; + }); + }; + + const applyFileSelection = (key: string, mode: "skill" | "all") => { + setReviewDrafts((current) => { + const draft = current[key]; + if (!draft) return current; + const nextSelected: Record = {}; + for (const file of draft.preview.files) { + nextSelected[file.path] = + file.path === draft.preview.candidate.readmePath || mode === "all"; + } + return { ...current, [key]: { ...draft, selected: nextSelected } }; + }); + }; + + const startReview = async () => { + if (selectedRepoItems.length === 0) return; + const nextQueue = selectedRepoItems; + setReviewQueue(nextQueue); + setReviewDrafts({}); + setExpandedDraftKeys({}); + setAcceptedLicenseTerms(false); + setPublishResults([]); setError(null); setStatus(null); - setPreview(null); - setSelected({}); - setSelectedCandidatePath(candidatePath); setIsBusy(true); try { - const result = (await previewCandidate({ - url: url.trim(), - candidatePath, - })) as CandidatePreview; - setPreview(result); - setSlug(result.defaults.slug); - setDisplayName(result.defaults.displayName); - setVersion(result.defaults.version); - setTags((result.defaults.tags ?? ["latest"]).join(",")); - const nextSelected: Record = {}; - for (const file of result.files) nextSelected[file.path] = file.defaultSelected; - setSelected(nextSelected); - setStatus("Ready to import."); + const drafts: Record = {}; + const usedSlugs = new Set(); + for (let index = 0; index < nextQueue.length; index += 1) { + const repo = nextQueue[index] as OwnedGitHubRepo; + setReviewLoadStatus(`Preparing ${index + 1} of ${nextQueue.length}`); + const result = (await previewCandidate({ + url: repo.htmlUrl, + candidatePath: repo.candidatePath, + })) as CandidatePreview; + const selected: Record = {}; + for (const file of result.files) selected[file.path] = file.defaultSelected; + const slug = nextNumericSlug(result.defaults.slug, usedSlugs); + usedSlugs.add(slug); + drafts[getRepoKey(repo)] = { + repo, + preview: result, + selected, + slug, + displayName: result.defaults.displayName, + version: result.defaults.version, + tags: (result.defaults.tags ?? ["latest"]).join(","), + iconName: pickDefaultIconName(`${repo.fullName}:${repo.skillPath}`), + }; + } + setReviewDrafts(drafts); + setReviewLoadStatus(null); } catch (e) { setError(getUserFacingConvexError(e, "Preview failed")); + setReviewQueue([]); + setReviewDrafts({}); + setExpandedDraftKeys({}); + setReviewLoadStatus(null); } finally { setIsBusy(false); } }; - const applyDefaultSelection = () => { - if (!preview) return; - const set = new Set(preview.defaults.selectedPaths); - const next: Record = {}; - for (const file of preview.files) next[file.path] = set.has(file.path); - setSelected(next); + const cancelReview = () => { + setReviewQueue([]); + setReviewDrafts({}); + setExpandedDraftKeys({}); + setAcceptedLicenseTerms(false); + setReviewLoadStatus(null); + setStatus(null); + setError(null); }; - const selectAll = () => { - if (!preview) return; - const next: Record = {}; - for (const file of preview.files) next[file.path] = true; - setSelected(next); + const importDraft = async (draft: ReviewDraft) => { + const selectedPaths = draft.preview.files + .map((file) => file.path) + .filter((path) => draft.selected[path]); + const tagList = draft.tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + const icon = + draft.iconName && Object.hasOwn(ALLOWED_LUCIDE_ICONS, draft.iconName) + ? makeLucideIconValue(draft.iconName as keyof typeof ALLOWED_LUCIDE_ICONS) + : undefined; + return importSkill({ + url: draft.preview.resolved.originalUrl, + commit: draft.preview.resolved.commit, + candidatePath: draft.preview.candidate.path, + selectedPaths, + slug: draft.slug.trim(), + displayName: draft.displayName.trim(), + version: draft.version.trim(), + tags: tagList, + ...(icon ? { icon } : {}), + acceptLicenseTerms: acceptedLicenseTerms, + }); }; - const clearAll = () => { - if (!preview) return; - const next: Record = {}; - for (const file of preview.files) next[file.path] = false; - setSelected(next); - }; - - const doImport = async () => { - if (!preview) return; - if (slugCollision) { - toast.error(slugCollision.message); - return; - } + const publishReviewed = async () => { + if (!canPublish) return; setIsBusy(true); setError(null); - setStatus("Importing..."); + const results = publishResults.filter((item) => item.ok && item.slug); + const publishedKeys = new Set(results.map((item) => item.key)); + setPublishResults(results); try { - const selectedPaths = preview.files.map((file) => file.path).filter((path) => selected[path]); - const tagList = tags - .split(",") - .map((tag) => tag.trim()) - .filter(Boolean); - const result = await importSkill({ - url: url.trim(), - commit: preview.resolved.commit, - candidatePath: preview.candidate.path, - selectedPaths, - slug: slug.trim(), - displayName: displayName.trim(), - version: version.trim(), - tags: tagList, - }); - const nextSlug = result.slug; - setStatus("Imported."); - const ownerParam = me?.handle ?? (me?._id ? String(me._id) : "unknown"); - await navigate({ to: "/$owner/$slug", params: { owner: ownerParam, slug: nextSlug } }); + for (let index = 0; index < orderedDrafts.length; index += 1) { + const draft = orderedDrafts[index] as ReviewDraft; + const key = getRepoKey(draft.repo); + if (publishedKeys.has(key)) continue; + setStatus(`Publishing ${index + 1} of ${orderedDrafts.length}`); + try { + const result = await importDraft(draft); + results.push({ key, name: draft.displayName, ok: true, slug: result.slug }); + publishedKeys.add(key); + } catch (e) { + results.push({ + key, + name: draft.displayName, + ok: false, + message: getUserFacingConvexError(e, "Import failed"), + }); + } + setPublishResults([...results]); + } + setStatus(`Published ${results.filter((item) => item.ok).length} of ${orderedDrafts.length}`); } catch (e) { toast.error(getUserFacingConvexError(e, "Import failed")); setStatus(null); @@ -231,282 +630,1090 @@ export function ImportGitHub() { } return ( -
- -
-
-
-

- GitHub import -

-

- Import from GitHub -

-

- Public repos only. Detects SKILL.md automatically. -

- - Skill-only import. Plugins are not supported here. Use{" "} - - Publish Plugin - - . - -
-
-
Public only
-
Commit pinned
-
-
-
- - -
-
-
- - - Repo, tree path, or blob - -
- setUrl(e.target.value)} - placeholder="https://github.com/owner/repo" - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} +
+ +
+
+
+ 0 ? 3 : 2) : 1} + onSelect={isReviewing && !isBusy ? cancelReview : undefined} + onReview={ + !isReviewing && selectedRepoItems.length > 0 && !isBusy ? startReview : undefined + } + onPublish={isReviewing && canPublish ? publishReviewed : undefined} />
-
- -
- - {status ?

{status}

: null} -
- - {error ? ( -
- {error} -
- ) : null} - - - {candidates.length > 1 ? ( - -

Pick a skill

-
- {candidates.map((candidate) => ( - - ))} -
-
- ) : null} - - {preview ? ( - <> - -
-
-
-
- - - Unique, lowercase - -
- setSlug(e.target.value)} - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} - /> -
-
-
- - - Shown in listings - -
- setDisplayName(e.target.value)} - /> -
-
-
-
- - Semver -
- setVersion(e.target.value)} - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} - /> -
-
-
- - - Comma-separated - -
- setTags(e.target.value)} - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} - /> -
-
-
- +
+
+

+ Import from GitHub +

+

+ {publishSucceeded + ? "Ready to share" + : isReviewing + ? "Review selected skills before publishing" + : "Select skills"} +

- - - -
-

Files

-
- - - -
+
+
-

- Selected: {selectedCount}/{preview.files.length} • {formatBytes(selectedBytes)} -

-
- {preview.files.map((file) => ( -
+ + + {!isReviewing ? ( +
+
+
+ + {(accountAvatarUrl ?? me?.image) ? ( + + ) : ( + + )} + + + + GitHub account + + + {accountLogin ?? me?.handle ?? me?.name ?? "GitHub"} + - - ))} -
-
- - {slugCollision ? ( -
- {slugCollision.message} - {slugCollision.url ? ( + + {!isRepoListBusy ? ( + + + + + + + Update list + + + + ) : null} +
+
+
+ {selectedRepoItems.length} selected + {visibleRepos.length > 0 ? ( <> - {" "} - ) : null}
- ) : null} +
- - - ) : null} + {showRepoSearch ? ( +
+
+ ) : null} + + {repoListError ? ( +
+ {repoListError} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {isRepoListBusy ? ( + + ) : visibleRepos.length > 0 ? ( + <> +
+ {visibleRepos.map((repo) => { + const rowKey = getRepoKey(repo); + const checked = selectedRepoKeys[rowKey]; + return ( + + ); + })} +
+
+ {hasMoreRepos ? ( + + ) : ( + + )} + +
+ + ) : repoSearch.trim() ? ( +

No matching skills.

+ ) : repoListStatus ? ( +

{repoListStatus}

+ ) : null} +
+ ) : null} + + {isReviewLoading ? ( + + ) : null} + + {publishSucceeded ? ( + + ) : isReviewing && !isReviewLoading ? ( +
+ {error ? ( +
+ {error} +
+ ) : null} + + {orderedDrafts.map((draft) => { + const key = getRepoKey(draft.repo); + const issues = reviewIssuesByKey[key] ?? []; + const slugResult = slugResults[toSlugQueryKey(key)]; + const isExpanded = expandedDraftKeys[key] || issues.length > 0; + return ( + + setExpandedDraftKeys((current) => ({ ...current, [key]: !isExpanded })) + } + onChangeDraft={(patch) => updateDraft(key, patch)} + onToggleFile={(path) => updateDraftSelection(key, path)} + onApplyFileSelection={(mode) => applyFileSelection(key, mode)} + /> + ); + })} + + + +
+ License +

+ {PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_NAME} +

+
+
+

+ All skills published on ClawHub are licensed under MIT-0. Free to use, modify, + and redistribute. No attribution required. +

+

+ ClawHub does not support paid skills, per-skill pricing, or paywalled + releases. +

+
+ +
+
+ +
+
+ {publishStatus.message} +
+ +
+ + {publishResults.length > 0 ? : null} +
+ ) : null} +
); } + +function PublishedImportSuccess({ + drafts, + ownerHandle, + results, +}: { + drafts: ReviewDraft[]; + ownerHandle?: string | null; + results: PublishResultRow[]; +}) { + const [copiedAllLinks, setCopiedAllLinks] = useState(false); + const successfulResults = results.filter((result) => result.ok && result.slug); + const draftByKey = new Map(drafts.map((draft) => [getRepoKey(draft.repo), draft])); + const publishedItems = successfulResults.map((result) => { + const draft = draftByKey.get(result.key); + const slug = result.slug ?? ""; + const href = buildSkillHref(ownerHandle, slug); + const url = buildSkillUrl(ownerHandle, slug); + const Icon = + draft?.iconName && Object.hasOwn(ALLOWED_LUCIDE_ICONS, draft.iconName) + ? ALLOWED_LUCIDE_ICONS[draft.iconName] + : Rocket; + return { ...result, draft, href, Icon, url }; + }); + + const copyAll = async () => { + const text = publishedItems.map((item) => item.url).join("\n"); + const copied = await copyText(text); + if (copied) { + setCopiedAllLinks(true); + window.setTimeout(() => setCopiedAllLinks(false), 1800); + toast.success("Links copied"); + } else { + toast.error("Could not copy links"); + } + }; + + return ( +
+
+
+
+
+
+

+ They're alive! +

+

+ {publishedItems.length} {publishedItems.length === 1 ? "skill" : "skills"} imported + and ready to share. +

+
+
+ +
+ +
+ {publishedItems.map((item) => { + const Icon = item.Icon; + return ( +
+
+
+
+
+
+ {item.name} +
+
/{item.slug}
+
+
+
+ {item.url} + + +
+
+ ); + })} +
+ + +
+ ); +} + +function PublishResultList({ results }: { results: PublishResultRow[] }) { + return ( +
+ {results.map((result) => { + const Icon = result.ok ? CheckCircle2 : CircleX; + return ( +
+
+
+
+
+ {result.name} + {result.ok && result.slug ? ( + /{result.slug} + ) : null} +
+

+ {result.ok ? "Published." : normalizePublishResultMessage(result.message)} +

+
+
+ ); + })} +
+ ); +} + +function ImportStepper({ + current, + onSelect, + onReview, + onPublish, +}: { + current: 1 | 2 | 3; + onSelect?: () => void; + onReview?: () => void; + onPublish?: () => void; +}) { + const stepClass = (id: 1 | 2 | 3, isEnabled: boolean) => + [ + "inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 transition-colors", + current === id + ? "border-[color:var(--accent)]/40 bg-[color:var(--accent)]/10 text-[color:var(--ink)]" + : current > id + ? "border-[color:var(--line)] text-[color:var(--ink)]" + : "border-[color:var(--line)]", + isEnabled ? "hover:border-[color:var(--border-ui-hover)]" : "opacity-55", + ].join(" "); + const stepButton = ( + id: 1 | 2 | 3, + label: string, + icon: ReactNode, + onClick: (() => void) | undefined, + ) => ( + + ); + return ( +
+ {stepButton(1, "Select",
+ ); +} + +function LoadingPanel({ label, description }: { label: string; description: string }) { + return ( + + + +
+
{label}
+
{description}
+
+
+
+ ); +} + +function ClawHubSpinner() { + return ( +
- +
+ + +
diff --git a/src/styles.css b/src/styles.css index 0647de3e..63943f6d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7353,6 +7353,236 @@ code { } } +.clawhub-import-spinner { + display: inline-flex; + width: 42px; + height: 42px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); + border-radius: 999px; + background: + radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.18), transparent 28%), + color-mix(in srgb, var(--surface-muted) 82%, var(--accent) 18%); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.04), + 0 10px 32px color-mix(in srgb, var(--accent) 18%, transparent); + transform-style: preserve-3d; + animation: clawhub-import-orbit 2.8s linear infinite; +} + +.clawhub-import-spinner-emoji::before { + content: "🦑"; + display: block; + font-size: 22px; + line-height: 1; + transform: translateZ(8px); + transform-style: preserve-3d; + animation: + clawhub-import-emoji 2.8s steps(1, end) infinite, + clawhub-import-emoji-spin 2.8s linear infinite; +} + +@keyframes clawhub-import-orbit { + 0% { + transform: rotateY(0deg); + } + 12.49% { + transform: rotateY(89deg); + } + 12.5% { + transform: rotateY(90deg); + } + 25% { + transform: rotateY(180deg); + } + 37.49% { + transform: rotateY(269deg); + } + 37.5% { + transform: rotateY(270deg); + } + 50% { + transform: rotateY(360deg); + } + 62.49% { + transform: rotateY(449deg); + } + 62.5% { + transform: rotateY(450deg); + } + 75% { + transform: rotateY(540deg); + } + 87.49% { + transform: rotateY(629deg); + } + 87.5% { + transform: rotateY(630deg); + } + 100% { + transform: rotateY(720deg); + } +} + +@keyframes clawhub-import-emoji-spin { + 0% { + transform: translateZ(8px) rotateY(0deg); + } + 12.49% { + transform: translateZ(8px) rotateY(-89deg); + } + 12.5% { + transform: translateZ(8px) rotateY(-90deg); + } + 25% { + transform: translateZ(8px) rotateY(-180deg); + } + 37.49% { + transform: translateZ(8px) rotateY(-269deg); + } + 37.5% { + transform: translateZ(8px) rotateY(-270deg); + } + 50% { + transform: translateZ(8px) rotateY(-360deg); + } + 62.49% { + transform: translateZ(8px) rotateY(-449deg); + } + 62.5% { + transform: translateZ(8px) rotateY(-450deg); + } + 75% { + transform: translateZ(8px) rotateY(-540deg); + } + 87.49% { + transform: translateZ(8px) rotateY(-629deg); + } + 87.5% { + transform: translateZ(8px) rotateY(-630deg); + } + 100% { + transform: translateZ(8px) rotateY(-720deg); + } +} + +@keyframes clawhub-import-emoji { + 0%, + 12.49% { + content: "🦑"; + } + 12.5%, + 37.49% { + content: "🦞"; + } + 37.5%, + 62.49% { + content: "🦐"; + } + 62.5%, + 87.49% { + content: "🦀"; + } + 87.5%, + 100% { + content: "🦑"; + } +} + +.github-import-review-card { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-muted) 72%, var(--surface)) 0%, + var(--surface) 46%, + color-mix(in srgb, var(--surface) 92%, var(--bg)) 100% + ); +} + +.github-import-success-panel { + position: relative; + border: 1px solid color-mix(in srgb, var(--status-success-fg) 6%, var(--line)); + background: + radial-gradient( + circle at 88% 16%, + color-mix(in srgb, var(--status-success-fg) 5%, transparent), + transparent 42% + ), + radial-gradient( + circle at 8% 76%, + color-mix(in srgb, var(--status-success-fg) 3%, transparent), + transparent 38% + ), + linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-muted) 70%, transparent) 0%, + color-mix(in srgb, var(--surface) 96%, var(--bg)) 42%, + color-mix(in srgb, var(--surface) 72%, var(--bg)) 100% + ); + box-shadow: + 0 0 0 1px color-mix(in srgb, white 2%, transparent) inset, + 0 16px 44px color-mix(in srgb, black 22%, transparent); +} + +.github-import-success-panel::before { + position: absolute; + inset: 0; + pointer-events: none; + content: ""; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--status-success-fg) 8%, transparent), + transparent + ); + height: 1px; +} + +.github-import-success-mark { + color: color-mix(in srgb, var(--status-success-fg) 74%, var(--ink)); + border: 1px solid color-mix(in srgb, var(--status-success-fg) 18%, var(--line)); + background: + radial-gradient( + circle at 50% 42%, + color-mix(in srgb, var(--status-success-fg) 5%, transparent), + transparent 56% + ), + color-mix(in srgb, var(--surface-muted) 86%, transparent); + box-shadow: + 0 0 0 6px color-mix(in srgb, var(--status-success-fg) 1.5%, transparent), + 0 10px 20px color-mix(in srgb, black 14%, transparent); +} + +.github-import-publish-result-row { + display: flex; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--line); +} + +.github-import-publish-result-row:last-child { + border-bottom: 0; +} + +.github-import-input:focus, +.github-import-input:focus-visible { + border-color: var(--line) !important; + box-shadow: none !important; + outline: none !important; +} + +.github-import-share-action, +.github-import-share-action:hover, +.github-import-share-action:focus-visible { + text-decoration: none; +} + +.github-import-share-action:hover, +.github-import-share-action:focus-visible { + background: color-mix(in srgb, white 4%, transparent); +} + @keyframes upload-decor-jiggle { 0%, 100% {