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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ packages/
│ ├── targets/ # Target agent definitions plus MCP/hook config writers
│ ├── subagents/ # Subagent identity, store, and runtime writer
│ ├── agents/ # Compatibility re-export barrel for older internal imports
│ ├── plugins/ # Plugin install, provenance markers, target selection, runtime writers
│ ├── config/ # agents.toml schema, loader, writer
│ ├── lockfile/ # agents.lock schema, loader, writer
│ ├── symlinks/ # Symlink creation/management
Expand All @@ -34,6 +35,7 @@ packages/
└── src/
├── index.ts # Public API of the lib
├── skills/ # SKILL.md loader, discovery, resolver (parseSource, etc.)
├── plugins/ # Agent Plugins schemas, discovery, resolution, installed-bundle loading
├── sources/ # git.ts, cache.ts (configureCache), local.ts, wellknown.ts, repository-source.ts
├── trust/ # Trust validation (validateTrustedSource), TrustPolicy interface
└── utils/ # exec.ts, fs.ts (copyDir, stripTrailingSlashes)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Requires Node.js 20+ and pnpm.
This repo is a pnpm workspace with two packages:

- [`packages/dotagents/`](packages/dotagents/) — `@sentry/dotagents`, the CLI and host library. Owns `agents.toml`, the `.agents/` convention, and the per-agent (Claude/Cursor/etc.) integrations.
- [`packages/dotagents-lib/`](packages/dotagents-lib/) — `@sentry/dotagents-lib`, the reusable core (SKILL.md loading, source resolution, trust validation). Depend on this directly if you want to consume agent skills from your own tooling without `agents.toml`.
- [`packages/dotagents-lib/`](packages/dotagents-lib/) — `@sentry/dotagents-lib`, the reusable core (SKILL.md and Agent Plugin resolution, source caching, trust validation). Depend on this directly to consume skills or resolve plugins without `agents.toml`.

Both packages are versioned in lock-step — see [`RELEASING.md`](RELEASING.md).

Expand Down
23 changes: 22 additions & 1 deletion packages/dotagents-lib/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# @sentry/dotagents-lib

