From c35f609f8bbe2dc74fbf32a90c2f6a7b4d5f2256 Mon Sep 17 00:00:00 2001
From: Benjamin Staneck
Date: Mon, 23 Feb 2026 16:40:31 +0100
Subject: [PATCH 1/2] feat(cli): Add GitLab URLs and require explicit add
sources
Support GitLab HTTPS and SSH repository URLs in source parsing,
validation, and clone/auth hint handling, including subgroup paths.
Reject owner/repo shorthand in the add flow so source host is explicit
and no longer inferred as GitHub. This avoids ambiguous behavior now
that hosted providers beyond GitHub are supported.
Update README and docs examples to use explicit hosted URLs and add
coverage for GitLab parsing/validation plus shorthand rejection.
Co-Authored-By: Claude
---
README.md | 68 ++++++++++--------
docs/src/app/cli/page.tsx | 37 +++++-----
docs/src/app/page.tsx | 39 +++++++---
src/cli/commands/add.test.ts | 12 ++++
src/cli/commands/add.ts | 72 +++++++++++++++----
src/config/schema.test.ts | 52 ++++++++++++--
src/config/schema.ts | 53 +++++++++-----
src/skills/resolver.test.ts | 87 ++++++++++++++++++++---
src/skills/resolver.ts | 50 +++++++++++--
src/sources/git.ts | 29 ++++++--
src/trust/validator.test.ts | 134 +++++++++++++++++++++++++++--------
11 files changed, 482 insertions(+), 151 deletions(-)
diff --git a/README.md b/README.md
index 91175d4..94f90dd 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.
@@ -86,25 +86,27 @@ Add one or more skills and install them. Specify skill names as positional argum
```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.
+Note: `dotagents add` requires explicit git URLs. Shorthand forms like `getsentry/skills` are not supported in the add flow.
+
### remove
```bash
@@ -160,11 +162,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 +188,11 @@ 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`
## Agent Targets
@@ -196,13 +202,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 +272,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..ac479c7 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
@@ -133,7 +133,9 @@ 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 in add commands; shorthand like{" "}
+ getsentry/skills is not supported.
>
}
options={[
@@ -153,13 +155,16 @@ 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",
"",
"# All skills from a repo",
- "dotagents add getsentry/skills --all",
+ "dotagents add https://github.com/getsentry/skills --all",
"",
"# Pinned to a version",
- "dotagents add getsentry/warden@v1.0.0",
+ "dotagents add https://github.com/getsentry/warden@v1.0.0",
+ "",
+ "# Hosted GitLab",
+ "dotagents add https://gitlab.com/group/repo --name find-bugs",
"",
"# Non-GitHub git server",
"dotagents add git:https://git.corp.dev/team/skills --name review",
@@ -206,9 +211,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 +269,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 +535,7 @@ dotagents mcp add --url [--header ...] [--env ...]"
Git URL
@@ -272,17 +289,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..0ce42b5 100644
--- a/src/cli/commands/add.test.ts
+++ b/src/cli/commands/add.test.ts
@@ -175,6 +175,18 @@ 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");
diff --git a/src/cli/commands/add.ts b/src/cli/commands/add.ts
index 933ddcc..a9be8b5 100644
--- a/src/cli/commands/add.ts
+++ b/src/cli/commands/add.ts
@@ -5,7 +5,8 @@ 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";
@@ -36,6 +37,16 @@ export interface AddOptions {
interactive?: boolean;
}
+function isExplicitSourceSpecifier(specifier: string): boolean {
+ return (
+ specifier.startsWith("path:") ||
+ specifier.startsWith("git:") ||
+ specifier.startsWith("http://") ||
+ specifier.startsWith("https://") ||
+ specifier.startsWith("git@")
+ );
+}
+
export async function runAdd(opts: AddOptions): Promise {
const { scope, specifier, ref, names: rawNames, all, interactive } = opts;
// Deduplicate names to prevent writing duplicate config entries
@@ -48,11 +59,17 @@ export async function runAdd(opts: AddOptions): Promise {
// Parse the specifier
const parsed = parseSource(specifier);
+ if (parsed.type === "github" && !isExplicitSourceSpecifier(specifier)) {
+ throw new AddError(
+ `Shorthand source "${specifier}" 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
+ ? specifier.slice(0, -(parsed.ref.length + 1))
+ : specifier;
// Validate trust against the source
validateTrustedSource(sourceForStorage, config.trust);
@@ -67,7 +84,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.`,
);
@@ -156,7 +177,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
@@ -226,7 +251,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 +283,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 +320,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,
@@ -304,7 +339,9 @@ export default async function add(args: string[], flags?: { user?: boolean }): P
const specifier = positionals[0];
if (!specifier) {
console.error(
- chalk.red("Usage: dotagents add [...] [--skill ...] [--ref [] [--all]"),
+ chalk.red(
+ "Usage: dotagents add ] [...] [--skill ...] [--ref [] [--all]",
+ ),
);
process.exitCode = 1;
return;
@@ -326,8 +363,11 @@ 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,
@@ -345,7 +385,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", () => {
From 5d576235e19c76d1d49e8b460e38bf549d44e177 Mon Sep 17 00:00:00 2001
From: Benjamin Staneck
Date: Mon, 23 Feb 2026 16:46:46 +0100
Subject: [PATCH 2/2] feat(cli): Add gh/gl hints for add shorthand sources
Allow explicit source hints in add commands so owner/repo shorthand can
be used without implicit host inference.
Support `gh` and `gl` prefixes and expand shorthand to the corresponding
GitHub or GitLab HTTPS URL before source parsing and trust checks.
Update CLI and docs examples to show both explicit URLs and hint-based
shorthand forms.
Co-Authored-By: Claude
---
README.md | 4 +-
docs/src/app/cli/page.tsx | 11 +++-
docs/src/app/page.tsx | 5 +-
src/cli/commands/add.test.ts | 70 +++++++++++++++++++----
src/cli/commands/add.ts | 106 ++++++++++++++++++++++++++++++-----
5 files changed, 165 insertions(+), 31 deletions(-)
diff --git a/README.md b/README.md
index 94f90dd..e25e065 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,7 @@ 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.
@@ -105,7 +106,7 @@ When a repo has one skill, it's added automatically. When multiple are found and
When adding multiple skills, any that already exist in `agents.toml` are skipped with a warning. The rest are added normally.
-Note: `dotagents add` requires explicit git URLs. Shorthand forms like `getsentry/skills` are not supported in the add flow.
+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
@@ -193,6 +194,7 @@ exclude = ["deprecated-skill"]
```
Or from the CLI: `dotagents add https://github.com/getsentry/skills --all`
+Or with shorthand + hint: `dotagents add gh getsentry/skills --all`
## Agent Targets
diff --git a/docs/src/app/cli/page.tsx b/docs/src/app/cli/page.tsx
index ac479c7..947bddf 100644
--- a/docs/src/app/cli/page.tsx
+++ b/docs/src/app/cli/page.tsx
@@ -127,15 +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. Use
- explicit git URLs in add commands; shorthand like{" "}
- getsentry/skills is not supported.
+ explicit git URLs, or use source hints with shorthand such as{" "}
+ gh getsentry/skills and{" "}
+ gl getsentry/skills.
>
}
options={[
@@ -156,15 +157,19 @@ export default function CliPage() {
examples={[
"# Single skill from GitHub",
"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 https://github.com/getsentry/skills --all",
+ "dotagents add gh getsentry/skills --all",
"",
"# Pinned to a version",
"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",
diff --git a/docs/src/app/page.tsx b/docs/src/app/page.tsx
index b9d8b1a..b180af3 100644
--- a/docs/src/app/page.tsx
+++ b/docs/src/app/page.tsx
@@ -207,8 +207,9 @@ dotagents add path:./my-skills/custom`}
to add them all as a wildcard entry.
- Note: dotagents add requires explicit git URLs. Shorthand
- like getsentry/skills is not supported in the add flow.
+ Use explicit URLs, or use source hints with shorthand, for example{" "}
+ dotagents add gh getsentry/skills --all and{" "}
+ dotagents add gl group/repo --all.
diff --git a/src/cli/commands/add.test.ts b/src/cli/commands/add.test.ts
index 0ce42b5..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 });
@@ -192,10 +226,15 @@ describe("runAdd", () => {
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 });
@@ -244,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 () => {
@@ -385,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 a9be8b5..afa4b02 100644
--- a/src/cli/commands/add.ts
+++ b/src/cli/commands/add.ts
@@ -6,7 +6,10 @@ 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";
+ 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";
@@ -31,12 +34,19 @@ 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:") ||
@@ -47,29 +57,77 @@ function isExplicitSourceSpecifier(specifier: string): boolean {
);
}
+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(specifier)) {
+ if (parsed.type === "github" && !isExplicitSourceSpecifier(hintedSpecifier)) {
throw new AddError(
- `Shorthand source "${specifier}" is no longer supported in 'dotagents add'. ` +
+ `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.ref
- ? specifier.slice(0, -(parsed.ref.length + 1))
- : specifier;
+ ? hintedSpecifier.slice(0, -(parsed.ref.length + 1))
+ : hintedSpecifier;
// Validate trust against the source
validateTrustedSource(sourceForStorage, config.trust);
@@ -142,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) {
@@ -204,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) {
@@ -336,11 +402,16 @@ export default async function add(
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]",
+ "Usage: dotagents add [gh|gl] ] [...] [--skill ...] [--ref [] [--all]",
),
);
process.exitCode = 1;
@@ -348,12 +419,16 @@ export default async function add(
}
// 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;
@@ -371,6 +446,7 @@ export default async function add(
const result = await runAdd({
scope,
specifier,
+ sourceHint,
ref: values["ref"],
names,
all: values["all"],
]