Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions src/cli/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,18 @@ export async function runAdd(opts: AddOptions): Promise<string> {
// Load config early so we can check trust before any network work
const config = await loadConfig(configPath);

// Validate trust before resolution
validateTrustedSource(specifier, config.trust);

// Parse the specifier
const parsed = parseSource(specifier);

// Normalize GitHub URLs to owner/repo form for storage
const canonicalSource =
parsed.type === "github"
? `${parsed.owner}/${parsed.repo}${parsed.ref ? `@${parsed.ref}` : ""}`
: specifier;

// Validate trust against the canonical source (owner/repo form for GitHub)
validateTrustedSource(canonicalSource, config.trust);

// Determine ref (flag overrides inline @ref)
const effectiveRef = ref ?? parsed.ref;

Expand All @@ -49,13 +55,13 @@ export async function runAdd(opts: AddOptions): Promise<string> {
throw new AddError("Cannot use --all with --name. Use one or the other.");
}

if (config.skills.some((s) => isWildcardDep(s) && s.source === specifier)) {
if (config.skills.some((s) => isWildcardDep(s) && s.source === canonicalSource)) {
throw new AddError(
`A wildcard entry for "${specifier}" already exists in agents.toml.`,
`A wildcard entry for "${canonicalSource}" already exists in agents.toml.`,
);
}

await addWildcardToConfig(configPath, specifier, {
await addWildcardToConfig(configPath, canonicalSource, {
...(effectiveRef ? { ref: effectiveRef } : {}),
exclude: [],
});
Expand Down Expand Up @@ -96,24 +102,24 @@ export async function runAdd(opts: AddOptions): Promise<string> {
const found = await discoverSkill(cached.repoDir, nameOverride);
if (!found) {
throw new AddError(
`Skill "${nameOverride}" not found in ${specifier}. ` +
`Use 'dotagents add ${specifier}' without --name to see available skills.`,
`Skill "${nameOverride}" not found in ${canonicalSource}. ` +
`Use 'dotagents add ${canonicalSource}' without --name to see available skills.`,
);
}
skillName = nameOverride;
} else {
// Discover all skills and pick
const skills = await discoverAllSkills(cached.repoDir);
if (skills.length === 0) {
throw new AddError(`No skills found in ${specifier}.`);
throw new AddError(`No skills found in ${canonicalSource}.`);
}
if (skills.length === 1) {
skillName = skills[0]!.meta.name;
} else {
// Multiple skills found — list them and ask user to pick with --name or --all
const names = skills.map((s) => s.meta.name).sort();
throw new AddError(
`Multiple skills found in ${specifier}: ${names.join(", ")}. ` +
`Multiple skills found in ${canonicalSource}: ${names.join(", ")}. ` +
`Use --name to specify which one, or --all for all skills.`,
);
}
Expand All @@ -129,7 +135,7 @@ export async function runAdd(opts: AddOptions): Promise<string> {

// Add to config
await addSkillToConfig(configPath, skillName, {
source: specifier,
source: canonicalSource,
...(effectiveRef ? { ref: effectiveRef } : {}),
});

Expand Down
16 changes: 16 additions & 0 deletions src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ describe("agentsConfigSchema", () => {
it("rejects three-part path (not a valid format)", () => {
expect(parseSkill("a/b/c").success).toBe(false);
});

it("accepts GitHub HTTPS URL", () => {
expect(parseSkill("https://github.com/owner/repo").success).toBe(true);
});

it("accepts GitHub HTTPS URL with .git suffix", () => {
expect(parseSkill("https://github.com/owner/repo.git").success).toBe(true);
});

it("accepts GitHub SSH URL", () => {
expect(parseSkill("git@github.com:owner/repo.git").success).toBe(true);
});

it("rejects GitHub URL with dash-prefixed owner", () => {
expect(parseSkill("https://github.com/-bad/repo").success).toBe(false);
});
});

describe("skill name validation", () => {
Expand Down
12 changes: 11 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,28 @@ import { z } from "zod/v4";
*/
const GIT_URL_VALID = /^git:(https:\/\/|git:\/\/|ssh:\/\/|git@|file:\/\/|\/)/;

/** GitHub HTTPS URL pattern — owner/repo must start with alphanumeric (no dash prefix). */
export const GITHUB_HTTPS_URL =
/^https?:\/\/github\.com\/([a-zA-Z0-9][^/]*)\/([a-zA-Z0-9][^/@]*?)(?:\.git)?(?:\/)?(?:@(.+))?$/;
/** GitHub SSH URL pattern — owner/repo must start with alphanumeric (no dash prefix). */
export const GITHUB_SSH_URL =
/^git@github\.com:([a-zA-Z0-9][^/]*)\/([a-zA-Z0-9][^/@]*?)(?:\.git)?(?:@(.+))?$/;

const skillSourceSchema = z.string().check(
z.refine((s) => {
if (s.startsWith("git:")) {
// Require a valid protocol scheme or absolute path to prevent argument injection
return GIT_URL_VALID.test(s);
}
if (s.startsWith("path:")) return true;
// GitHub HTTPS or SSH URLs
if (GITHUB_HTTPS_URL.test(s)) return true;
if (GITHUB_SSH_URL.test(s)) return true;
// owner/repo or owner/repo@ref
const base = s.includes("@") ? s.slice(0, s.indexOf("@")) : s;
const parts = base.split("/");
return parts.length === 2 && parts.every((p) => p.length > 0 && !p.startsWith("-"));
}, "Must be owner/repo, owner/repo@ref, git:<url> (with https/git/ssh protocol), or path:<relative>"),
}, "Must be owner/repo, owner/repo@ref, GitHub URL, git:<url> (with https/git/ssh protocol), or path:<relative>"),
);

export type SkillSource = z.infer<typeof skillSourceSchema>;
Expand Down
75 changes: 75 additions & 0 deletions src/skills/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,79 @@ describe("parseSource", () => {
expect(result.type).toBe("local");
expect(result.path).toBe("../shared/my-skill");
});

it("parses HTTPS GitHub URL", () => {
const result = parseSource("https://github.com/getsentry/skills");
expect(result.type).toBe("github");
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
expect(result.ref).toBeUndefined();
});

it("parses HTTPS GitHub URL with .git suffix", () => {
const result = parseSource("https://github.com/getsentry/skills.git");
expect(result.type).toBe("github");
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
});

it("parses HTTPS GitHub URL with trailing slash", () => {
const result = parseSource("https://github.com/getsentry/skills/");
expect(result.type).toBe("github");
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
});

it("parses HTTPS GitHub URL with @ref", () => {
const result = parseSource("https://github.com/getsentry/skills@v1.0.0");
expect(result.type).toBe("github");
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
expect(result.ref).toBe("v1.0.0");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
});

it("parses SSH GitHub URL", () => {
const result = parseSource("git@github.com:getsentry/skills");
expect(result.type).toBe("github");
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
expect(result.ref).toBeUndefined();
});

it("parses SSH GitHub URL with .git suffix", () => {
const result = parseSource("git@github.com:getsentry/skills.git");
expect(result.type).toBe("github");
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
});

it("parses SSH GitHub URL with @ref", () => {
const result = parseSource("git@github.com:getsentry/skills@v2.0");
expect(result.type).toBe("github");
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
expect(result.ref).toBe("v2.0");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
});

it("parses HTTPS GitHub URL with dotted repo name", () => {
const result = parseSource("https://github.com/vercel/next.js");
expect(result.type).toBe("github");
expect(result.owner).toBe("vercel");
expect(result.repo).toBe("next.js");
expect(result.url).toBe("https://github.com/vercel/next.js.git");
});

it("parses HTTPS GitHub URL with dotted repo name and .git suffix", () => {
const result = parseSource("https://github.com/vercel/next.js.git");
expect(result.type).toBe("github");
expect(result.owner).toBe("vercel");
expect(result.repo).toBe("next.js");
expect(result.url).toBe("https://github.com/vercel/next.js.git");
});
});
15 changes: 15 additions & 0 deletions src/skills/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { join } from "node:path";
import type { WildcardSkillDependency } from "../config/schema.js";
import { GITHUB_HTTPS_URL, GITHUB_SSH_URL } from "../config/schema.js";
import { ensureCached } from "../sources/cache.js";
import { resolveLocalSource } from "../sources/local.js";
import { discoverSkill, discoverAllSkills } from "./discovery.js";
Expand Down Expand Up @@ -56,6 +57,20 @@ export function parseSource(source: string): {
return { type: "git", url: source.slice(4) };
}

// GitHub HTTPS or SSH URL
const githubUrlMatch =
source.match(GITHUB_HTTPS_URL) || source.match(GITHUB_SSH_URL);
if (githubUrlMatch) {
const [, owner, repo, ref] = githubUrlMatch;
return {
type: "github",
owner,
repo,
ref,
url: `https://github.com/${owner}/${repo}.git`,
};
}

// owner/repo or owner/repo@ref
const atIdx = source.indexOf("@");
const base = atIdx !== -1 ? source.slice(0, atIdx) : source;
Expand Down