mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
3083c55ffc
commit
1618617ea9
3 changed files with 93 additions and 4 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue