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:
Vyctor H. Brzezowski
2026-06-17 12:43:51 -07:00
committed by GitHub
parent 6d87fc078d
commit 5f0e1ee639
15 changed files with 3850 additions and 689 deletions
+557 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -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
View File
@@ -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[] = [];
+8 -1
View File
@@ -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
View File
@@ -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.
+449 -46
View File
@@ -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(),
+3
View File
@@ -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();
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+23 -7
View File
@@ -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
View File
@@ -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% {