Reusable core for [SKILL.md](https://www.anthropic.com/engineering/skills) loading, source resolution, and trust validation. This library powers the [`@sentry/dotagents`](https://www.npmjs.com/package/@sentry/dotagents) CLI. Depend on it directly when you want to consume agent skills from your own tooling without using `agents.toml`.
Reusable core for [SKILL.md](https://www.anthropic.com/engineering/skills) loading, [Agent Plugins](https://agent-plugins.org/) resolution, source resolution, and trust validation. This library powers the [`@sentry/dotagents`](https://www.npmjs.com/package/@sentry/dotagents) CLI. Depend on it directly when you want to consume agent skills or plugins from your own tooling without using `agents.toml`.

## What's in here

- **Source-string grammar** — `parseSource`, `applyDefaultRepositorySource`, `normalizeSource`, etc. The recognized forms are `owner/repo[@ref]`, GitHub/GitLab URLs (HTTPS and SSH), `git:<url>`, `path:<rel>`, and bare `https://` for well-known endpoints.
- **Resolution** — `resolveSkill(name, dep, opts?)` and `resolveWildcardSkills(dep, opts?)` clone/cache the source and return the on-disk skill directory plus a commit SHA for git sources. Both accept an optional `trust?: TrustPolicy` opt for opt-in trust enforcement at the resolver layer.
- **Agent Plugins** — `resolvePlugin(dep, opts)` resolves a plugin from a local or git source and returns a validated `PluginBundle` (directory, effective manifest, authored native manifests). `discoverPlugins(sourceDir, names?)` lists plugins in a source (root manifest, marketplaces, `.agents/plugins/`, `plugins/`). With `names`, it returns only matching plugins; an empty result can also mean no name matched. `loadInstalledPluginBundle` reloads a bundle that a host installed. `parsePluginManifest`, `parsePluginMcp`, and `parsePluginMarketplace` validate `plugin.json`, `mcp.json`, and `marketplace.json`. Every path stays inside its source root. The lib does not install bundles or write client files; hosts own those steps.
- **SKILL.md loading and discovery** — `loadSkillMd`, `discoverSkill`, `discoverAllSkills`.
- **Cache primitives** — `ensureCached`, `ensureWellKnownCached`. The lib has no default cache location; callers pass `stateDir` explicitly so hosts own their own conventions and env-var prefixes.
- **Trust** — `validateTrustedSource`, `extractDomain`, `TrustError`, `TrustPolicy`.
Expand All @@ -14,6 +15,26 @@ Reusable core for [SKILL.md](https://www.anthropic.com/engineering/skills) loadi

See `src/index.ts` for the full public surface.

To load a skill-only Agent Plugin in another host, resolve the selected plugin and scan only its `skills/` directory:

```ts
import { join } from "node:path";
import { discoverAllSkills, resolvePlugin } from "@sentry/dotagents-lib";

const resolved = await resolvePlugin(
{ name: "review-tools", source: "path:./plugins/review-tools" },
{ projectRoot: workspaceRoot, stateDir: cacheDir, trust: trustPolicy },
);
const skills = (await discoverAllSkills(join(resolved.plugin.pluginDir, "skills"), { scanDirs: [] }))
.filter(({ path }) => path !== "." && !path.includes("/"));
```

Agent Plugins discovers only immediate child directories of `skills/`. The empty
`scanDirs` keeps the generic skill resolver from recursing, and the path filter
excludes a `SKILL.md` at the `skills/` root or other discovery formats.

`resolved.plugin.pluginDir` points to the source. For git sources it is a mutable cache checkout, so the host must copy the selected bundle into its own durable store before another resolution of that source can move the checkout. Serialize resolution and copying when multiple plugins share a source. Record the source and resolved commit with that copy. The host decides when to install, update, and expose the skills. Resolution does not configure a client or enable MCP servers.

## Versioning

`@sentry/dotagents-lib` ships in lock-step with `@sentry/dotagents` — both packages always carry the same version, published from the same release run. See [`RELEASING.md`](../../RELEASING.md) at the repo root.
Expand Down
5 changes: 3 additions & 2 deletions packages/dotagents-lib/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@sentry/dotagents-lib",
"version": "3.3.0",
"description": "Reusable core for SKILL.md loading, source resolution, and trust validation — the library powering @sentry/dotagents.",
"description": "Reusable core for SKILL.md loading, Agent Plugin resolution, source resolution, and trust validation \u2014 the library powering @sentry/dotagents.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand All @@ -28,6 +28,7 @@
"node": ">=20"
},
"dependencies": {
"yaml": "^2.8.3"
"yaml": "^2.8.3",
"zod": "^4.3.6"
}
}
54 changes: 54 additions & 0 deletions packages/dotagents-lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,60 @@ export type { CacheResult, CacheReuse } from "./sources/cache.js";
export { ensureWellKnownCached } from "./sources/wellknown.js";
export { resolveLocalSource, LocalSourceError } from "./sources/local.js";

// Agent Plugins: manifest schemas, discovery, and resolution
export {
AGENT_PLUGIN_SCHEMA,
AGENT_PLUGIN_MCP_SCHEMA,
isStandardPluginManifest,
parsePluginManifest,
parsePluginMcp,
parsePluginMcpBestEffort,
parsePluginMarketplace,
} from "./plugins/schema.js";
export type {
StandardPluginManifest,
LegacyPluginManifest,
PluginManifest,
PluginMcpConfig,
PluginMcpParseResult,
MarketplacePluginEntry,
PluginMarketplace,
} from "./plugins/schema.js";
export {
PLUGIN_NAME_PATTERN,
HYBRID_LEGACY_ROOTS,
resolvePlugin,
discoverPlugins,
loadInstalledPluginBundle,
assertPluginBundleSymlinksContained,
} from "./plugins/resolver.js";
export type {
PluginDependencyInput,
PluginResolveOptions,
PluginResolverServices,
PluginCandidate,
ResolvedPlugin,
ResolvedLocalPlugin,
ResolvedGitPlugin,
InstalledPluginProvenance,
} from "./plugins/resolver.js";
export {
NATIVE_PLUGIN_SOURCES,
NATIVE_PLUGIN_MANIFEST_PATHS,
nativePluginDisplayName,
nativeInterfaceNeedsFallback,
generatedNativeMcpPath,
manifestString,
legacyManifestString,
codexPluginInterface,
} from "./plugins/native-interfaces.js";
export type {
NativePluginSource,
AuthoredNativePluginInterface,
AuthoredNativePluginInterfaces,
PluginBundle,
} from "./plugins/types.js";

// Source-host primitives
export {
GITHUB_HTTPS_URL,
Expand Down
113 changes: 113 additions & 0 deletions packages/dotagents-lib/src/plugins/native-interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { isDeepStrictEqual } from "node:util";
import { isSerializedObject, type SerializedObject, type SerializedValue } from "../utils/serialized.js";
import { isString } from "../utils/type-guards.js";
import {
isStandardPluginManifest,
type LegacyPluginManifest,
type PluginManifest,
type PluginMcpConfig,
} from "./schema.js";
import type { NativePluginSource } from "./types.js";

// Owns the native manifest fields that a portable plugin core can reproduce.
// Resolution uses this to classify authored native manifests; hosts that
// generate native adapters must use the same values so the two stay in sync.

export const NATIVE_PLUGIN_SOURCES: readonly NativePluginSource[] = ["claude", "cursor", "codex"];

export const NATIVE_PLUGIN_MANIFEST_PATHS: ReadonlyArray<{
source: NativePluginSource;
path: string;
}> = [
{ source: "claude", path: ".claude-plugin/plugin.json" },
{ source: "cursor", path: ".cursor-plugin/plugin.json" },
{ source: "codex", path: ".codex-plugin/plugin.json" },
];

const GENERATED_NATIVE_FIELDS = {
claude: new Set(["$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills"]),
cursor: new Set(["$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills"]),
codex: new Set(["$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills"]),
} satisfies Record<NativePluginSource, ReadonlySet<string>>;

/** Returns the display name for a native plugin client. */
export function nativePluginDisplayName(source: NativePluginSource): string {
return source === "claude" ? "Claude" : source === "cursor" ? "Cursor" : "Codex";
}

/** Returns whether a native manifest contains behavior the target adapter cannot generate from the portable core. */
export function nativeInterfaceNeedsFallback(
source: NativePluginSource,
value: SerializedValue,
reproducibleFields: SerializedObject = {},
): boolean {
if (!isSerializedObject(value)) {return true;}
for (const [key, field] of Object.entries(value)) {
if (reproducibleFields[key] !== undefined && isDeepStrictEqual(field, reproducibleFields[key])) {
continue;
}
if (!GENERATED_NATIVE_FIELDS[source].has(key)) {return true;}
if (key === "skills" && !isConventionalSkillsReference(field)) {return true;}
}
return false;
}

/** Returns the native MCP reference generated from a portable MCP parse result. */
export function generatedNativeMcpPath(
source: NativePluginSource,
portableMcp: { config?: PluginMcpConfig; issues: string[] } | undefined,
): string | undefined {
if (!portableMcp?.config) {return undefined;}
if (portableMcp.issues.length === 0) {return "./mcp.json";}
if (Object.keys(portableMcp.config.mcpServers).length > 0) {
return `./.${source}-plugin/mcp.json`;
}
return undefined;
}

function isConventionalSkillsReference(value: SerializedValue | undefined): boolean {
const values = Array.isArray(value) ? value : [value];
return values.length === 1 && isString(values[0]) &&
values[0].replace(/^\.\//, "").replace(/\/$/, "") === "skills";
}

/** Returns a string metadata field from either manifest variant. */
export function manifestString(
manifest: PluginManifest,
key: "description" | "version",
): string | undefined {
const value = manifest[key];
return isString(value) ? value : undefined;
}

/** Returns a non-empty legacy-only metadata field. */
export function legacyManifestString(
manifest: PluginManifest,
key: "category",
): string | undefined {
if (isStandardPluginManifest(manifest)) {return undefined;}
// SAFETY: the standard-manifest guard above leaves the legacy manifest variant.
const value = (manifest as LegacyPluginManifest)[key];
return isString(value) && value.length > 0 ? value : undefined;
}

function titleCase(value: string): string {
return value
.split(/[-.]/)
.filter(Boolean)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
}

/** Returns the Codex `interface` block generated from a portable manifest. */
export function codexPluginInterface(name: string, manifest: PluginManifest): SerializedObject {
return {
displayName: titleCase(name),
shortDescription: manifestString(manifest, "description") ?? "",
developerName: manifest.author && isString(manifest.author.name)
? manifest.author.name
: "Unknown",
category: legacyManifestString(manifest, "category") ?? "Coding",
capabilities: ["Interactive", "Write"],
};
}
126 changes: 126 additions & 0 deletions packages/dotagents-lib/src/plugins/resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AGENT_PLUGIN_SCHEMA } from "./schema.js";
import { discoverPlugins, loadInstalledPluginBundle, resolvePlugin } from "./resolver.js";
import { discoverAllSkills } from "../skills/discovery.js";

// The host package covers discovery precedence and install behavior in depth.
// These tests pin the library contract that non-dotagents hosts depend on.
describe("plugin resolver library contract", () => {
let root: string;

beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "dotagents-lib-plugins-"));
});

