Skip to content
Open
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
33 changes: 17 additions & 16 deletions src/cli/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import chalk from "chalk";
import { loadConfig } from "../../config/loader.js";
import { isWildcardDep } from "../../config/schema.js";
import { addSkillToConfig, addWildcardToConfig } from "../../config/writer.js";
import { parseSource, resolveSkill } from "../../skills/resolver.js";
import { parseSource, resolveSkill, sourcesMatch } from "../../skills/resolver.js";
import { discoverAllSkills } from "../../skills/discovery.js";
import { ensureCached } from "../../sources/cache.js";
import { validateTrustedSource, TrustError } from "../../trust/index.js";
Expand Down Expand Up @@ -37,14 +37,14 @@ export async function runAdd(opts: AddOptions): Promise<string> {
// 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}` : ""}`
// Preserve original source form (SSH, HTTPS, or shorthand) — strip inline @ref (stored separately)
const sourceForStorage =
parsed.type === "github" && parsed.ref
? specifier.slice(0, -(parsed.ref.length + 1))
: specifier;

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

// Determine ref (flag overrides inline @ref)
const effectiveRef = ref ?? parsed.ref;
Expand All @@ -55,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 === canonicalSource)) {
if (config.skills.some((s) => isWildcardDep(s) && sourcesMatch(s.source, sourceForStorage))) {
throw new AddError(
`A wildcard entry for "${canonicalSource}" already exists in agents.toml.`,
`A wildcard entry for "${sourceForStorage}" already exists in agents.toml.`,
);
}

await addWildcardToConfig(configPath, canonicalSource, {
await addWildcardToConfig(configPath, sourceForStorage, {
...(effectiveRef ? { ref: effectiveRef } : {}),
exclude: [],
});
Expand Down Expand Up @@ -89,37 +89,38 @@ export async function runAdd(opts: AddOptions): Promise<string> {
} else {
// Git source — clone and discover
const url = parsed.url!;
const cloneUrl = parsed.cloneUrl ?? url;
const cacheKey =
parsed.type === "github"
? `${parsed.owner}/${parsed.repo}`
: url.replace(/^https?:\/\//, "").replace(/\.git$/, "");

const cached = await ensureCached({ url, cacheKey, ref: effectiveRef });
const cached = await ensureCached({ url: cloneUrl, cacheKey, ref: effectiveRef });

if (nameOverride) {
// User specified name, verify it exists
const { discoverSkill } = await import("../../skills/discovery.js");
const found = await discoverSkill(cached.repoDir, nameOverride);
if (!found) {
throw new AddError(
`Skill "${nameOverride}" not found in ${canonicalSource}. ` +
`Use 'dotagents add ${canonicalSource}' without --name to see available skills.`,
`Skill "${nameOverride}" not found in ${sourceForStorage}. ` +
`Use 'dotagents add ${sourceForStorage}' 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 ${canonicalSource}.`);
throw new AddError(`No skills found in ${sourceForStorage}.`);
}
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 ${canonicalSource}: ${names.join(", ")}. ` +
`Multiple skills found in ${sourceForStorage}: ${names.join(", ")}. ` +
`Use --name to specify which one, or --all for all skills.`,
);
}
Expand All @@ -135,7 +136,7 @@ export async function runAdd(opts: AddOptions): Promise<string> {

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

Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { loadLockfile } from "../../lockfile/loader.js";
import { writeLockfile } from "../../lockfile/writer.js";
import { isGitLocked } from "../../lockfile/schema.js";
import type { Lockfile, LockedSkill } from "../../lockfile/schema.js";
import { resolveSkill, resolveWildcardSkills } from "../../skills/resolver.js";
import { resolveSkill, resolveWildcardSkills, sourcesMatch } from "../../skills/resolver.js";
import { validateTrustedSource, TrustError } from "../../trust/index.js";
import type { ResolvedSkill } from "../../skills/resolver.js";
import { hashDirectory } from "../../utils/hash.js";
Expand Down Expand Up @@ -88,7 +88,7 @@ async function expandSkills(
// In frozen mode, expand from lockfile — no network needed
if (!lockfile) continue;
for (const [name, locked] of Object.entries(lockfile.skills)) {
if (locked.source !== wDep.source) continue;
if (!sourcesMatch(locked.source, wDep.source)) continue;
if (explicitNames.has(name)) continue;
if (excludeSet.has(name)) continue;

Expand All @@ -103,7 +103,7 @@ async function expandSkills(

// Check for conflicts between different wildcards
const existingSource = wildcardNames.get(name);
if (existingSource && existingSource !== wDep.source) {
if (existingSource && !sourcesMatch(existingSource, wDep.source)) {
throw new InstallError(
`Skill "${name}" found in both wildcard sources: "${existingSource}" and "${wDep.source}". ` +
`Use an explicit [[skills]] entry or add it to one source's exclude list.`,
Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { loadConfig } from "../../config/loader.js";
import { isWildcardDep } from "../../config/schema.js";
import { loadLockfile } from "../../lockfile/loader.js";
import { isGitLocked } from "../../lockfile/schema.js";
import { sourcesMatch } from "../../skills/resolver.js";
import { hashDirectory } from "../../utils/hash.js";
import { existsSync } from "node:fs";
import { resolveScope, resolveDefaultScope, ScopeError } from "../../scope.js";
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function runList(opts: ListOptions): Promise<SkillStatus[]> {
for (const wDep of wildcardDeps) {
const excludeSet = new Set(wDep.exclude);
for (const [name, locked] of Object.entries(lockfile.skills)) {
if (locked.source !== wDep.source) continue;
if (!sourcesMatch(locked.source, wDep.source)) continue;
if (explicitNames.has(name)) continue;
if (excludeSet.has(name)) continue;
if (skillEntries.has(name)) continue;
Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { removeSkillFromConfig, addExcludeToWildcard } from "../../config/writer
import { loadLockfile } from "../../lockfile/loader.js";
import { writeLockfile } from "../../lockfile/writer.js";
import { updateAgentsGitignore } from "../../gitignore/writer.js";
import { sourcesMatch } from "../../skills/resolver.js";
import { resolveScope, resolveDefaultScope, ScopeError } from "../../scope.js";
import type { ScopeRoot } from "../../scope.js";

Expand Down Expand Up @@ -75,7 +76,7 @@ export async function runRemove(opts: RemoveOptions): Promise<void> {
const locked = lockfile?.skills[skillName];
if (locked) {
const wildcardDep = config.skills.find(
(s) => isWildcardDep(s) && s.source === locked.source,
(s) => isWildcardDep(s) && sourcesMatch(s.source, locked.source),
);
if (wildcardDep) {
throw new WildcardSkillRemoveError(skillName, locked.source);
Expand Down
5 changes: 3 additions & 2 deletions src/cli/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readdir } from "node:fs/promises";
import chalk from "chalk";
import { loadConfig } from "../../config/loader.js";
import { isWildcardDep } from "../../config/schema.js";
import { normalizeSource } from "../../skills/resolver.js";
import { loadLockfile } from "../../lockfile/loader.js";
import { writeLockfile } from "../../lockfile/writer.js";
import { addSkillToConfig } from "../../config/writer.js";
Expand Down Expand Up @@ -54,10 +55,10 @@ export async function runSync(opts: SyncOptions): Promise<SyncResult> {
if (lockfile) {
// Add concrete skill names from wildcard sources
const wildcardSources = new Set(
config.skills.filter(isWildcardDep).map((s) => s.source),
config.skills.filter(isWildcardDep).map((s) => normalizeSource(s.source)),
);
for (const [name, locked] of Object.entries(lockfile.skills)) {
if (wildcardSources.has(locked.source)) {
if (wildcardSources.has(normalizeSource(locked.source))) {
declaredNames.add(name);
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { isWildcardDep } from "../../config/schema.js";
import type { WildcardSkillDependency } from "../../config/schema.js";
import { loadLockfile } from "../../lockfile/loader.js";
import { isGitLocked } from "../../lockfile/schema.js";
import { resolveSkill, resolveWildcardSkills } from "../../skills/resolver.js";
import { resolveSkill, resolveWildcardSkills, sourcesMatch } from "../../skills/resolver.js";
import { validateTrustedSource, TrustError } from "../../trust/index.js";
import { hashDirectory } from "../../utils/hash.js";
import { copyDir } from "../../utils/fs.js";
Expand Down Expand Up @@ -75,7 +75,7 @@ export async function runUpdate(opts: UpdateOptions): Promise<UpdateResult> {
if (!locked) {
throw new UpdateError(`Skill "${skillName}" not found in agents.toml or lockfile.`);
}
const wDep = wildcardDeps.find((w) => w.source === locked.source);
const wDep = wildcardDeps.find((w) => sourcesMatch(w.source, locked.source));
if (!wDep) {
throw new UpdateError(`Skill "${skillName}" not found in agents.toml.`);
}
Expand Down Expand Up @@ -180,7 +180,7 @@ async function updateWildcardSource(

// Find lockfile entries that belong to this wildcard source
const lockedFromSource = Object.entries(lockfile.skills).filter(
([name, locked]) => locked.source === wDep.source && !explicitNames.has(name),
([name, locked]) => sourcesMatch(locked.source, wDep.source) && !explicitNames.has(name),
);

// Process discovered skills (new + changed)
Expand Down
3 changes: 2 additions & 1 deletion src/config/writer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFile, writeFile } from "node:fs/promises";
import { stringify } from "smol-toml";
import type { WildcardSkillDependency, TrustConfig, McpConfig } from "./schema.js";
import { sourcesMatch } from "../skills/resolver.js";

export interface DefaultConfigOptions {
agents?: string[];
Expand Down Expand Up @@ -93,7 +94,7 @@ export async function addExcludeToWildcard(
const isWildcard = nameLine?.trim().match(/^name\s*=\s*"\*"/);
const sourceMatch = sourceLine?.trim().match(/^source\s*=\s*"([^"]+)"/);

if (isWildcard && sourceMatch && sourceMatch[1] === source && !found) {
if (isWildcard && sourceMatch && sourcesMatch(sourceMatch[1]!, source) && !found) {
found = true;
// Find or create exclude line
const excludeIdx = blockLines.findIndex((l) => l.trim().startsWith("exclude"));
Expand Down
77 changes: 76 additions & 1 deletion src/skills/resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseSource } from "./resolver.js";
import { parseSource, normalizeSource, sourcesMatch } from "./resolver.js";

describe("parseSource", () => {
it("parses owner/repo as github", () => {
Expand All @@ -8,6 +8,7 @@ describe("parseSource", () => {
expect(result.owner).toBe("anthropics");
expect(result.repo).toBe("skills");
expect(result.url).toBe("https://github.com/anthropics/skills.git");
expect(result.cloneUrl).toBeUndefined();
expect(result.ref).toBeUndefined();
});

Expand Down Expand Up @@ -46,6 +47,7 @@ describe("parseSource", () => {
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
expect(result.cloneUrl).toBe("https://github.com/getsentry/skills");
expect(result.ref).toBeUndefined();
});

Expand All @@ -71,6 +73,7 @@ describe("parseSource", () => {
expect(result.repo).toBe("skills");
expect(result.ref).toBe("v1.0.0");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
expect(result.cloneUrl).toBe("https://github.com/getsentry/skills");
});

it("parses SSH GitHub URL", () => {
Expand All @@ -79,6 +82,7 @@ describe("parseSource", () => {
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
expect(result.cloneUrl).toBe("git@github.com:getsentry/skills");
expect(result.ref).toBeUndefined();
});

Expand All @@ -88,6 +92,7 @@ describe("parseSource", () => {
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("skills");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
expect(result.cloneUrl).toBe("git@github.com:getsentry/skills.git");
});

it("parses SSH GitHub URL with @ref", () => {
Expand All @@ -97,6 +102,7 @@ describe("parseSource", () => {
expect(result.repo).toBe("skills");
expect(result.ref).toBe("v2.0");
expect(result.url).toBe("https://github.com/getsentry/skills.git");
expect(result.cloneUrl).toBe("git@github.com:getsentry/skills");
});

it("parses HTTPS GitHub URL with dotted repo name", () => {
Expand All @@ -114,4 +120,73 @@ describe("parseSource", () => {
expect(result.repo).toBe("next.js");
expect(result.url).toBe("https://github.com/vercel/next.js.git");
});

it("upgrades http:// to https:// in cloneUrl", () => {
const result = parseSource("http://github.com/getsentry/skills");
expect(result.type).toBe("github");
expect(result.cloneUrl).toBe("https://github.com/getsentry/skills");
});

it("does not set cloneUrl for owner/repo shorthand", () => {
const result = parseSource("getsentry/skills@v1.0");
expect(result.cloneUrl).toBeUndefined();
});

it("strips ref containing @ from cloneUrl correctly", () => {
const result = parseSource("git@github.com:org/repo@packages/foo@1.0.0");
expect(result.type).toBe("github");
expect(result.owner).toBe("org");
expect(result.repo).toBe("repo");
expect(result.ref).toBe("packages/foo@1.0.0");
expect(result.cloneUrl).toBe("git@github.com:org/repo");
});
});

describe("normalizeSource", () => {
it("normalizes owner/repo shorthand to itself", () => {
expect(normalizeSource("getsentry/skills")).toBe("getsentry/skills");
});

it("normalizes HTTPS URL to owner/repo", () => {
expect(normalizeSource("https://github.com/getsentry/skills")).toBe("getsentry/skills");
});

it("normalizes SSH URL to owner/repo", () => {
expect(normalizeSource("git@github.com:getsentry/skills.git")).toBe("getsentry/skills");
});

it("normalizes HTTPS URL with .git suffix", () => {
expect(normalizeSource("https://github.com/getsentry/skills.git")).toBe("getsentry/skills");
});

it("returns non-github sources unchanged", () => {
expect(normalizeSource("path:../my-skill")).toBe("path:../my-skill");
expect(normalizeSource("git:https://example.com/repo.git")).toBe("git:https://example.com/repo.git");
});
});

describe("sourcesMatch", () => {
it("matches identical shorthand", () => {
expect(sourcesMatch("getsentry/skills", "getsentry/skills")).toBe(true);
});

it("matches SSH URL with shorthand", () => {
expect(sourcesMatch("git@github.com:getsentry/skills.git", "getsentry/skills")).toBe(true);
});

it("matches HTTPS URL with shorthand", () => {
expect(sourcesMatch("https://github.com/getsentry/skills", "getsentry/skills")).toBe(true);
});

it("matches SSH URL with HTTPS URL", () => {
expect(sourcesMatch("git@github.com:getsentry/skills.git", "https://github.com/getsentry/skills")).toBe(true);
});

it("does not match different repos", () => {
expect(sourcesMatch("getsentry/skills", "getsentry/other")).toBe(false);
});

it("does not match different owners", () => {
expect(sourcesMatch("getsentry/skills", "anthropics/skills")).toBe(false);
});
});
Loading