feat(ui): accept ssh clone urls when registering a skill

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
milan 2026-07-31 21:47:22 +00:00
parent 3083c55ffc
commit 1618617ea9
3 changed files with 93 additions and 4 deletions

View file

@ -167,7 +167,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
label="Repository URL"
name="skillUrl"
rules={[{ required: true, message: "Please enter a repository URL" }]}
tooltip="Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host. E.g. github.com/org/repo, gitlab.com/org/repo, or github.com/org/repo/tree/main/my-skill"
tooltip="Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host, e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key"
>
<Input
placeholder="https://github.com/org/repo or https://gitlab.com/org/repo"

View file

@ -159,6 +159,11 @@ describe("getSourceLink", () => {
it("returns null when no repo or url", () => {
expect(getSourceLink({ source: "github" })).toBeNull();
});
it("returns null for an ssh clone url, which is not browsable", () => {
expect(getSourceLink({ source: "url", url: "git@ghe.example.com:org/repo.git" })).toBeNull();
expect(getSourceLink({ source: "url", url: "ssh://git@ghe.example.com/org/repo.git" })).toBeNull();
});
});
describe("getCategoryBadgeColor", () => {
@ -455,6 +460,44 @@ describe("parseSkillSource", () => {
expect(parseSkillSource("gitlab.com/org/repo", "a//b")).toBeNull();
});
it("keeps an scp-style ssh clone url so private hosts authenticate with the user's key", () => {
expect(parseSkillSource("git@ghe.example.com:org/repo.git")?.parsed).toEqual({
source: "url",
url: "git@ghe.example.com:org/repo.git",
});
expect(parseSkillSource("git@ghe.example.com:org/repo")?.parsed).toEqual({
source: "url",
url: "git@ghe.example.com:org/repo.git",
});
expect(parseSkillSource("git@ghe.example.com:org/repo.git")?.suggestedName).toBe("repo");
});
it("normalizes an ssh:// clone url and keeps a custom port", () => {
expect(parseSkillSource("ssh://git@ghe.example.com/org/repo")?.parsed).toEqual({
source: "url",
url: "ssh://git@ghe.example.com/org/repo.git",
});
expect(parseSkillSource("ssh://git@ghe.example.com:2222/org/nested/repo.git")?.parsed).toEqual({
source: "url",
url: "ssh://git@ghe.example.com:2222/org/nested/repo.git",
});
});
it("combines an ssh clone url with an explicit subfolder", () => {
expect(parseSkillSource("git@ghe.example.com:org/repo.git", "plugins/my-skill")?.parsed).toEqual({
source: "git-subdir",
url: "git@ghe.example.com:org/repo.git",
path: "plugins/my-skill",
});
expect(parseSkillSource("git@ghe.example.com:org/repo.git", "../etc")).toBeNull();
});
it("rejects ssh-looking input without a host or repo path", () => {
expect(parseSkillSource("git@ghe.example.com:repo.git")).toBeNull();
expect(parseSkillSource("git@localhost:org/repo.git")).toBeNull();
expect(parseSkillSource("git@:org/repo.git")).toBeNull();
});
it("returns null for empty and garbage input", () => {
expect(parseSkillSource("")).toBeNull();
expect(parseSkillSource(" ")).toBeNull();

View file

@ -30,6 +30,10 @@ const IPV4_HOST_REGEX = /^\d{1,3}(\.\d{1,3}){3}$/;
const GITHUB_ORG_REGEX = /^[A-Za-z0-9-]+$/;
const GITHUB_REPO_REGEX = /^[A-Za-z0-9._-]+$/;
const SSH_SCP_REGEX = /^([a-z0-9._-]+)@([a-z0-9.-]+\.[a-z]{2,}):([a-z0-9._-]+(?:\/[a-z0-9._-]+)+?)(?:\.git)?\/?$/i;
const SSH_URL_REGEX =
/^ssh:\/\/([a-z0-9._-]+)@([a-z0-9.-]+\.[a-z]{2,})(:\d+)?\/([a-z0-9._-]+(?:\/[a-z0-9._-]+)+?)(?:\.git)?\/?$/i;
const buildRepoUrl = (url: URL): string => `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
const pathSegments = (url: URL): string[] => url.pathname.split("/").filter((seg) => seg !== "");
@ -160,12 +164,54 @@ const parseRawGitSource = (url: URL, subPath?: string): SkillSourcePreview | nul
};
};
const withGitSuffix = (path: string): string => `${path.replace(/\.git$/i, "")}.git`;
const parseSshRepoUrl = (raw: string): string | null => {
const trimmed = raw.trim();
const sshUrl = SSH_URL_REGEX.exec(trimmed);
if (sshUrl) {
const [, user, host, port, path] = sshUrl;
return `ssh://${user}@${host}${port ?? ""}/${withGitSuffix(path)}`;
}
const scp = SSH_SCP_REGEX.exec(trimmed);
if (scp) {
const [, user, host, path] = scp;
return `${user}@${host}:${withGitSuffix(path)}`;
}
return null;
};
const parseSshSource = (cloneUrl: string, subPath?: string): SkillSourcePreview | null => {
const repoName = lastSegment(cloneUrl.replace(/\.git$/, "").replace(/^[^:]*:/, ""));
const normalized = normalizeSubPath(subPath ?? "");
if (normalized !== "") {
if (!SUBDIR_PATH_REGEX.test(normalized)) {
return null;
}
return {
parsed: { source: "git-subdir", url: cloneUrl, path: normalized },
label: `SSH subdir — ${cloneUrl} @ ${normalized}`,
suggestedName: toKebabCase(lastSegment(normalized)),
};
}
return {
parsed: { source: "url", url: cloneUrl },
label: `SSH repo — ${cloneUrl}`,
suggestedName: toKebabCase(repoName),
};
};
/**
* Parse any git-accessible repository URL into a registerable skill source.
* GitHub URLs keep their `github`/`git-subdir` shorthand; every other host is
* treated as a raw repo URL, with an optional subfolder turning it into git-subdir.
* GitHub https URLs keep their `github`/`git-subdir` shorthand; ssh clone URLs stay ssh so a
* private host authenticates with the user's own key; every other host is treated as a raw repo
* URL, with an optional subfolder turning it into git-subdir.
*/
export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourcePreview | null => {
const sshCloneUrl = parseSshRepoUrl(rawUrl);
if (sshCloneUrl) {
return parseSshSource(sshCloneUrl, subPath);
}
const url = parseRepoUrl(rawUrl);
if (!url) {
return null;
@ -268,7 +314,7 @@ export const getSourceLink = (source: PluginSource): string | null => {
return `https://github.com/${source.repo}`;
}
if ((source.source === "url" || source.source === "git-subdir") && source.url) {
return source.url;
return source.url.startsWith("https://") ? source.url : null;
}
return null;
};