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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,15 @@ source = "https://cli.sentry.dev" # Well-known HTTPS source
[[skills]]
name = "local"
source = "path:./my-skills/local-skill" # Local directory

[[skills]]
name = "*"
source = "getsentry/skills"
path = "skills/engineering" # All skills under one subdirectory
```

Wildcard `path` must name an existing directory inside the source. Discovered source-relative paths are stored in `agents.lock`, allowing `list` and offline `sync` to enforce the current scope. Legacy entries without path metadata remain selected until install refreshes them.

Shorthand (`owner/repo`) resolves to GitHub by default. Set `defaultRepositorySource = "gitlab"` in `agents.toml` to resolve to GitLab instead.

## Agent Targets
Expand Down
11 changes: 6 additions & 5 deletions docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ Each `[[skills]]` entry requires `name` and `source`. Optional fields: `ref` (gi
| `name` | string | Yes | Skill name. Pattern: `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`. Use `"*"` for wildcard. |
| `source` | string | Yes | `owner/repo` (resolved via `defaultRepositorySource`), `owner/repo@ref`, `https://github.com/owner/repo`, `https://gitlab.com/group/repo`, `https://<domain>` (well-known), `git:<url>`, or `path:<relative>` |
| `ref` | string | No | Git ref (tag, branch, or SHA). Also supported inline: `owner/repo@ref`. |
| `path` | string | No | Subdirectory within repo. Only needed when auto-discovery fails. |
| `path` | string | No | For named skills, the exact skill directory within the source. For wildcard skills, an existing source subdirectory whose complete skill subtree is discovered. Wildcard paths must remain inside the source root. |

