diff --git a/README.md b/README.md
index 91175d4..e25e065 100644
--- a/README.md
+++ b/README.md
@@ -19,13 +19,13 @@ A package manager for `.agents` directories. Declare agent skill dependencies in
npx @sentry/dotagents init
# Add a skill from a GitHub repo
-npx @sentry/dotagents add getsentry/skills find-bugs
+npx @sentry/dotagents add https://github.com/getsentry/skills find-bugs
# Add multiple skills at once
-npx @sentry/dotagents add getsentry/skills find-bugs code-review commit
+npx @sentry/dotagents add https://github.com/getsentry/skills find-bugs code-review commit
# Or add all skills from a repo
-npx @sentry/dotagents add getsentry/skills --all
+npx @sentry/dotagents add https://github.com/getsentry/skills --all
# Install all declared skills
npx @sentry/dotagents install
@@ -40,23 +40,23 @@ agents = ["claude"]
[[skills]]
name = "find-bugs"
-source = "getsentry/skills"
+source = "https://github.com/getsentry/skills"
```
And a lockfile (`agents.lock`) pinning the exact commit and integrity hash.
## Commands
-| Command | Description |
-|---------|-------------|
-| `init` | Create `agents.toml` and `.agents/skills/` |
-| `add [skills...]` | Add skill dependencies |
-| `remove ` | Remove a skill |
-| `install` | Install all dependencies from `agents.toml` |
-| `update [name]` | Update skills to latest versions |
-| `list` | Show installed skills and their status |
-| `sync` | Reconcile gitignore, symlinks, and verify state |
-| `mcp` | Manage MCP server declarations |
+| Command | Description |
+| -------------------------- | ----------------------------------------------- |
+| `init` | Create `agents.toml` and `.agents/skills/` |
+| `add [skills...]` | Add skill dependencies |
+| `remove ` | Remove a skill |
+| `install` | Install all dependencies from `agents.toml` |
+| `update [name]` | Update skills to latest versions |
+| `list` | Show installed skills and their status |
+| `sync` | Reconcile gitignore, symlinks, and verify state |
+| `mcp` | Manage MCP server declarations |
All commands accept `--user` to operate on user scope (`~/.agents/`) instead of the current project.
@@ -80,31 +80,34 @@ Use `--frozen` in CI to fail if the lockfile is missing or out of sync. Use `--f
```bash
dotagents add [...] [--skill ...] [--ref [] [--all]
+dotagents add [gh|gl] ] [...] [--skill ...] [--ref [] [--all]
```
Add one or more skills and install them. Specify skill names as positional arguments or with `--skill` flags.
```bash
# Add a single skill
-dotagents add getsentry/skills find-bugs
+dotagents add https://github.com/getsentry/skills find-bugs
# Add multiple skills
-dotagents add getsentry/skills find-bugs code-review commit
+dotagents add https://github.com/getsentry/skills find-bugs code-review commit
# Equivalent using --skill flags
-dotagents add getsentry/skills --skill find-bugs --skill code-review
+dotagents add https://github.com/getsentry/skills --skill find-bugs --skill code-review
# Pin to a ref
-dotagents add getsentry/skills find-bugs --ref v1.0.0
+dotagents add https://github.com/getsentry/skills find-bugs --ref v1.0.0
# Add all skills as a wildcard entry
-dotagents add getsentry/skills --all
+dotagents add https://github.com/getsentry/skills --all
```
When a repo has one skill, it's added automatically. When multiple are found and no names are given, interactive mode shows a picker.
When adding multiple skills, any that already exist in `agents.toml` are skipped with a warning. The rest are added normally.
+Use explicit URLs (for example `https://github.com/getsentry/skills`) or use source hints with shorthand (for example `gh getsentry/skills` or `gl getsentry/skills`).
+
### remove
```bash
@@ -160,11 +163,15 @@ dotagents mcp list [--json]
```toml
[[skills]]
name = "find-bugs"
-source = "getsentry/skills" # GitHub repo
+source = "https://github.com/getsentry/skills" # GitHub URL
[[skills]]
name = "review"
-source = "getsentry/skills@v1.0.0" # Pinned to a ref
+source = "https://github.com/getsentry/skills@v1.0.0" # Pinned to a ref
+
+[[skills]]
+name = "gitlab"
+source = "https://gitlab.com/group/repo" # GitLab URL
[[skills]]
name = "internal"
@@ -182,11 +189,12 @@ Add all skills from a repo with a single entry. Use `exclude` to skip specific o
```toml
[[skills]]
name = "*"
-source = "getsentry/skills"
+source = "https://github.com/getsentry/skills"
exclude = ["deprecated-skill"]
```
-Or from the CLI: `dotagents add getsentry/skills --all`
+Or from the CLI: `dotagents add https://github.com/getsentry/skills --all`
+Or with shorthand + hint: `dotagents add gh getsentry/skills --all`
## Agent Targets
@@ -196,13 +204,13 @@ The `agents` field tells dotagents which tools to configure.
agents = ["claude", "cursor"]
```
-| Agent | Config Dir | MCP Config | Hooks |
-|-------|-----------|------------|-------|
-| `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` |
-| `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` |
-| `codex` | `.codex` | `.codex/config.toml` | -- |
-| `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` |
-| `opencode` | `.claude` | `opencode.json` | -- |
+| Agent | Config Dir | MCP Config | Hooks |
+| ---------- | ---------- | -------------------- | ----------------------- |
+| `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` |
+| `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` |
+| `codex` | `.codex` | `.codex/config.toml` | -- |
+| `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` |
+| `opencode` | `.claude` | `opencode.json` | -- |
### Pi
@@ -266,7 +274,7 @@ Use `--user` to manage skills shared across all projects:
```bash
dotagents --user init
-dotagents --user add getsentry/skills --all
+dotagents --user add https://github.com/getsentry/skills --all
```
User-scope files live in `~/.agents/` (override with `DOTAGENTS_HOME`).
diff --git a/docs/src/app/cli/page.tsx b/docs/src/app/cli/page.tsx
index 618417b..947bddf 100644
--- a/docs/src/app/cli/page.tsx
+++ b/docs/src/app/cli/page.tsx
@@ -59,8 +59,8 @@ export default function CliPage() {
]Global Options
- --user Operate on user scope (
- ~/.agents/) instead of the current project
+ --user Operate on user scope (~/.agents/)
+ instead of the current project
--help, -h Show help
@@ -127,13 +127,16 @@ export default function CliPage() {
Add a skill dependency and install it. Auto-discovers skills in
the repo. When a repo has one skill, it is added automatically.
When multiple are found, use --name to pick one or{" "}
- --all to add them all as a wildcard entry.
+ --all to add them all as a wildcard entry. Use
+ explicit git URLs, or use source hints with shorthand such as{" "}
+ gh getsentry/skills and{" "}
+ gl getsentry/skills.
>
}
options={[
@@ -153,13 +156,20 @@ export default function CliPage() {
]}
examples={[
"# Single skill from GitHub",
- "dotagents add getsentry/skills --name find-bugs",
+ "dotagents add https://github.com/getsentry/skills --name find-bugs",
+ "dotagents add gh getsentry/skills --name find-bugs",
"",
"# All skills from a repo",
- "dotagents add getsentry/skills --all",
+ "dotagents add https://github.com/getsentry/skills --all",
+ "dotagents add gh getsentry/skills --all",
"",
"# Pinned to a version",
- "dotagents add getsentry/warden@v1.0.0",
+ "dotagents add https://github.com/getsentry/warden@v1.0.0",
+ "dotagents add gh getsentry/warden@v1.0.0",
+ "",
+ "# Hosted GitLab",
+ "dotagents add https://gitlab.com/group/repo --name find-bugs",
+ "dotagents add gl group/repo --name find-bugs",
"",
"# Non-GitHub git server",
"dotagents add git:https://git.corp.dev/team/skills --name review",
@@ -206,9 +216,9 @@ dotagents mcp add --url [--header ...] [--env ...]"
description={
<>
Add an MCP server declaration to agents.toml and run{" "}
- install to generate agent configs. Specify exactly one
- transport: --command for stdio or --url{" "}
- for HTTP.
+ install to generate agent configs. Specify exactly
+ one transport: --command for stdio or{" "}
+ --url for HTTP.
>
}
options={[
@@ -264,10 +274,7 @@ dotagents mcp add --url [--header ...] [--env ...]"
machine-readable output.
>
}
- examples={[
- "dotagents mcp list",
- "dotagents mcp list --json",
- ]}
+ examples={["dotagents mcp list", "dotagents mcp list --json"]}
/>
--url [--header ...] [--env ...]"
Agent targets: claude, cursor,{" "}
- codex, vscode,{" "}
- opencode
+ codex, vscode, opencode
@@ -534,7 +540,7 @@ dotagents mcp add --url [--header ...] [--env ...]"
Git URL
@@ -272,17 +290,17 @@ github_orgs = ["getsentry"]
# Individual skill
[[skills]]
name = "find-bugs"
-source = "getsentry/skills"
+source = "https://github.com/getsentry/skills"
# Pinned to a ref
[[skills]]
name = "warden-skill"
-source = "getsentry/warden@v1.0.0"
+source = "https://github.com/getsentry/warden@v1.0.0"
# Wildcard: all skills from a repo
[[skills]]
name = "*"
-source = "myorg/skills"
+source = "https://github.com/myorg/skills"
exclude = ["deprecated-skill"]
# MCP server (stdio)
diff --git a/src/cli/commands/add.test.ts b/src/cli/commands/add.test.ts
index d5eb993..1ef7a0e 100644
--- a/src/cli/commands/add.test.ts
+++ b/src/cli/commands/add.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
-import { runAdd, AddError } from "./add.js";
+import { runAdd, AddError, applyAddSourceHint } from "./add.js";
import add from "./add.js";
import { exec } from "../../utils/exec.js";
import { resolveScope } from "../../scope.js";
@@ -15,6 +15,32 @@ description: Test skill ${name}
# ${name}
`;
+describe("applyAddSourceHint", () => {
+ it("expands gh shorthand to GitHub URL", () => {
+ expect(applyAddSourceHint("getsentry/skills", "gh")).toBe(
+ "https://github.com/getsentry/skills",
+ );
+ });
+
+ it("expands gl shorthand to GitLab URL", () => {
+ expect(applyAddSourceHint("getsentry/skills", "gl")).toBe(
+ "https://gitlab.com/getsentry/skills",
+ );
+ });
+
+ it("preserves @ref when expanding shorthand", () => {
+ expect(applyAddSourceHint("getsentry/skills@v1.0.0", "gh")).toBe(
+ "https://github.com/getsentry/skills@v1.0.0",
+ );
+ });
+
+ it("throws when hint is used with explicit URLs", () => {
+ expect(() =>
+ applyAddSourceHint("https://github.com/getsentry/skills", "gh"),
+ ).toThrow(AddError);
+ });
+});
+
describe("runAdd", () => {
let tmpDir: string;
let stateDir: string;
@@ -36,7 +62,9 @@ describe("runAdd", () => {
// Create a local git repo with skills
await mkdir(repoDir, { recursive: true });
await exec("git", ["init"], { cwd: repoDir });
- await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir });
+ await exec("git", ["config", "user.email", "test@test.com"], {
+ cwd: repoDir,
+ });
await exec("git", ["config", "user.name", "Test"], { cwd: repoDir });
await mkdir(join(repoDir, "pdf"), { recursive: true });
@@ -44,10 +72,16 @@ describe("runAdd", () => {
await writeFile(join(repoDir, "pdf", "prompt.md"), "Process PDFs");
await mkdir(join(repoDir, "skills", "review"), { recursive: true });
- await writeFile(join(repoDir, "skills", "review", "SKILL.md"), SKILL_MD("review"));
+ await writeFile(
+ join(repoDir, "skills", "review", "SKILL.md"),
+ SKILL_MD("review"),
+ );
await mkdir(join(repoDir, "skills", "commit"), { recursive: true });
- await writeFile(join(repoDir, "skills", "commit", "SKILL.md"), SKILL_MD("commit"));
+ await writeFile(
+ join(repoDir, "skills", "commit", "SKILL.md"),
+ SKILL_MD("commit"),
+ );
await exec("git", ["add", "."], { cwd: repoDir });
await exec("git", ["commit", "-m", "initial"], { cwd: repoDir });
@@ -175,15 +209,32 @@ describe("runAdd", () => {
).rejects.toThrow(AddError);
});
+ it("rejects owner/repo shorthand source", async () => {
+ const scope = resolveScope("project", projectRoot);
+
+ await expect(
+ runAdd({
+ scope,
+ specifier: "getsentry/skills",
+ names: ["pdf"],
+ }),
+ ).rejects.toThrow(/Shorthand source/);
+ });
+
it("auto-selects when repo has a single skill", async () => {
// Create a repo with only one skill
const singleRepo = join(tmpDir, "single-repo");
await mkdir(singleRepo, { recursive: true });
await exec("git", ["init"], { cwd: singleRepo });
- await exec("git", ["config", "user.email", "test@test.com"], { cwd: singleRepo });
+ await exec("git", ["config", "user.email", "test@test.com"], {
+ cwd: singleRepo,
+ });
await exec("git", ["config", "user.name", "Test"], { cwd: singleRepo });
await mkdir(join(singleRepo, "only-skill"), { recursive: true });
- await writeFile(join(singleRepo, "only-skill", "SKILL.md"), SKILL_MD("only-skill"));
+ await writeFile(
+ join(singleRepo, "only-skill", "SKILL.md"),
+ SKILL_MD("only-skill"),
+ );
await exec("git", ["add", "."], { cwd: singleRepo });
await exec("git", ["commit", "-m", "initial"], { cwd: singleRepo });
@@ -232,10 +283,16 @@ describe("runAdd (local sources)", () => {
await writeFile(join(localSkillsDir, "pdf", "SKILL.md"), SKILL_MD("pdf"));
await mkdir(join(localSkillsDir, "skills", "review"), { recursive: true });
- await writeFile(join(localSkillsDir, "skills", "review", "SKILL.md"), SKILL_MD("review"));
+ await writeFile(
+ join(localSkillsDir, "skills", "review", "SKILL.md"),
+ SKILL_MD("review"),
+ );
await mkdir(join(localSkillsDir, "skills", "commit"), { recursive: true });
- await writeFile(join(localSkillsDir, "skills", "commit", "SKILL.md"), SKILL_MD("commit"));
+ await writeFile(
+ join(localSkillsDir, "skills", "commit", "SKILL.md"),
+ SKILL_MD("commit"),
+ );
});
afterEach(async () => {
@@ -373,14 +430,19 @@ describe("add() CLI parsing", () => {
// Create a local git repo with skills
await mkdir(repoDir, { recursive: true });
await exec("git", ["init"], { cwd: repoDir });
- await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir });
+ await exec("git", ["config", "user.email", "test@test.com"], {
+ cwd: repoDir,
+ });
await exec("git", ["config", "user.name", "Test"], { cwd: repoDir });
await mkdir(join(repoDir, "pdf"), { recursive: true });
await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf"));
await mkdir(join(repoDir, "skills", "review"), { recursive: true });
- await writeFile(join(repoDir, "skills", "review", "SKILL.md"), SKILL_MD("review"));
+ await writeFile(
+ join(repoDir, "skills", "review", "SKILL.md"),
+ SKILL_MD("review"),
+ );
await exec("git", ["add", "."], { cwd: repoDir });
await exec("git", ["commit", "-m", "initial"], { cwd: repoDir });
diff --git a/src/cli/commands/add.ts b/src/cli/commands/add.ts
index 933ddcc..afa4b02 100644
--- a/src/cli/commands/add.ts
+++ b/src/cli/commands/add.ts
@@ -5,7 +5,11 @@ 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, sourcesMatch, VALID_SKILL_NAME } from "../../skills/resolver.js";
+import {
+ parseSource,
+ sourcesMatch,
+ VALID_SKILL_NAME,
+} from "../../skills/resolver.js";
import { discoverAllSkills } from "../../skills/discovery.js";
import { ensureCached } from "../../sources/cache.js";
import { validateTrustedSource, TrustError } from "../../trust/index.js";
@@ -30,29 +34,100 @@ export class AddCancelledError extends Error {
export interface AddOptions {
scope: ScopeRoot;
specifier: string;
+ sourceHint?: AddSourceHint;
ref?: string;
names?: string[];
all?: boolean;
interactive?: boolean;
}
+export type AddSourceHint = "gh" | "gl";
+
+function isAddSourceHint(value: string): value is AddSourceHint {
+ return value === "gh" || value === "gl";
+}
+
+function isExplicitSourceSpecifier(specifier: string): boolean {
+ return (
+ specifier.startsWith("path:") ||
+ specifier.startsWith("git:") ||
+ specifier.startsWith("http://") ||
+ specifier.startsWith("https://") ||
+ specifier.startsWith("git@")
+ );
+}
+
+function parseOwnerRepoShorthand(
+ specifier: string,
+): { owner: string; repo: string; ref?: string } | undefined {
+ if (isExplicitSourceSpecifier(specifier)) return undefined;
+
+ const atIdx = specifier.indexOf("@");
+ const base = atIdx !== -1 ? specifier.slice(0, atIdx) : specifier;
+ const ref = atIdx !== -1 ? specifier.slice(atIdx + 1) : undefined;
+ const parts = base.split("/");
+ if (parts.length !== 2) return undefined;
+
+ const [owner, repo] = parts;
+ if (!owner || !repo || owner.startsWith("-") || repo.startsWith("-")) {
+ return undefined;
+ }
+
+ return { owner, repo, ref };
+}
+
+export function applyAddSourceHint(
+ specifier: string,
+ sourceHint?: AddSourceHint,
+): string {
+ if (!sourceHint) return specifier;
+
+ const shorthand = parseOwnerRepoShorthand(specifier);
+ if (!shorthand) {
+ throw new AddError(
+ `Source hint "${sourceHint}" requires owner/repo shorthand, ` +
+ `e.g. 'dotagents add ${sourceHint} getsentry/skills --all'.`,
+ );
+ }
+
+ const host = sourceHint === "gh" ? "github.com" : "gitlab.com";
+ const refSuffix = shorthand.ref ? `@${shorthand.ref}` : "";
+ return `https://${host}/${shorthand.owner}/${shorthand.repo}${refSuffix}`;
+}
+
export async function runAdd(opts: AddOptions): Promise {
- const { scope, specifier, ref, names: rawNames, all, interactive } = opts;
+ const {
+ scope,
+ specifier,
+ sourceHint,
+ ref,
+ names: rawNames,
+ all,
+ interactive,
+ } = opts;
// Deduplicate names to prevent writing duplicate config entries
const namesOverride = rawNames ? [...new Set(rawNames)] : rawNames;
const { configPath } = scope;
+ const hintedSpecifier = applyAddSourceHint(specifier, sourceHint);
+
// Load config early so we can check trust before any network work
const config = await loadConfig(configPath);
// Parse the specifier
- const parsed = parseSource(specifier);
+ const parsed = parseSource(hintedSpecifier);
+
+ if (parsed.type === "github" && !isExplicitSourceSpecifier(hintedSpecifier)) {
+ throw new AddError(
+ `Shorthand source "${hintedSpecifier}" is no longer supported in 'dotagents add'. ` +
+ `Use an explicit URL instead, e.g. https://github.com/getsentry/skills or https://gitlab.com/group/repo.`,
+ );
+ }
// 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;
+ const sourceForStorage = parsed.ref
+ ? hintedSpecifier.slice(0, -(parsed.ref.length + 1))
+ : hintedSpecifier;
// Validate trust against the source
validateTrustedSource(sourceForStorage, config.trust);
@@ -67,7 +142,11 @@ export async function runAdd(opts: AddOptions): Promise {
throw new AddError("Cannot use --all with --name. Use one or the other.");
}
- if (config.skills.some((s) => isWildcardDep(s) && sourcesMatch(s.source, sourceForStorage))) {
+ if (
+ config.skills.some(
+ (s) => isWildcardDep(s) && sourcesMatch(s.source, sourceForStorage),
+ )
+ ) {
throw new AddError(
`A wildcard entry for "${sourceForStorage}" already exists in agents.toml.`,
);
@@ -121,14 +200,18 @@ export async function runAdd(opts: AddOptions): Promise {
const toAdd: string[] = [];
for (const name of namesOverride) {
if (config.skills.some((s) => s.name === name)) {
- console.warn(chalk.yellow(`Skipping "${name}": already exists in agents.toml`));
+ console.warn(
+ chalk.yellow(`Skipping "${name}": already exists in agents.toml`),
+ );
} else {
toAdd.push(name);
}
}
if (toAdd.length === 0) {
- throw new AddError("All specified skills already exist in agents.toml.");
+ throw new AddError(
+ "All specified skills already exist in agents.toml.",
+ );
}
for (const name of toAdd) {
@@ -156,7 +239,11 @@ export async function runAdd(opts: AddOptions): Promise {
? `${parsed.owner}/${parsed.repo}`
: url.replace(/^https?:\/\//, "").replace(/\.git$/, "");
- const cached = await ensureCached({ url: cloneUrl, cacheKey, ref: effectiveRef });
+ const cached = await ensureCached({
+ url: cloneUrl,
+ cacheKey,
+ ref: effectiveRef,
+ });
if (namesOverride?.length) {
// User specified name(s), verify each exists
@@ -179,14 +266,18 @@ export async function runAdd(opts: AddOptions): Promise {
const toAdd: string[] = [];
for (const name of namesOverride) {
if (config.skills.some((s) => s.name === name)) {
- console.warn(chalk.yellow(`Skipping "${name}": already exists in agents.toml`));
+ console.warn(
+ chalk.yellow(`Skipping "${name}": already exists in agents.toml`),
+ );
} else {
toAdd.push(name);
}
}
if (toAdd.length === 0) {
- throw new AddError("All specified skills already exist in agents.toml.");
+ throw new AddError(
+ "All specified skills already exist in agents.toml.",
+ );
}
for (const name of toAdd) {
@@ -226,7 +317,12 @@ export async function runAdd(opts: AddOptions): Promise {
if (selected.length === skills.length) {
// All selected — add wildcard entry
- if (config.skills.some((s) => isWildcardDep(s) && sourcesMatch(s.source, sourceForStorage))) {
+ if (
+ config.skills.some(
+ (s) =>
+ isWildcardDep(s) && sourcesMatch(s.source, sourceForStorage),
+ )
+ ) {
throw new AddError(
`A wildcard entry for "${sourceForStorage}" already exists in agents.toml.`,
);
@@ -253,7 +349,9 @@ export async function runAdd(opts: AddOptions): Promise {
added.push(name);
}
if (added.length === 0) {
- throw new AddError("All selected skills already exist in agents.toml.");
+ throw new AddError(
+ "All selected skills already exist in agents.toml.",
+ );
}
await runInstall({ scope });
return added;
@@ -288,7 +386,10 @@ export async function runAdd(opts: AddOptions): Promise {
return skillName;
}
-export default async function add(args: string[], flags?: { user?: boolean }): Promise {
+export default async function add(
+ args: string[],
+ flags?: { user?: boolean },
+): Promise {
const { positionals, values } = parseArgs({
args,
allowPositionals: true,
@@ -301,22 +402,33 @@ export default async function add(args: string[], flags?: { user?: boolean }): P
strict: true,
});
- const specifier = positionals[0];
+ const firstPositional = positionals[0];
+ const sourceHint =
+ firstPositional && isAddSourceHint(firstPositional)
+ ? firstPositional
+ : undefined;
+ const specifier = sourceHint ? positionals[1] : positionals[0];
if (!specifier) {
console.error(
- chalk.red("Usage: dotagents add [...] [--skill ...] [--ref [] [--all]"),
+ chalk.red(
+ "Usage: dotagents add [gh|gl] ] [...] [--skill ...] [--ref [] [--all]",
+ ),
);
process.exitCode = 1;
return;
}
// Collect skill names from positional args and flags
- const positionalNames = positionals.slice(1);
+ const positionalNames = sourceHint
+ ? positionals.slice(2)
+ : positionals.slice(1);
const flagNames = [...(values["name"] ?? []), ...(values["skill"] ?? [])];
if (positionalNames.length > 0 && flagNames.length > 0) {
console.error(
- chalk.red("Cannot mix positional skill names with --skill/--name flags. Use one or the other."),
+ chalk.red(
+ "Cannot mix positional skill names with --skill/--name flags. Use one or the other.",
+ ),
);
process.exitCode = 1;
return;
@@ -326,11 +438,15 @@ export default async function add(args: string[], flags?: { user?: boolean }): P
const names = rawNames.length > 0 ? [...new Set(rawNames)] : undefined;
try {
- const scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve("."));
- const interactive = process.stdout.isTTY === true && !names && !values["all"];
+ const scope = flags?.user
+ ? resolveScope("user")
+ : resolveDefaultScope(resolve("."));
+ const interactive =
+ process.stdout.isTTY === true && !names && !values["all"];
const result = await runAdd({
scope,
specifier,
+ sourceHint,
ref: values["ref"],
names,
all: values["all"],
@@ -345,7 +461,11 @@ export default async function add(args: string[], flags?: { user?: boolean }): P
}
} catch (err) {
if (err instanceof AddCancelledError) return;
- if (err instanceof ScopeError || err instanceof AddError || err instanceof TrustError) {
+ if (
+ err instanceof ScopeError ||
+ err instanceof AddError ||
+ err instanceof TrustError
+ ) {
console.error(chalk.red(err.message));
process.exitCode = 1;
return;
diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts
index 633689b..90fb3fc 100644
--- a/src/config/schema.test.ts
+++ b/src/config/schema.test.ts
@@ -21,7 +21,10 @@ describe("agentsConfigSchema", () => {
});
it("parses gitignore = true", () => {
- const result = agentsConfigSchema.safeParse({ version: 1, gitignore: true });
+ const result = agentsConfigSchema.safeParse({
+ version: 1,
+ gitignore: true,
+ });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.gitignore).toBe(true);
@@ -29,7 +32,10 @@ describe("agentsConfigSchema", () => {
});
it("parses gitignore = false", () => {
- const result = agentsConfigSchema.safeParse({ version: 1, gitignore: false });
+ const result = agentsConfigSchema.safeParse({
+ version: 1,
+ gitignore: false,
+ });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.gitignore).toBe(false);
@@ -93,11 +99,15 @@ describe("agentsConfigSchema", () => {
});
it("accepts git: source with ssh", () => {
- expect(parseSkill("git:ssh://git@example.com/repo.git").success).toBe(true);
+ expect(parseSkill("git:ssh://git@example.com/repo.git").success).toBe(
+ true,
+ );
});
it("accepts git: source with git@", () => {
- expect(parseSkill("git:git@github.com:owner/repo.git").success).toBe(true);
+ expect(parseSkill("git:git@github.com:owner/repo.git").success).toBe(
+ true,
+ );
});
it("accepts git: source with absolute path", () => {
@@ -137,13 +147,29 @@ describe("agentsConfigSchema", () => {
});
it("accepts GitHub HTTPS URL with .git suffix", () => {
- expect(parseSkill("https://github.com/owner/repo.git").success).toBe(true);
+ 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("accepts GitLab HTTPS URL", () => {
+ expect(parseSkill("https://gitlab.com/group/repo").success).toBe(true);
+ });
+
+ it("accepts GitLab SSH URL", () => {
+ expect(parseSkill("git@gitlab.com:group/repo.git").success).toBe(true);
+ });
+
+ it("accepts GitLab subgroup URL", () => {
+ expect(parseSkill("https://gitlab.com/group/subgroup/repo").success).toBe(
+ true,
+ );
+ });
+
it("rejects GitHub URL with dash-prefixed owner", () => {
expect(parseSkill("https://github.com/-bad/repo").success).toBe(false);
});
@@ -214,7 +240,13 @@ describe("agentsConfigSchema", () => {
it("accepts a stdio MCP server", () => {
const result = agentsConfigSchema.safeParse({
version: 1,
- mcp: [{ name: "github", command: "npx", args: ["-y", "@mcp/server-github"] }],
+ mcp: [
+ {
+ name: "github",
+ command: "npx",
+ args: ["-y", "@mcp/server-github"],
+ },
+ ],
});
expect(result.success).toBe(true);
if (result.success) {
@@ -248,7 +280,13 @@ describe("agentsConfigSchema", () => {
it("accepts MCP server with headers", () => {
const result = agentsConfigSchema.safeParse({
version: 1,
- mcp: [{ name: "r", url: "https://x.com", headers: { Authorization: "Bearer tok" } }],
+ mcp: [
+ {
+ name: "r",
+ url: "https://x.com",
+ headers: { Authorization: "Bearer tok" },
+ },
+ ],
});
expect(result.success).toBe(true);
});
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 4235ae4..c65c058 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -4,6 +4,8 @@ import { z } from "zod/v4";
* Source specifier patterns (inferred from value):
* owner/repo -- GitHub
* owner/repo@ref -- GitHub pinned
+ * https://gitlab.com/group/repo[.git][@ref] -- GitLab URL
+ * git@gitlab.com:group/repo[.git][@ref] -- GitLab SSH URL
* git:https://... -- non-GitHub git
* path:../relative -- local filesystem
*/
@@ -15,6 +17,12 @@ export const GITHUB_HTTPS_URL =
/** 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)?(?:@(.+))?$/;
+/** GitLab HTTPS URL pattern — supports nested groups/subgroups. */
+export const GITLAB_HTTPS_URL =
+ /^https?:\/\/gitlab\.com\/([a-zA-Z0-9][^@]*?)\/([a-zA-Z0-9][^/@]*?)(?:\.git)?(?:\/)?(?:@(.+))?$/;
+/** GitLab SSH URL pattern — supports nested groups/subgroups. */
+export const GITLAB_SSH_URL =
+ /^git@gitlab\.com:([a-zA-Z0-9][^@]*?)\/([a-zA-Z0-9][^/@]*?)(?:\.git)?(?:@(.+))?$/;
const skillSourceSchema = z.string().check(
z.refine((s) => {
@@ -26,20 +34,28 @@ const skillSourceSchema = z.string().check(
// GitHub HTTPS or SSH URLs
if (GITHUB_HTTPS_URL.test(s)) return true;
if (GITHUB_SSH_URL.test(s)) return true;
+ // GitLab HTTPS or SSH URLs
+ if (GITLAB_HTTPS_URL.test(s)) return true;
+ if (GITLAB_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, GitHub URL, git:] (with https/git/ssh protocol), or path:"),
+ return (
+ parts.length === 2 &&
+ parts.every((p) => p.length > 0 && !p.startsWith("-"))
+ );
+ }, "Must be owner/repo, owner/repo@ref, GitHub/GitLab URL, git: (with https/git/ssh protocol), or path:"),
);
export type SkillSource = z.infer;
/** Skill names must be safe for use in file paths: alphanumeric, dots, hyphens, underscores. */
-const skillNameSchema = z.string().regex(
- /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/,
- "Skill names must start with alphanumeric and contain only [a-zA-Z0-9._-]",
-);
+const skillNameSchema = z
+ .string()
+ .regex(
+ /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/,
+ "Skill names must start with alphanumeric and contain only [a-zA-Z0-9._-]",
+ );
const wildcardSkillDependencySchema = z.object({
name: z.literal("*"),
@@ -60,11 +76,17 @@ const skillDependencySchema = z.union([
regularSkillDependencySchema,
]);
-export type WildcardSkillDependency = z.infer;
-export type RegularSkillDependency = z.infer;
+export type WildcardSkillDependency = z.infer<
+ typeof wildcardSkillDependencySchema
+>;
+export type RegularSkillDependency = z.infer<
+ typeof regularSkillDependencySchema
+>;
export type SkillDependency = z.infer;
-export function isWildcardDep(dep: SkillDependency): dep is WildcardSkillDependency {
+export function isWildcardDep(
+ dep: SkillDependency,
+): dep is WildcardSkillDependency {
return dep.name === "*";
}
@@ -94,14 +116,11 @@ const mcpSchema = z
env: z.array(z.string()).default([]),
})
.check(
- z.refine(
- (m) => {
- const hasStdio = !!m.command;
- const hasHttp = !!m.url;
- return (hasStdio || hasHttp) && !(hasStdio && hasHttp);
- },
- "MCP server must have either command (stdio) or url (http), but not both",
- ),
+ z.refine((m) => {
+ const hasStdio = !!m.command;
+ const hasHttp = !!m.url;
+ return (hasStdio || hasHttp) && !(hasStdio && hasHttp);
+ }, "MCP server must have either command (stdio) or url (http), but not both"),
);
export type McpConfig = z.infer;
diff --git a/src/skills/resolver.test.ts b/src/skills/resolver.test.ts
index ef33aa6..a873cf0 100644
--- a/src/skills/resolver.test.ts
+++ b/src/skills/resolver.test.ts
@@ -18,9 +18,7 @@ describe("parseSource", () => {
expect(result.owner).toBe("getsentry");
expect(result.repo).toBe("sentry-skills");
expect(result.ref).toBe("v1.0.0");
- expect(result.url).toBe(
- "https://github.com/getsentry/sentry-skills.git",
- );
+ expect(result.url).toBe("https://github.com/getsentry/sentry-skills.git");
});
it("parses owner/repo@sha as github with sha ref", () => {
@@ -30,7 +28,9 @@ describe("parseSource", () => {
});
it("parses git: prefix as generic git", () => {
- const result = parseSource("git:https://git.corp.example.com/team/skills.git");
+ const result = parseSource(
+ "git:https://git.corp.example.com/team/skills.git",
+ );
expect(result.type).toBe("git");
expect(result.url).toBe("https://git.corp.example.com/team/skills.git");
});
@@ -121,6 +121,33 @@ describe("parseSource", () => {
expect(result.url).toBe("https://github.com/vercel/next.js.git");
});
+ it("parses HTTPS GitLab URL", () => {
+ const result = parseSource("https://gitlab.com/group/repo");
+ expect(result.type).toBe("git");
+ expect(result.owner).toBe("group");
+ expect(result.repo).toBe("repo");
+ expect(result.url).toBe("https://gitlab.com/group/repo.git");
+ expect(result.cloneUrl).toBe("https://gitlab.com/group/repo");
+ });
+
+ it("parses HTTPS GitLab URL with subgroup", () => {
+ const result = parseSource("https://gitlab.com/group/subgroup/repo");
+ expect(result.type).toBe("git");
+ expect(result.owner).toBe("group/subgroup");
+ expect(result.repo).toBe("repo");
+ expect(result.url).toBe("https://gitlab.com/group/subgroup/repo.git");
+ });
+
+ it("parses SSH GitLab URL with ref", () => {
+ const result = parseSource("git@gitlab.com:group/repo@v2.0");
+ expect(result.type).toBe("git");
+ expect(result.owner).toBe("group");
+ expect(result.repo).toBe("repo");
+ expect(result.ref).toBe("v2.0");
+ expect(result.url).toBe("https://gitlab.com/group/repo.git");
+ expect(result.cloneUrl).toBe("git@gitlab.com:group/repo");
+ });
+
it("upgrades http:// to https:// in cloneUrl", () => {
const result = parseSource("http://github.com/getsentry/skills");
expect(result.type).toBe("github");
@@ -148,20 +175,40 @@ describe("normalizeSource", () => {
});
it("normalizes HTTPS URL to owner/repo", () => {
- expect(normalizeSource("https://github.com/getsentry/skills")).toBe("getsentry/skills");
+ 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");
+ 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");
+ expect(normalizeSource("https://github.com/getsentry/skills.git")).toBe(
+ "getsentry/skills",
+ );
+ });
+
+ it("normalizes GitLab HTTPS URL to gitlab.com/group/repo", () => {
+ expect(normalizeSource("https://gitlab.com/group/repo")).toBe(
+ "gitlab.com/group/repo",
+ );
+ });
+
+ it("normalizes GitLab SSH URL to gitlab.com/group/repo", () => {
+ expect(normalizeSource("git@gitlab.com:group/repo.git")).toBe(
+ "gitlab.com/group/repo",
+ );
});
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");
+ expect(normalizeSource("git:https://example.com/repo.git")).toBe(
+ "git:https://example.com/repo.git",
+ );
});
});
@@ -171,15 +218,33 @@ describe("sourcesMatch", () => {
});
it("matches SSH URL with shorthand", () => {
- expect(sourcesMatch("git@github.com:getsentry/skills.git", "getsentry/skills")).toBe(true);
+ 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);
+ 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);
+ expect(
+ sourcesMatch(
+ "git@github.com:getsentry/skills.git",
+ "https://github.com/getsentry/skills",
+ ),
+ ).toBe(true);
+ });
+
+ it("matches GitLab SSH URL with GitLab HTTPS URL", () => {
+ expect(
+ sourcesMatch(
+ "git@gitlab.com:group/repo.git",
+ "https://gitlab.com/group/repo",
+ ),
+ ).toBe(true);
});
it("does not match different repos", () => {
diff --git a/src/skills/resolver.ts b/src/skills/resolver.ts
index d8c7656..d9c7297 100644
--- a/src/skills/resolver.ts
+++ b/src/skills/resolver.ts
@@ -1,6 +1,11 @@
import { join } from "node:path";
import type { WildcardSkillDependency } from "../config/schema.js";
-import { GITHUB_HTTPS_URL, GITHUB_SSH_URL } from "../config/schema.js";
+import {
+ GITHUB_HTTPS_URL,
+ GITHUB_SSH_URL,
+ GITLAB_HTTPS_URL,
+ GITLAB_SSH_URL,
+} from "../config/schema.js";
import { ensureCached } from "../sources/cache.js";
import { resolveLocalSource } from "../sources/local.js";
import { discoverSkill, discoverAllSkills } from "./discovery.js";
@@ -77,6 +82,24 @@ export function parseSource(source: string): {
};
}
+ // GitLab HTTPS or SSH URL
+ const gitlabUrlMatch =
+ source.match(GITLAB_HTTPS_URL) || source.match(GITLAB_SSH_URL);
+ if (gitlabUrlMatch) {
+ const [, owner, repo, ref] = gitlabUrlMatch;
+ // Strip @ref suffix using known ref length, upgrade http:// to https:// (no-op for SSH URLs)
+ const withoutRef = ref ? source.slice(0, -(ref.length + 1)) : source;
+ const cloneUrl = withoutRef.replace(/^http:\/\//i, "https://");
+ return {
+ type: "git",
+ owner,
+ repo,
+ ref,
+ url: `https://gitlab.com/${owner}/${repo}.git`,
+ cloneUrl,
+ };
+ }
+
// owner/repo or owner/repo@ref — shorthand, no cloneUrl
const atIdx = source.indexOf("@");
const base = atIdx !== -1 ? source.slice(0, atIdx) : source;
@@ -92,10 +115,18 @@ export function parseSource(source: string): {
};
}
-/** Normalize any GitHub source to owner/repo canonical form for comparison/dedup. */
+/** Normalize hosted sources to canonical form for comparison/dedup. */
export function normalizeSource(source: string): string {
const parsed = parseSource(source);
if (parsed.type === "github") return `${parsed.owner}/${parsed.repo}`;
+ if (
+ parsed.type === "git" &&
+ parsed.url?.startsWith("https://gitlab.com/") &&
+ parsed.owner &&
+ parsed.repo
+ ) {
+ return `gitlab.com/${parsed.owner}/${parsed.repo}`;
+ }
return source;
}
@@ -196,10 +227,17 @@ export async function resolveWildcardSkills(
const skillDir = await resolveLocalSource(projectRoot, parsed.path!);
const discovered = await discoverAllSkills(skillDir);
return discovered
- .filter((d) => !excludeSet.has(d.meta.name) && VALID_SKILL_NAME.test(d.meta.name))
+ .filter(
+ (d) =>
+ !excludeSet.has(d.meta.name) && VALID_SKILL_NAME.test(d.meta.name),
+ )
.map((d) => ({
name: d.meta.name,
- resolved: { type: "local" as const, source: dep.source, skillDir: join(skillDir, d.path) },
+ resolved: {
+ type: "local" as const,
+ source: dep.source,
+ skillDir: join(skillDir, d.path),
+ },
}));
}
@@ -222,7 +260,9 @@ export async function resolveWildcardSkills(
const discovered = await discoverAllSkills(cached.repoDir);
return discovered
- .filter((d) => !excludeSet.has(d.meta.name) && VALID_SKILL_NAME.test(d.meta.name))
+ .filter(
+ (d) => !excludeSet.has(d.meta.name) && VALID_SKILL_NAME.test(d.meta.name),
+ )
.map((d) => ({
name: d.meta.name,
resolved: {
diff --git a/src/sources/git.ts b/src/sources/git.ts
index b555d31..6391f63 100644
--- a/src/sources/git.ts
+++ b/src/sources/git.ts
@@ -1,6 +1,20 @@
import { exec, ExecError } from "../utils/exec.js";
import { existsSync } from "node:fs";
+function toSshCloneUrl(url: string): string | undefined {
+ const hostedMatch = url.match(
+ /^https?:\/\/(github\.com|gitlab\.com)\/(.+)$/i,
+ );
+ if (!hostedMatch) return undefined;
+
+ const host = hostedMatch[1]!;
+ const rawPath = hostedMatch[2]!;
+ let path = rawPath;
+ while (path.endsWith("/")) path = path.slice(0, -1);
+
+ return `git@${host.toLowerCase()}:${path.endsWith(".git") ? path : `${path}.git`}`;
+}
+
export class GitError extends Error {
constructor(message: string) {
super(message);
@@ -28,15 +42,12 @@ export async function clone(
} catch (err) {
if (err instanceof ExecError) {
const stderr = err.stderr;
+ const sshUrl = toSshCloneUrl(url);
if (
- url.startsWith("https://github.com/") &&
+ sshUrl &&
(/terminal prompts disabled/i.test(stderr) ||
/could not read Username/i.test(stderr))
) {
- // Convert https://github.com/org/repo[.git][/] → git@github.com:org/repo.git
- let path = url.slice("https://github.com/".length);
- while (path.endsWith("/")) path = path.slice(0, -1);
- const sshUrl = "git@github.com:" + (path.endsWith(".git") ? path : path + ".git");
throw new GitError(
`Failed to clone ${url}: authentication required.\n` +
`Hint: for private repos, use the SSH URL instead:\n` +
@@ -69,11 +80,15 @@ export async function fetchAndReset(repoDir: string): Promise {
*/
export async function fetchRef(repoDir: string, ref: string): Promise {
try {
- await exec("git", ["fetch", "--depth=1", "--", "origin", ref], { cwd: repoDir });
+ await exec("git", ["fetch", "--depth=1", "--", "origin", ref], {
+ cwd: repoDir,
+ });
await exec("git", ["checkout", "FETCH_HEAD"], { cwd: repoDir });
} catch (err) {
if (err instanceof ExecError) {
- throw new GitError(`Failed to fetch ref ${ref} in ${repoDir}: ${err.stderr}`);
+ throw new GitError(
+ `Failed to fetch ref ${ref} in ${repoDir}: ${err.stderr}`,
+ );
}
throw err;
}
diff --git a/src/trust/validator.test.ts b/src/trust/validator.test.ts
index 1c5cc58..b00c97e 100644
--- a/src/trust/validator.test.ts
+++ b/src/trust/validator.test.ts
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
-import { validateTrustedSource, extractDomain, TrustError } from "./validator.js";
+import {
+ validateTrustedSource,
+ extractDomain,
+ TrustError,
+} from "./validator.js";
import type { TrustConfig } from "../config/schema.js";
function makeTrust(overrides: Partial = {}): TrustConfig {
@@ -15,14 +19,20 @@ function makeTrust(overrides: Partial = {}): TrustConfig {
describe("validateTrustedSource", () => {
it("allows everything when trust config is undefined", () => {
expect(() => validateTrustedSource("evil/repo", undefined)).not.toThrow();
- expect(() => validateTrustedSource("git:https://evil.com/repo.git", undefined)).not.toThrow();
- expect(() => validateTrustedSource("path:../local", undefined)).not.toThrow();
+ expect(() =>
+ validateTrustedSource("git:https://evil.com/repo.git", undefined),
+ ).not.toThrow();
+ expect(() =>
+ validateTrustedSource("path:../local", undefined),
+ ).not.toThrow();
});
it("allows everything when allow_all is true", () => {
const trust = makeTrust({ allow_all: true });
expect(() => validateTrustedSource("evil/repo", trust)).not.toThrow();
- expect(() => validateTrustedSource("git:https://evil.com/repo.git", trust)).not.toThrow();
+ expect(() =>
+ validateTrustedSource("git:https://evil.com/repo.git", trust),
+ ).not.toThrow();
});
it("allows everything when allow_all is true even with other rules", () => {
@@ -34,17 +44,27 @@ describe("validateTrustedSource", () => {
const trust = makeTrust({ github_orgs: ["getsentry", "anthropics"] });
it("allows matching orgs", () => {
- expect(() => validateTrustedSource("getsentry/skills", trust)).not.toThrow();
- expect(() => validateTrustedSource("anthropics/tools", trust)).not.toThrow();
+ expect(() =>
+ validateTrustedSource("getsentry/skills", trust),
+ ).not.toThrow();
+ expect(() =>
+ validateTrustedSource("anthropics/tools", trust),
+ ).not.toThrow();
});
it("rejects non-matching orgs", () => {
- expect(() => validateTrustedSource("evil/repo", trust)).toThrow(TrustError);
+ expect(() => validateTrustedSource("evil/repo", trust)).toThrow(
+ TrustError,
+ );
});
it("strips @ref before checking", () => {
- expect(() => validateTrustedSource("getsentry/skills@v1.0.0", trust)).not.toThrow();
- expect(() => validateTrustedSource("evil/repo@main", trust)).toThrow(TrustError);
+ expect(() =>
+ validateTrustedSource("getsentry/skills@v1.0.0", trust),
+ ).not.toThrow();
+ expect(() => validateTrustedSource("evil/repo@main", trust)).toThrow(
+ TrustError,
+ );
});
});
@@ -52,19 +72,27 @@ describe("validateTrustedSource", () => {
const trust = makeTrust({ github_repos: ["external-org/one-approved"] });
it("allows exact repo matches", () => {
- expect(() => validateTrustedSource("external-org/one-approved", trust)).not.toThrow();
+ expect(() =>
+ validateTrustedSource("external-org/one-approved", trust),
+ ).not.toThrow();
});
it("rejects same-org different-repo", () => {
- expect(() => validateTrustedSource("external-org/other-repo", trust)).toThrow(TrustError);
+ expect(() =>
+ validateTrustedSource("external-org/other-repo", trust),
+ ).toThrow(TrustError);
});
it("rejects different-org same-repo", () => {
- expect(() => validateTrustedSource("other-org/one-approved", trust)).toThrow(TrustError);
+ expect(() =>
+ validateTrustedSource("other-org/one-approved", trust),
+ ).toThrow(TrustError);
});
it("strips @ref before checking", () => {
- expect(() => validateTrustedSource("external-org/one-approved@v2", trust)).not.toThrow();
+ expect(() =>
+ validateTrustedSource("external-org/one-approved@v2", trust),
+ ).not.toThrow();
});
});
@@ -73,19 +101,28 @@ describe("validateTrustedSource", () => {
it("allows matching domains (https)", () => {
expect(() =>
- validateTrustedSource("git:https://git.corp.example.com/team/repo.git", trust),
+ validateTrustedSource(
+ "git:https://git.corp.example.com/team/repo.git",
+ trust,
+ ),
).not.toThrow();
});
it("allows matching domains (ssh)", () => {
expect(() =>
- validateTrustedSource("git:ssh://git.corp.example.com/team/repo.git", trust),
+ validateTrustedSource(
+ "git:ssh://git.corp.example.com/team/repo.git",
+ trust,
+ ),
).not.toThrow();
});
it("allows matching domains (scp-style)", () => {
expect(() =>
- validateTrustedSource("git:git@git.corp.example.com:team/repo.git", trust),
+ validateTrustedSource(
+ "git:git@git.corp.example.com:team/repo.git",
+ trust,
+ ),
).not.toThrow();
});
@@ -94,12 +131,21 @@ describe("validateTrustedSource", () => {
validateTrustedSource("git:https://evil.com/repo.git", trust),
).toThrow(TrustError);
});
+
+ it("allows direct GitLab URLs when domain is trusted", () => {
+ const gitlabTrust = makeTrust({ git_domains: ["gitlab.com"] });
+ expect(() =>
+ validateTrustedSource("https://gitlab.com/group/repo", gitlabTrust),
+ ).not.toThrow();
+ });
});
describe("local sources", () => {
it("always allows path: sources even with restrictive trust", () => {
const trust = makeTrust({ github_orgs: ["getsentry"] });
- expect(() => validateTrustedSource("path:../local-skill", trust)).not.toThrow();
+ expect(() =>
+ validateTrustedSource("path:../local-skill", trust),
+ ).not.toThrow();
});
});
@@ -111,11 +157,15 @@ describe("validateTrustedSource", () => {
});
it("allows source matching org rule", () => {
- expect(() => validateTrustedSource("getsentry/anything", trust)).not.toThrow();
+ expect(() =>
+ validateTrustedSource("getsentry/anything", trust),
+ ).not.toThrow();
});
it("allows source matching repo rule", () => {
- expect(() => validateTrustedSource("external/approved", trust)).not.toThrow();
+ expect(() =>
+ validateTrustedSource("external/approved", trust),
+ ).not.toThrow();
});
it("allows source matching domain rule", () => {
@@ -125,15 +175,21 @@ describe("validateTrustedSource", () => {
});
it("rejects source matching none", () => {
- expect(() => validateTrustedSource("evil/repo", trust)).toThrow(TrustError);
+ expect(() => validateTrustedSource("evil/repo", trust)).toThrow(
+ TrustError,
+ );
});
});
describe("case-insensitive matching", () => {
it("matches GitHub orgs case-insensitively", () => {
const trust = makeTrust({ github_orgs: ["getsentry"] });
- expect(() => validateTrustedSource("GetSentry/repo", trust)).not.toThrow();
- expect(() => validateTrustedSource("GETSENTRY/repo", trust)).not.toThrow();
+ expect(() =>
+ validateTrustedSource("GetSentry/repo", trust),
+ ).not.toThrow();
+ expect(() =>
+ validateTrustedSource("GETSENTRY/repo", trust),
+ ).not.toThrow();
});
it("matches GitHub repos case-insensitively", () => {
@@ -145,7 +201,10 @@ describe("validateTrustedSource", () => {
it("matches git domains case-insensitively", () => {
const trust = makeTrust({ git_domains: ["git.corp.example.com"] });
expect(() =>
- validateTrustedSource("git:https://Git.Corp.Example.COM/repo.git", trust),
+ validateTrustedSource(
+ "git:https://Git.Corp.Example.COM/repo.git",
+ trust,
+ ),
).not.toThrow();
});
});
@@ -153,28 +212,43 @@ describe("validateTrustedSource", () => {
describe("error messages", () => {
it("includes the rejected source", () => {
const trust = makeTrust({ github_orgs: ["getsentry"] });
- expect(() => validateTrustedSource("evil/repo", trust)).toThrow(/evil\/repo/);
+ expect(() => validateTrustedSource("evil/repo", trust)).toThrow(
+ /evil\/repo/,
+ );
});
it("includes allowed alternatives", () => {
- const trust = makeTrust({ github_orgs: ["getsentry"], github_repos: ["ext/one"] });
- expect(() => validateTrustedSource("evil/repo", trust)).toThrow(/getsentry/);
- expect(() => validateTrustedSource("evil/repo", trust)).toThrow(/ext\/one/);
+ const trust = makeTrust({
+ github_orgs: ["getsentry"],
+ github_repos: ["ext/one"],
+ });
+ expect(() => validateTrustedSource("evil/repo", trust)).toThrow(
+ /getsentry/,
+ );
+ expect(() => validateTrustedSource("evil/repo", trust)).toThrow(
+ /ext\/one/,
+ );
});
});
});
describe("extractDomain", () => {
it("extracts from https URL", () => {
- expect(extractDomain("https://git.corp.com/team/repo.git")).toBe("git.corp.com");
+ expect(extractDomain("https://git.corp.com/team/repo.git")).toBe(
+ "git.corp.com",
+ );
});
it("extracts from ssh URL", () => {
- expect(extractDomain("ssh://git.corp.com/team/repo.git")).toBe("git.corp.com");
+ expect(extractDomain("ssh://git.corp.com/team/repo.git")).toBe(
+ "git.corp.com",
+ );
});
it("extracts from git:// URL", () => {
- expect(extractDomain("git://git.corp.com/team/repo.git")).toBe("git.corp.com");
+ expect(extractDomain("git://git.corp.com/team/repo.git")).toBe(
+ "git.corp.com",
+ );
});
it("extracts from scp-style URL", () => {