afterEach(async () => {
await rm(root, { recursive: true, force: true });
});

it("resolves and loads a skill-only plugin without host config", async () => {
const pluginDir = join(root, "source", "plugins", "review-tools");
await mkdir(join(pluginDir, "skills", "review"), { recursive: true });
await writeFile(
join(pluginDir, "plugin.json"),
JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA, name: "review-tools", description: "Review helpers" }),
);
await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), "---\nname: review\ndescription: Review\n---\n");
await mkdir(join(pluginDir, "skills", "category", "nested"), { recursive: true });
await writeFile(join(pluginDir, "skills", "category", "nested", "SKILL.md"),
"---\nname: nested\ndescription: Not an immediate child\n---\n");
await writeFile(join(pluginDir, "skills", "SKILL.md"),
"---\nname: root\ndescription: Not an immediate child\n---\n");

const resolved = await resolvePlugin(
{ name: "review-tools", source: "path:source" },
{ stateDir: join(root, "state"), projectRoot: root },
);

expect(resolved).toEqual({
type: "local",
plugin: {
name: "review-tools",
source: "path:source",
pluginDir,
manifest: { $schema: AGENT_PLUGIN_SCHEMA, name: "review-tools", description: "Review helpers" },
authoredNativeInterfaces: {},
nativeSource: undefined,
},
});
const skills = (await discoverAllSkills(join(resolved.plugin.pluginDir, "skills"), { scanDirs: [] }))
.filter(({ path }) => path !== "." && !path.includes("/"));
expect(skills.map(({ meta }) => meta.name)).toEqual(["review"]);
});

