mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: import owned public GitHub skills (#2444)
* feat: add GitHub skill import flow * polish: refine GitHub import review states * feat: link publish page to GitHub import * test: keep import route checks lint-clean * fix: harden GitHub import review flow * polish: show copied state for GitHub import links * fix: stabilize GitHub import slug review state * fix: harden GitHub import discovery * fix: address GitHub import review feedback * fix: preserve legacy GitHub skill filenames * fix: allow public GitHub URL imports * style: format github import * fix: remove stale import route imports * fix: enforce owned GitHub import URLs * fix: align GitHub import with main
This commit is contained in:
+557
-1
@@ -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) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+822
-199
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
|
||||
+27
-10
@@ -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<string, string> = {
|
||||
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<string, Uint8Array>;
|
||||
|
||||
export function buildGitHubZipForTests(entries: Record<string, string>) {
|
||||
@@ -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<string>();
|
||||
const out: GitHubImportCandidate[] = [];
|
||||
|
||||
@@ -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):
|
||||
|
||||
- `<skill>/.clawhub/origin.json` (legacy `.clawdhub`)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 523 KiB |
+85
-18
@@ -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:<login>` and
|
||||
`filename:skills.md user:<login>`) 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/<owner>/<repo>/archive/<ref>.zip`
|
||||
- Follow redirects. Final redirect usually pins a commit via `codeload.github.com/.../zip/<sha-or-branch>`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<string, unknown>) &&
|
||||
(args as Record<string, unknown>).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<string, { args: { slug: string } }>) => {
|
||||
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(<ImportGitHub />);
|
||||
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(<ImportGitHub />);
|
||||
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<string, { args: { slug: string } }>) => {
|
||||
queryKeySets.push(Object.keys(queries));
|
||||
return Object.fromEntries(
|
||||
Object.entries(queries).map(([key]) => [
|
||||
key,
|
||||
{ available: true, reason: "available", message: null, url: null },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
render(<ImportGitHub />);
|
||||
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(<ImportGitHub />);
|
||||
|
||||
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(<ImportGitHub />);
|
||||
|
||||
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<string, { args: { slug: string } }>) => {
|
||||
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(<ImportGitHub />);
|
||||
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(<ImportGitHub />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => <a href={to}>{children}</a>,
|
||||
createFileRoute: () => (config: { component: unknown }) => config,
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearch: () => useSearchMock(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
+24
-11
@@ -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
|
||||
</h1>
|
||||
<p className="empty-state-body">
|
||||
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.
|
||||
</p>
|
||||
{publisherSelector}
|
||||
<div className="flex gap-3 justify-center">
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/import">
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
Import from GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/skills/publish" search={{ updateSlug: undefined, ownerHandle }}>
|
||||
Publish a Skill
|
||||
Publish manually
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
@@ -246,12 +251,20 @@ export function Dashboard() {
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<h2 className="dashboard-collection-title">Skills</h2>
|
||||
<Button asChild size="sm" className="dashboard-section-action">
|
||||
<Link to="/skills/publish" search={{ updateSlug: undefined, ownerHandle }}>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
New Skill
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild size="sm" variant="outline" className="dashboard-section-action">
|
||||
<Link to="/import">
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
Import from GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="dashboard-section-action">
|
||||
<Link to="/skills/publish" search={{ updateSlug: undefined, ownerHandle }}>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
New Skill
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
|
||||
+1601
-394
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { Link, createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
@@ -63,6 +63,14 @@ function isRequiredSkillFile(path: string) {
|
||||
return REQUIRED_SKILL_FILE_NAMES.includes(path.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function GitHubLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
||||
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.69-1.28-1.69-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.68 0-1.25.45-2.28 1.18-3.08-.12-.29-.51-1.46.11-3.04 0 0 .97-.31 3.16 1.18.92-.26 1.9-.38 2.88-.39.98 0 1.96.13 2.88.39 2.19-1.49 3.15-1.18 3.15-1.18.63 1.58.24 2.75.12 3.04.74.8 1.18 1.83 1.18 3.08 0 4.42-2.69 5.39-5.25 5.67.42.36.78 1.07.78 2.15 0 1.55-.01 2.8-.01 3.18 0 .31.21.67.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type SkillPublishField = "slug" | "displayName" | "version" | "tags" | "license";
|
||||
|
||||
export const Route = createFileRoute("/skills/publish")({
|
||||
@@ -718,12 +726,20 @@ export function Upload() {
|
||||
</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">Drop or select a skill folder</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="w-fit">
|
||||
<a href={SKILL_PUBLISHING_GUIDE_URL} target="_blank" rel="noreferrer">
|
||||
Skill publishing guide
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="w-fit">
|
||||
<Link to="/import">
|
||||
<GitHubLogo className="h-3.5 w-3.5" />
|
||||
Import from GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="w-fit">
|
||||
<a href={SKILL_PUBLISHING_GUIDE_URL} target="_blank" rel="noreferrer">
|
||||
Skill publishing guide
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
|
||||
+230
@@ -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% {
|
||||
|
||||
Reference in New Issue
Block a user