Source formats:
- `owner/repo` -- shorthand resolved by `defaultRepositorySource` (defaults to GitHub)
Expand All @@ -172,10 +172,11 @@ Set `name = "*"` to install all skills from a source. Use `exclude` to skip spec
[[skills]]
name = "*"
source = "getsentry/skills"
path = "skills/engineering"
exclude = ["deprecated-skill"]
```

Wildcards are expanded during `install`. Each discovered skill gets its own lockfile entry.
When `path` is set, wildcard discovery is limited to that subdirectory and remains recursive. Wildcards are expanded during `install`; each discovered skill gets its source-relative path in the lockfile. `list` and offline `sync` use those paths to enforce the current scope. Legacy lock entries without `resolved_path` are retained until the next successful install refreshes their metadata.

### MCP Servers

Expand Down Expand Up @@ -491,7 +492,7 @@ After cloning a repo, dotagents scans these locations for skills:

Single-skill repos with SKILL.md at the root are supported — the skill name is derived from the frontmatter `name` field. Child-directory skills take priority over root-level SKILL.md when names conflict.

Use the `path` field for explicit overrides when auto-discovery does not find the skill.
For named skills, use `path` as an explicit override when auto-discovery does not find the skill. For wildcard skills, `path` selects the recursive discovery root.

A valid skill directory contains a `SKILL.md` file with YAML frontmatter:

Expand Down Expand Up @@ -531,11 +532,11 @@ resolved_commit = "fedcba9876543210fedcba9876543210fedcba98"
|-------|-------------|-------------|
| `source` | All | Original source from `agents.toml` |
| `resolved_url` | Git and well-known sources | Resolved git clone URL or HTTP base URL |
| `resolved_path` | Git sources | Subdirectory within repo where skill was found |
| `resolved_path` | Discovered skills | Path within the source where the skill was found. Recorded for Git sources and wildcard-expanded local or well-known sources. |
| `resolved_ref` | Git sources (optional) | Resolved ref name (omitted for default branch) |
| `resolved_commit` | Git sources (optional) | Full commit SHA that was installed. Informational only; install does not use it for locking. |

Local `path:` skills and subagents have `source` only. Subagent entries use the same fields under `[subagents.<name>]`; `resolved_path` points to the subagent file inside a git source.
Explicit local `path:` skills have `source` only. Wildcard-expanded local skills also record `resolved_path` so offline sync can enforce the configured path scope. Subagent entries use the same fields under `[subagents.<name>]`; `resolved_path` points to the subagent file inside a git source.

## Caching

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ Status output:
| `name` | string | Yes | Skill identifier. Use `"*"` for wildcard. |
| `source` | string | Yes | `owner/repo`, `owner/repo@ref`, GitHub/GitLab URL, `git:url`, or `path:relative` |
| `ref` | string | No | Tag, branch, or commit SHA to pin |
| `path` | string | No | Subdirectory within repo, when auto-discovery fails |
| `path` | string | No | Exact directory for a named skill, or an existing contained recursive discovery root for a wildcard |
| `exclude` | string[] | No | Skills to skip. Wildcard entries only. |

### MCP Servers
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ source = "https://cli.sentry.dev"
[[skills]]
name = "*"
source = "myorg/skills"
path = "skills/engineering"
exclude = ["deprecated-skill"]

# MCP server (stdio)
Expand Down
14 changes: 10 additions & 4 deletions docs/src/content/docs/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,19 @@ resolved_commit = "fedcba9876543210fedcba9876543210fedcba98"
| --- | --- |
| `source` | Original source from agents.toml |
| `resolved_url` | Resolved git clone URL or HTTP base URL |
| `resolved_path` | Subdirectory within repo where skill was found |
| `resolved_path` | Path within the source where the skill was found |
| `resolved_ref` | Resolved ref name, omitted for default branch |
| `resolved_commit` | Installed commit SHA. Informational only |

Local `path:` skills and subagents have `source` only. Subagent entries use the
same fields under `[subagents.<name>]`; `resolved_path` points to the subagent
file inside a git source.
Explicit local `path:` skills and local subagents have `source` only.
Wildcard-expanded local skills also record `resolved_path` so offline sync can
enforce the configured path scope. Subagent entries use the same fields under
`[subagents.<name>]`; `resolved_path` points to the subagent file inside a git
source.

`list` and offline `sync` filter wildcard entries when `resolved_path` is
available. Legacy entries without that metadata remain selected until a
successful install refreshes the lockfile.

## Caching

Expand Down
124 changes: 123 additions & 1 deletion packages/dotagents-lib/src/skills/resolver.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { mkdtemp, mkdir, writeFile, rm, symlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolveSkill, resolveWildcardSkills } from "./resolver.js";
Expand Down Expand Up @@ -283,6 +283,45 @@ describe("resolveWildcardSkills integration", () => {
expect(results[0]!.name).toBe("pdf");
});

it("scopes wildcard discovery to path and preserves repo-relative paths", async () => {
await mkdir(join(repoDir, "skills", "engineering", "deploy"), { recursive: true });
await writeFile(
join(repoDir, "skills", "engineering", "deploy", "SKILL.md"),
`---\nname: deploy\ndescription: Deploy skill\n---\n`,
);
await mkdir(join(repoDir, "skills", "engineering", "platform", "observe"), {
recursive: true,
});
await writeFile(
join(repoDir, "skills", "engineering", "platform", "observe", "SKILL.md"),
`---\nname: observe\ndescription: Observe skill\n---\n`,
);
await mkdir(join(repoDir, "skills", "productivity", "notes"), { recursive: true });
await writeFile(
join(repoDir, "skills", "productivity", "notes", "SKILL.md"),
`---\nname: notes\ndescription: Notes skill\n---\n`,
);
await exec("git", ["add", "."], { cwd: repoDir });
await exec("git", ["commit", "-m", "add categorized skills"], { cwd: repoDir });

const results = await resolveWildcardSkills(
{
source: `git:${repoDir}`,
path: "skills/engineering",
exclude: ["deploy"],
},
{ stateDir, projectRoot },
);

expect(results.map((result) => result.name)).toEqual(["observe"]);
expect(results[0]!.resolved.type).toBe("git");
if (results[0]!.resolved.type === "git") {
expect(results[0]!.resolved.resolvedPath).toBe(
"skills/engineering/platform/observe",
);
}
});

it("returns empty array when all skills excluded", async () => {
const results = await resolveWildcardSkills(
{ source: `git:${repoDir}`, exclude: ["pdf", "review"] },
Expand Down Expand Up @@ -311,6 +350,89 @@ describe("resolveWildcardSkills integration", () => {
expect(results[0]!.resolved.type).toBe("local");
});

it("scopes wildcard discovery within a local source", async () => {
const localSkills = join(projectRoot, "local-repo");
await mkdir(join(localSkills, "engineering", "review"), { recursive: true });
await writeFile(
join(localSkills, "engineering", "review", "SKILL.md"),
`---\nname: review\ndescription: Review skill\n---\n`,
);
await mkdir(join(localSkills, "productivity", "notes"), { recursive: true });
await writeFile(
join(localSkills, "productivity", "notes", "SKILL.md"),
`---\nname: notes\ndescription: Notes skill\n---\n`,
);

const results = await resolveWildcardSkills(
{ source: "path:local-repo", path: "engineering", exclude: [] },
{ stateDir, projectRoot },
);

expect(results.map((result) => result.name)).toEqual(["review"]);
expect(results[0]!.resolved.type).toBe("local");
expect(results[0]!.resolved.resolvedPath).toBe("engineering/review");
expect(results[0]!.resolved.skillDir).toBe(
join(localSkills, "engineering", "review"),
);
});

it("rejects wildcard paths outside the source root", async () => {
await mkdir(join(projectRoot, "local-repo"), { recursive: true });

await expect(
resolveWildcardSkills(
{ source: "path:local-repo", path: "../outside", exclude: [] },
{ stateDir, projectRoot },
),
).rejects.toThrow(/resolves outside source root/);
});

it("rejects a wildcard path that does not exist", async () => {
await mkdir(join(projectRoot, "local-repo"), { recursive: true });

await expect(
resolveWildcardSkills(
{ source: "path:local-repo", path: "missing", exclude: [] },
{ stateDir, projectRoot },
),
).rejects.toThrow(/does not exist in source/);
});

it("rejects a wildcard path that is not a directory", async () => {
await mkdir(join(projectRoot, "local-repo"), { recursive: true });
await writeFile(join(projectRoot, "local-repo", "skill.txt"), "not a directory");

await expect(
resolveWildcardSkills(
{ source: "path:local-repo", path: "skill.txt", exclude: [] },
{ stateDir, projectRoot },
),
).rejects.toThrow(/is not a directory in source/);
});

it("rejects a wildcard path symlinked outside the source root", async () => {
const localRoot = join(projectRoot, "local-repo");
const outside = join(projectRoot, "outside");
await mkdir(localRoot, { recursive: true });
await mkdir(join(outside, "review"), { recursive: true });
await writeFile(
join(outside, "review", "SKILL.md"),
`---\nname: review\ndescription: Review skill\n---\n`,
);
await symlink(
outside,
join(localRoot, "escaped"),
process.platform === "win32" ? "junction" : "dir",
);

await expect(
resolveWildcardSkills(
{ source: "path:local-repo", path: "escaped", exclude: [] },
{ stateDir, projectRoot },
),
).rejects.toThrow(/resolves outside source root/);
});

it("each resolved skill has correct commit and path", async () => {
const results = await resolveWildcardSkills(
{ source: `git:${repoDir}`, exclude: [] },
Expand Down
Loading
Loading