it("resolves a manifest-less root marketplace plugin without the host", async () => {
const sourceDir = join(root, "source");
await mkdir(join(sourceDir, ".claude-plugin"), { recursive: true });
await mkdir(join(sourceDir, "skills", "agent-browser"), { recursive: true });
await writeFile(join(sourceDir, "skills", "agent-browser", "SKILL.md"),
"---\nname: agent-browser\ndescription: Browse\n---\n");
await writeFile(join(sourceDir, ".claude-plugin", "marketplace.json"), JSON.stringify({
name: "agent-browser",
plugins: [{ name: "agent-browser", source: "./", strict: false, skills: ["./skills/agent-browser"] }],
}));

const resolved = await resolvePlugin(
{ name: "agent-browser", source: "path:source", path: "." },
{ stateDir: join(root, "state"), projectRoot: root },
);
expect(resolved.plugin).toMatchObject({
name: "agent-browser",
pluginDir: sourceDir,
manifest: { name: "agent-browser", skills: ["./skills/agent-browser"] },
nativeSource: "claude",
});
});

it("filters requested names without returning unrelated plugins", async () => {
for (const name of ["alpha", "beta"]) {
const pluginDir = join(root, "plugins", name);
await mkdir(pluginDir, { recursive: true });
await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA, name }));
}

const names = async (requested?: string[]) =>
(await discoverPlugins(root, requested)).map((candidate) => candidate.name).toSorted();

expect(await names(["alpha"])).toEqual(["alpha"]);
expect(await names(["alpha", "missing"])).toEqual(["alpha"]);
expect(await names(["missing"])).toEqual([]);
expect(await names()).toEqual(["alpha", "beta"]);
});

it("keeps the physical directory when a symlink alias reaches the same plugin", async () => {
// Use both name orders so the result cannot depend on readdir order.
for (const [physical, alias] of [["review-tools", "a-alias"], ["review-tools", "z-alias"]] as const) {
const sourceRoot = join(root, alias);
const pluginDir = join(sourceRoot, "plugins", physical);
await mkdir(pluginDir, { recursive: true });
await writeFile(join(pluginDir, "plugin.json"), JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA, name: physical }));
await symlink(physical, join(sourceRoot, "plugins", alias));

const candidates = await discoverPlugins(sourceRoot);

expect(candidates.map((candidate) => candidate.path)).toEqual([`plugins/${physical}`]);
}
});

it("checks installed bundle containment before reading host provenance", async () => {
const pluginsDir = join(root, "installed");
const outsideDir = join(root, "outside");
await mkdir(pluginsDir, { recursive: true });
await mkdir(outsideDir, { recursive: true });
await writeFile(join(outsideDir, "plugin.json"), JSON.stringify({ name: "escape" }));
await symlink(outsideDir, join(pluginsDir, "escape"));

let provenanceReads = 0;
await expect(loadInstalledPluginBundle(pluginsDir, join(pluginsDir, "escape"), "escape", async () => {
provenanceReads += 1;
return { fallbackSources: null };
})).rejects.toThrow("Installed plugin resolves outside source: escape");
expect(provenanceReads).toBe(0);
});
});
Loading
Loading