diff --git a/AGENTS.md b/AGENTS.md index ee4bb094..60af07eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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) diff --git a/README.md b/README.md index 2b1cac69..9c204a38 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/packages/dotagents-lib/README.md b/packages/dotagents-lib/README.md index 00d3ca1c..23ec3f33 100644 --- a/packages/dotagents-lib/README.md +++ b/packages/dotagents-lib/README.md @@ -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:`, `path:`, 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`. @@ -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. diff --git a/packages/dotagents-lib/package.json b/packages/dotagents-lib/package.json index c93bdee8..166add88 100644 --- a/packages/dotagents-lib/package.json +++ b/packages/dotagents-lib/package.json @@ -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", @@ -28,6 +28,7 @@ "node": ">=20" }, "dependencies": { - "yaml": "^2.8.3" + "yaml": "^2.8.3", + "zod": "^4.3.6" } } diff --git a/packages/dotagents-lib/src/index.ts b/packages/dotagents-lib/src/index.ts index 8a7a1c18..10691b39 100644 --- a/packages/dotagents-lib/src/index.ts +++ b/packages/dotagents-lib/src/index.ts @@ -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, diff --git a/packages/dotagents-lib/src/plugins/native-interfaces.ts b/packages/dotagents-lib/src/plugins/native-interfaces.ts new file mode 100644 index 00000000..5296d07e --- /dev/null +++ b/packages/dotagents-lib/src/plugins/native-interfaces.ts @@ -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>; + +/** 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"], + }; +} diff --git a/packages/dotagents-lib/src/plugins/resolver.test.ts b/packages/dotagents-lib/src/plugins/resolver.test.ts new file mode 100644 index 00000000..d4afcb4c --- /dev/null +++ b/packages/dotagents-lib/src/plugins/resolver.test.ts @@ -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); + }); +}); diff --git a/packages/dotagents-lib/src/plugins/resolver.ts b/packages/dotagents-lib/src/plugins/resolver.ts new file mode 100644 index 00000000..415a9ffb --- /dev/null +++ b/packages/dotagents-lib/src/plugins/resolver.ts @@ -0,0 +1,1065 @@ +import { existsSync } from "node:fs"; +import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { ensureCached, sanitizeCacheKey, type CacheReuse } from "../sources/cache.js"; +import { resolveLocalSource } from "../sources/local.js"; +import type { RepositorySource } from "../sources/repository-source.js"; +import { applyDefaultRepositorySource, isSourceExcluded, parseSource } from "../skills/resolver.js"; +import type { TrustPolicy } from "../trust/policy.js"; +import { validateTrustedSource } from "../trust/validator.js"; +import { + isSerializedObject, + isSerializedValue, + type SerializedObject, + type SerializedValue, +} from "../utils/serialized.js"; +import { hasErrorCode, isString } from "../utils/type-guards.js"; +import { + codexPluginInterface, + generatedNativeMcpPath, + NATIVE_PLUGIN_MANIFEST_PATHS, + nativeInterfaceNeedsFallback, + nativePluginDisplayName, +} from "./native-interfaces.js"; +import { + parsePluginManifest, + parsePluginMcpBestEffort, + parsePluginMarketplace, + isStandardPluginManifest, + type MarketplacePluginEntry, + type LegacyPluginManifest, + type PluginManifest, + type PluginMcpParseResult, +} from "./schema.js"; +import type { + AuthoredNativePluginInterfaces, + NativePluginSource, + PluginBundle, +} from "./types.js"; + +// Owns plugin source discovery and resolution: it finds a plugin in a local or +// cached git source, validates its manifests, and keeps every path inside the +// source root. Installing, provenance, and client output belong to the host. + +/** Valid plugin names: 1-64 lowercase letters, numbers, hyphens, or dots. */ +export const PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/; + +/** A plugin dependency: the plugin name, its source, and an optional ref and path. */ +export interface PluginDependencyInput { + name: string; + source: string; + ref?: string; + path?: string; +} + +export interface PluginResolveOptions { + stateDir: string; + projectRoot: string; + defaultRepositorySource?: RepositorySource; + minimumReleaseAge?: number; + minimumReleaseAgeExclude?: string[]; + trust?: TrustPolicy; + /** Exact git checkout acquired earlier in the current operation. */ + reuse?: CacheReuse; + services?: Partial; +} + +export interface PluginResolverServices { + realpath(path: string): Promise; +} + +const DEFAULT_PLUGIN_RESOLVER_SERVICES: PluginResolverServices = { realpath }; + +export interface ResolvedLocalPlugin { + type: "local"; + plugin: Plugin; +} + +export interface ResolvedGitPlugin { + type: "git"; + resolvedUrl: string; + resolvedPath: string; + resolvedRef?: string; + commit: string; + plugin: Plugin; +} + +export type ResolvedPlugin = + | ResolvedLocalPlugin + | ResolvedGitPlugin; + +/** Native interface provenance recorded when a host installed a plugin bundle. */ +export interface InstalledPluginProvenance { + /** Recorded native fallbacks; null means no fallback record exists. */ + fallbackSources: ReadonlySet | null; + nativeSource?: NativePluginSource; +} + + +type PluginCandidateOrigin = "explicit" | "root" | "marketplace" | "canonical" | "conventional"; + +export interface PluginCandidate { + name: string; + dir: string; + path: string; + manifest: PluginManifest; + authoredNativeInterfaces: AuthoredNativePluginInterfaces; + legacyRoots: string[]; + nativeSource?: NativePluginSource; + origin: PluginCandidateOrigin; +} + +interface PluginCatalog { + candidates: PluginCandidate[]; + unsupportedMarketplaceSources: Map; + marketplaceOutcomes: Map>; + issues: Array<{ + name?: string; + path?: string; + origin: Exclude; + error: Error; + blocksNamedResolution: boolean; + }>; +} + +const MARKETPLACE_PATHS = [ + ".agents/plugins/marketplace.json", + "marketplace.json", + ".claude-plugin/marketplace.json", + ".cursor-plugin/marketplace.json", + ".codex-plugin/marketplace.json", + ".plugin/marketplace.json", + ".github/plugin/marketplace.json", +] as const; + +const COPILOT_MARKETPLACE_PATH = ".github/plugin/marketplace.json"; + +const COPILOT_HIGHER_PRIORITY_MARKETPLACES = [ + "marketplace.json", + ".plugin/marketplace.json", +] as const; + +const FALLBACK_MANIFEST_PATHS: ReadonlyArray<{ path: string; nativeSource?: NativePluginSource }> = [ + { path: ".codex-plugin/plugin.json", nativeSource: "codex" }, + { path: ".claude-plugin/plugin.json", nativeSource: "claude" }, + { path: ".cursor-plugin/plugin.json", nativeSource: "cursor" }, + { path: ".plugin/plugin.json" }, + { path: ".github/plugin/plugin.json" }, +] as const; + +export const HYBRID_LEGACY_ROOTS = [ + ".agents", + ".claude", + ".cursor", + ".codex", + ".opencode", + "agents", + "apps", + "bin", + "commands", + "rules", + "hooks", + "monitors", + ".mcp.json", + ".lsp.json", + ".app.json", +] as const; + +/** Resolves a plugin declaration to a trusted local or cached git source bundle. */ +export async function resolvePlugin( + config: PluginDependencyInput, + opts: PluginResolveOptions, +): Promise { + const services = { ...DEFAULT_PLUGIN_RESOLVER_SERVICES, ...opts.services }; + const sourceForResolve = applyDefaultRepositorySource( + config.source, + opts.defaultRepositorySource, + ); + if (opts.trust) {validateTrustedSource(sourceForResolve, opts.trust);} + + const parsed = parseSource(sourceForResolve); + + if (parsed.type === "local") { + const sourceDir = await resolveLocalSource(opts.projectRoot, parsed.path!); + const discovered = await resolvePluginCandidate(sourceDir, config, services.realpath); + if (!discovered) { + throw new Error(`Plugin "${config.name}" not found in ${config.source}.`); + } + return { + type: "local", + plugin: toBundle(config, discovered), + }; + } + + if (parsed.type === "well-known") { + throw new Error( + `HTTPS well-known sources are not supported for plugins. Use a git: URL, GitHub/GitLab repository, or path: source for "${config.name}".`, + ); + } + + const url = parsed.url!; + const cloneUrl = parsed.cloneUrl ?? url; + const ref = config.ref ?? parsed.ref; + const cacheKey = parsed.type === "github" + ? `${parsed.owner}/${parsed.repo}` + : sanitizeCacheKey(url); + const excluded = isSourceExcluded(config.source, opts.minimumReleaseAgeExclude); + const cached = await ensureCached({ + stateDir: opts.stateDir, + url: cloneUrl, + cacheKey, + ref, + minimumReleaseAge: excluded ? undefined : opts.minimumReleaseAge, + reuse: opts.reuse, + }); + + const discovered = await resolvePluginCandidate(cached.repoDir, config, services.realpath); + if (!discovered) { + throw new Error(`Plugin "${config.name}" not found in ${config.source}.`); + } + + return { + type: "git", + resolvedUrl: cloneUrl, + resolvedPath: discovered.path, + resolvedRef: ref, + commit: cached.commit, + plugin: toBundle(config, discovered), + }; +} + +/** + * Loads a plugin bundle that a host installed under `rootDir`. The bundle must + * have a root `plugin.json`, stay inside `rootDir`, and match `name`. The host + * reads its own provenance record only after the containment checks pass; the + * record selects which authored native manifests remain fallbacks. + */ +export async function loadInstalledPluginBundle( + rootDir: string, + pluginDir: string, + name: string, + readProvenance: (pluginDir: string) => Promise, +): Promise> { + await assertInsideSourceRoot(rootDir, pluginDir, "Installed plugin"); + await assertPluginBundleSymlinksContained(pluginDir); + const loaded = await loadPluginInterfaces(pluginDir, await readProvenance(pluginDir)); + if (!loaded) { + throw new Error("Plugin bundle has no plugin.json or supported native manifest"); + } + assertPluginName(name, loaded.manifest, pluginDir); + return { + name, + pluginDir, + manifest: normalizeManifest(name, loaded.manifest), + authoredNativeInterfaces: loaded.authoredNativeInterfaces, + nativeSource: loaded.nativeSource, + }; +} + +/** Throws when any symlink inside a plugin bundle resolves outside the bundle. */ +export async function assertPluginBundleSymlinksContained(pluginDir: string): Promise { + const rootRealPath = await realpath(pluginDir); + + const visit = async (dirPath: string): Promise => { + for (const entry of await readdir(dirPath, { withFileTypes: true })) { + const entryPath = join(dirPath, entry.name); + if (entry.isSymbolicLink()) { + let targetRealPath: string; + try { + targetRealPath = await realpath(entryPath); + } catch (err) { + throw new Error( + `Plugin bundle contains an invalid symlink: ${relativePath(pluginDir, entryPath)}`, + { cause: err }, + ); + } + const targetRelativePath = relative(rootRealPath, targetRealPath); + if (isOutsideRelativePath(targetRelativePath)) { + throw new Error( + `Plugin bundle symlink resolves outside the plugin directory: ${relativePath(pluginDir, entryPath)}`, + ); + } + continue; + } + if (entry.isDirectory()) { + await visit(entryPath); + } + } + }; + + await visit(pluginDir); +} + +async function resolvePluginCandidate( + sourceDir: string, + config: PluginDependencyInput, + resolveRealpath: PluginResolverServices["realpath"] = realpath, +): Promise { + if (config.path) { + const dir = await resolveInside(sourceDir, config.path, "Plugin path", resolveRealpath); + let candidate = await loadPluginCandidate( + sourceDir, + dir, + { name: config.name }, + "Plugin source", + "explicit", + ); + if (!candidate) { + const marketplace = await discoverFromMarketplaces(sourceDir, { name: config.name, dir }); + const outcome = marketplace.outcomes.get(config.name)?.[0]; + if (outcome && "error" in outcome) {throw outcome.error;} + candidate = outcome?.candidate ?? null; + } + if (candidate) {await assertPluginBundleSymlinksContained(candidate.dir);} + return candidate; + } + + const catalog = await discoverPluginCatalog(sourceDir); + const candidate = resolveNamedPluginCandidate(catalog, config); + if (candidate) {await assertPluginBundleSymlinksContained(candidate.dir);} + return candidate; +} + +function resolveNamedPluginCandidate( + catalog: PluginCatalog, + config: Pick, +): PluginCandidate | null { + const { candidates } = catalog; + const canonicalPath = posix.join(".agents", "plugins", config.name); + const canonical = candidates.find((candidate) => candidate.path === canonicalPath); + if (canonical && candidateMatches(config.name, canonical)) { + return canonical; + } + + const canonicalIssue = catalog.issues.find( + (issue) => issue.origin === "canonical" && issue.path === canonicalPath, + ); + if (canonicalIssue) {throw canonicalIssue.error;} + + const namedRootIssue = catalog.issues.find( + (issue) => issue.origin === "root" && issue.name === config.name, + ); + if (namedRootIssue) {throw namedRootIssue.error;} + + const marketplaceOutcome = catalog.marketplaceOutcomes.get(config.name)?.[0]; + if (marketplaceOutcome && "error" in marketplaceOutcome) { + throw marketplaceOutcome.error; + } + if (marketplaceOutcome) {return marketplaceOutcome.candidate;} + + const conventionalIssue = catalog.issues.find( + (issue) => issue.origin === "conventional" && + issue.blocksNamedResolution && issue.name === config.name, + ); + if (conventionalIssue) {throw conventionalIssue.error;} + + const unique = rankedCandidates( + config.name, + candidates.filter((candidate) => candidate.origin !== "marketplace"), + ); + if (unique.length > 1) { + throw new Error( + `Plugin "${config.name}" is ambiguous in ${config.source}: ${unique.map((m) => m.path).join(", ")}`, + ); + } + const unsupportedMarketplaceSources = catalog.unsupportedMarketplaceSources.get(config.name) ?? []; + if (unique.length === 0 && unsupportedMarketplaceSources.length > 0) { + throw new Error( + `Plugin "${config.name}" not found in ${config.source}. Matching marketplace entries use unsupported source types: ${[...new Set(unsupportedMarketplaceSources)].join(", ")}. Only local marketplace sources are supported.`, + ); + } + const rootIssue = catalog.issues.find((issue) => issue.origin === "root"); + if (unique.length === 0 && rootIssue) {throw rootIssue.error;} + return unique[0] ?? null; +} + +/** + * Discovers valid plugins from supported source locations. + * + * With `requestedNames`, returns only matching candidates. An empty result can + * mean either that the source has no plugins or that none of the names match. + */ +export async function discoverPlugins( + sourceDir: string, + requestedNames?: string[], +): Promise { + const catalog = await discoverPluginCatalog(sourceDir); + let candidates = catalog.candidates; + if (requestedNames?.length) { + const requested: PluginCandidate[] = []; + for (const name of requestedNames) { + const candidate = resolveNamedPluginCandidate(catalog, { name, source: sourceDir }); + if (candidate) {requested.push(candidate);} + } + candidates = await dedupeCandidates(requested); + } else if (catalog.issues[0]) { + throw catalog.issues[0].error; + } + for (const candidate of candidates) { + await assertPluginBundleSymlinksContained(candidate.dir); + } + return candidates; +} + +async function discoverPluginCatalog(sourceDir: string): Promise { + const candidates: PluginCandidate[] = []; + const issues: PluginCatalog["issues"] = []; + + try { + const root = await loadPluginCandidate(sourceDir, sourceDir, {}, "Plugin source", "root"); + if (root) {candidates.push(root);} + } catch (err) { + issues.push({ + name: err instanceof NamedPluginManifestError ? err.pluginName : undefined, + path: "", + origin: "root", + error: err instanceof Error ? err : new Error(String(err)), + blocksNamedResolution: false, + }); + } + + const marketplace = await discoverFromMarketplaces(sourceDir); + candidates.push(...marketplace.candidates); + issues.push(...marketplace.issues); + candidates.push(...await scanPluginDirectories( + sourceDir, + join(sourceDir, ".agents", "plugins"), + 2, + "Canonical plugin source", + "canonical", + issues, + marketplace.referencedDirs, + )); + candidates.push(...await scanPluginDirectories( + sourceDir, + join(sourceDir, "plugins"), + 2, + "Plugin source", + "conventional", + issues, + marketplace.referencedDirs, + )); + + return { + candidates: await dedupeCandidates(candidates), + unsupportedMarketplaceSources: marketplace.unsupportedSources, + marketplaceOutcomes: marketplace.outcomes, + issues, + }; +} + +/** + * Reads marketplace selector files and resolves only explicit local sources. + * Relative paths prefer the repository root used by current Claude + * marketplaces, then fall back to the marketplace file directory for legacy + * catalogs. Both forms remain contained by the source root. + */ +async function discoverFromMarketplaces( + sourceDir: string, + selection?: { name: string; dir: string }, +): Promise<{ + candidates: PluginCandidate[]; + unsupportedSources: Map; + outcomes: PluginCatalog["marketplaceOutcomes"]; + issues: PluginCatalog["issues"]; + referencedDirs: Set; +}> { + const candidates: PluginCandidate[] = []; + const unsupportedSources = new Map(); + const outcomes: PluginCatalog["marketplaceOutcomes"] = new Map(); + const issues: PluginCatalog["issues"] = []; + const referencedDirs = new Set(); + for (const marketplacePath of MARKETPLACE_PATHS) { + // The Copilot-specific .github locator is invisible whenever either + // higher-priority native locator exists. Other generic discovery inputs + // retain their existing union behavior. + if ( + marketplacePath === COPILOT_MARKETPLACE_PATH && + COPILOT_HIGHER_PRIORITY_MARKETPLACES.some((path) => existsSync(join(sourceDir, path))) + ) { + continue; + } + const filePath = join(sourceDir, marketplacePath); + if (!existsSync(filePath)) {continue;} + + let marketplace: ReturnType; + try { + marketplace = parsePluginMarketplace(await readJson(filePath), filePath); + } catch (err) { + issues.push({ + origin: "marketplace", + error: err instanceof Error ? err : new Error(String(err)), + blocksNamedResolution: false, + }); + continue; + } + const root = isString(marketplace.metadata?.pluginRoot) + ? marketplace.metadata.pluginRoot + : "."; + for (const entry of marketplace.plugins) { + if (selection && entry.name !== selection.name) {continue;} + const path = localMarketplacePath(entry); + if (path === null) { + const sources = unsupportedSources.get(entry.name) ?? []; + sources.push(marketplaceSourceType(entry)); + unsupportedSources.set(entry.name, sources); + continue; + } + const marketplaceRoot = dirname(filePath); + const anchors = [...new Set([sourceDir, marketplaceRoot])].filter( + (anchor) => !selection || resolve(anchor, root, path) === selection.dir, + ); + if (anchors.length === 0) {continue;} + let pluginDir: string | undefined; + let candidate: PluginCandidate | null; + try { + candidate = null; + const resolutionErrors: Error[] = []; + for (const anchor of anchors) { + let resolvedDir: string; + try { + resolvedDir = await resolveMarketplaceSource( + sourceDir, + anchor, + join(root, path), + "Marketplace plugin source", + ); + } catch (err) { + resolutionErrors.push(err instanceof Error ? err : new Error(String(err))); + continue; + } + const resolvedCandidate = await loadPluginCandidate( + sourceDir, + resolvedDir, + marketplaceManifestOverlay(entry), + "Marketplace plugin source", + "marketplace", + { entry, filePath, nativeSource: NATIVE_PLUGIN_MANIFEST_PATHS.find( + (native) => dirname(native.path) === dirname(marketplacePath), + )?.source }, + ); + if (!resolvedCandidate) {continue;} + pluginDir = resolvedDir; + candidate = resolvedCandidate; + break; + } + if (!candidate && resolutionErrors.length === anchors.length) { + throw resolutionErrors[0]!; + } + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + issues.push({ + name: entry.name, + origin: "marketplace", + error, + blocksNamedResolution: true, + }); + const namedOutcomes = outcomes.get(entry.name) ?? []; + namedOutcomes.push({ error }); + outcomes.set(entry.name, namedOutcomes); + continue; + } + if (!candidate) { + const error = new Error( + `Marketplace plugin "${entry.name}" in ${filePath} has no supported plugin manifest at ${path || "."}.`, + ); + issues.push({ + name: entry.name, + origin: "marketplace", + error, + blocksNamedResolution: true, + }); + const namedOutcomes = outcomes.get(entry.name) ?? []; + namedOutcomes.push({ error }); + outcomes.set(entry.name, namedOutcomes); + continue; + } + referencedDirs.add(resolve(pluginDir!)); + candidates.push(candidate); + const namedOutcomes = outcomes.get(entry.name) ?? []; + namedOutcomes.push({ candidate }); + outcomes.set(entry.name, namedOutcomes); + } + } + return { candidates, unsupportedSources, outcomes, issues, referencedDirs }; +} + +async function scanPluginDirectories( + sourceRoot: string, + dir: string, + depth = 2, + label = "Plugin source", + origin: PluginCandidateOrigin = "conventional", + issues: PluginCatalog["issues"] = [], + skippedDirs: ReadonlySet = new Set(), +): Promise { + if (depth <= 0 || !await isDirectory(dir)) {return [];} + + const matches: PluginCandidate[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if ((!entry.isDirectory() && !entry.isSymbolicLink()) || entry.name.startsWith(".")) {continue;} + const childDir = join(dir, entry.name); + if (skippedDirs.has(resolve(childDir))) {continue;} + let candidate: PluginCandidate | null; + try { + candidate = await loadPluginCandidate(sourceRoot, childDir, {}, label, origin); + } catch (err) { + issues.push({ + name: entry.name, + path: relativePath(sourceRoot, childDir), + origin: origin === "canonical" ? "canonical" : "conventional", + error: err instanceof Error ? err : new Error(String(err)), + blocksNamedResolution: true, + }); + candidate = null; + } + if (candidate) { + matches.push(candidate); + continue; + } + matches.push(...await scanPluginDirectories( + sourceRoot, + childDir, + depth - 1, + label, + origin, + issues, + skippedDirs, + )); + } + return matches; +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch (err) { + if (isNotFoundError(err)) {return false;} + throw err; + } +} + +async function loadPluginCandidate( + sourceRoot: string, + pluginDir: string, + overlay: Partial = {}, + label = "Plugin source", + origin: PluginCandidateOrigin = "root", + marketplace?: { entry: MarketplacePluginEntry; filePath: string; nativeSource?: NativePluginSource }, +): Promise { + if (!existsSync(pluginDir)) {return null;} + await assertInsideSourceRoot(sourceRoot, pluginDir, label); + + let loaded = await loadPluginInterfaces(pluginDir); + if (!loaded && marketplace?.entry.strict === false && await isDirectory(pluginDir)) { + const manifest = parsePluginManifest(Object.fromEntries( + Object.entries(marketplace.entry).filter(([key]) => !["source", "strict", "category", "tags", "policy"].includes(key)), + ), marketplace.filePath); + const nativeSource = marketplace.nativeSource; + loaded = { + manifest, + nativeSource, + authoredNativeInterfaces: nativeSource ? { + [nativeSource]: { path: `.${nativeSource}-plugin/plugin.json`, fallback: true, manifest }, + } : {}, + }; + } + if (!loaded) {return null;} + const manifest = loaded.manifest; + + const name = manifest && isString(manifest["name"]) + ? String(manifest["name"]) + : isString(overlay["name"]) + ? String(overlay["name"]) + : basename(pluginDir); + // SAFETY: the merged fallback only combines fields from two legacy manifests. + const combined = manifest && isStandardPluginManifest(manifest) + ? manifest + : normalizeManifest(name, { ...overlay, ...manifest } as LegacyPluginManifest); + if (!PLUGIN_NAME_PATTERN.test(name)) { + throw new Error( + `Invalid plugin name "${name}" in ${relativePath(sourceRoot, pluginDir) || "."}. ` + + "Plugin names must be 1-64 lowercase letters, numbers, hyphens, or dots, have alphanumeric ends, and not contain '--' or '..'.", + ); + } + return { + name, + dir: pluginDir, + path: relativePath(sourceRoot, pluginDir), + manifest: combined, + authoredNativeInterfaces: loaded.authoredNativeInterfaces, + legacyRoots: isStandardPluginManifest(combined) + ? HYBRID_LEGACY_ROOTS.filter((path) => existsSync(join(pluginDir, path))) + : [], + nativeSource: loaded?.nativeSource, + origin, + }; +} + +function marketplaceManifestOverlay( + entry: MarketplacePluginEntry, +): Partial { + const overlay: Partial = { name: entry.name }; + if (entry.description) {overlay.description = entry.description;} + if (entry.version) {overlay.version = entry.version;} + if (entry.category) {overlay.category = entry.category;} + return overlay; +} + +async function loadPluginInterfaces( + pluginDir: string, + provenance?: InstalledPluginProvenance, +): Promise<{ + manifest: PluginManifest; + authoredNativeInterfaces: AuthoredNativePluginInterfaces; + nativeSource?: NativePluginSource; +} | null> { + const fallbackSources = provenance?.fallbackSources; + const installedNativeSource = provenance?.nativeSource; + const rootPath = join(pluginDir, "plugin.json"); + if (existsSync(rootPath)) { + const value = await readJson(rootPath); + let manifest: PluginManifest; + try { + manifest = parsePluginManifest(value, rootPath); + } catch (err) { + throw pluginManifestError(err, value); + } + return { + manifest, + authoredNativeInterfaces: await loadAuthoredNativeInterfaces( + pluginDir, + fallbackSources, + installedNativeSource, + manifest, + ), + nativeSource: installedNativeSource, + }; + } + if (provenance) { + throw new Error("Installed plugin bundle is missing plugin.json. Reinstall the plugin."); + } + + for (const candidate of FALLBACK_MANIFEST_PATHS) { + const filePath = join(pluginDir, candidate.path); + if (!existsSync(filePath)) {continue;} + const value = await readJson(filePath); + let manifest: PluginManifest; + try { + manifest = parsePluginManifest(value, filePath); + } catch (err) { + throw pluginManifestError(err, value); + } + return { + manifest, + authoredNativeInterfaces: await loadAuthoredNativeInterfaces(pluginDir), + nativeSource: candidate.nativeSource, + }; + } + return null; +} + +async function loadAuthoredNativeInterfaces( + pluginDir: string, + fallbackSources?: ReadonlySet | null, + installedNativeSource?: NativePluginSource, + portableManifest?: PluginManifest, +): Promise { + // undefined classifies source input; null ignores installed adapters; a Set reloads recorded fallbacks. + const interfaces: AuthoredNativePluginInterfaces = {}; + const portableMcp = fallbackSources === undefined && portableManifest && + isStandardPluginManifest(portableManifest) + ? await loadPortableMcpForGeneration(pluginDir) + : undefined; + for (const candidate of NATIVE_PLUGIN_MANIFEST_PATHS) { + const filePath = join(pluginDir, candidate.path); + if (!existsSync(filePath)) { + if (fallbackSources?.has(candidate.source)) { + throw new Error( + `Installed plugin records a ${nativePluginDisplayName(candidate.source)} native fallback, but ${candidate.path} is missing. Reinstall the plugin.`, + ); + } + continue; + } + if (fallbackSources && !fallbackSources.has(candidate.source)) {continue;} + if (fallbackSources === null && installedNativeSource !== candidate.source) {continue;} + let fallback = true; + try { + const value = await readJson(filePath); + const manifest = parsePluginManifest(value, filePath); + if (fallbackSources === undefined && portableManifest) { + const reproducibleFields: SerializedObject = {}; + const mcpPath = await reproducibleNativeMcpPath( + pluginDir, + candidate.source, + portableMcp, + ); + if (mcpPath) {reproducibleFields["mcpServers"] = mcpPath;} + if (candidate.source === "codex" && portableManifest && isString(portableManifest.name)) { + reproducibleFields["interface"] = codexPluginInterface( + portableManifest.name, + portableManifest, + ); + } + fallback = nativeInterfaceNeedsFallback(candidate.source, value, reproducibleFields); + } + interfaces[candidate.source] = { + path: candidate.path, + fallback, + manifest, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + interfaces[candidate.source] = { + path: candidate.path, + fallback, + error: message.replaceAll(filePath, candidate.path), + }; + } + } + return interfaces; +} + +async function reproducibleNativeMcpPath( + pluginDir: string, + source: NativePluginSource, + portableMcp: PluginMcpParseResult | undefined, +): Promise { + const mcpPath = generatedNativeMcpPath(source, portableMcp); + if (!mcpPath || mcpPath === "./mcp.json") {return mcpPath;} + const adapterPath = join(pluginDir, mcpPath); + if (!existsSync(adapterPath)) {return mcpPath;} + try { + return isDeepStrictEqual(await readJson(adapterPath), portableMcp?.config) + ? mcpPath + : undefined; + } catch { + return undefined; + } +} + +async function loadPortableMcpForGeneration( + pluginDir: string, +): Promise { + const filePath = join(pluginDir, "mcp.json"); + if (!existsSync(filePath)) {return undefined;} + try { + return parsePluginMcpBestEffort(await readJson(filePath), filePath); + } catch { + return undefined; + } +} + +class NamedPluginManifestError extends Error { + constructor(message: string, readonly pluginName?: string) { + super(message); + } +} + +function pluginManifestError(err: Thrown, value: SerializedValue): Error { + const message = err instanceof Error ? err.message : String(err); + const name = isSerializedObject(value) && isString(value["name"]) + ? value["name"] + : undefined; + return new NamedPluginManifestError(message, name); +} + +function toBundle( + config: Pick, + candidate: PluginCandidate, +): PluginBundle { + assertPluginName(config.name, candidate.manifest, candidate.path); + return { + name: config.name, + source: config.source, + pluginDir: candidate.dir, + manifest: normalizeManifest(config.name, candidate.manifest), + authoredNativeInterfaces: candidate.authoredNativeInterfaces, + nativeSource: candidate.nativeSource, + }; +} + +function assertPluginName( + expected: string, + manifest: PluginManifest, + context: string, +): void { + const actual = manifest["name"]; + if (isString(actual) && actual !== expected) { + throw new Error(`Plugin manifest name "${actual}" does not match configured name "${expected}" in ${context}.`); + } +} + +function normalizeManifest( + name: string, + manifest: PluginManifest, +): PluginManifest { + // SAFETY: replacing the required name field preserves every PluginManifest variant. + return { ...manifest, name } as PluginManifest; +} + +function candidateMatches(name: string, candidate: PluginCandidate): boolean { + return basename(candidate.dir) === name || candidate.name === name; +} + +/** Applies discovery precedence: directory-name matches win before manifest-name fallback. */ +function rankedCandidates(name: string, candidates: PluginCandidate[]): PluginCandidate[] { + const directoryMatches = candidates.filter((candidate) => basename(candidate.dir) === name); + return directoryMatches.length > 0 + ? directoryMatches + : candidates.filter((candidate) => candidate.name === name); +} + +/** + * Removes candidates that reach the same plugin directory. The first origin + * wins. Within one origin, a physical directory wins over a symlink alias, and + * then the lower path wins, so the result does not depend on `readdir` order. + */ +async function dedupeCandidates(candidates: PluginCandidate[]): Promise { + const indexByKey = new Map(); + const result: Array<{ candidate: PluginCandidate; alias: boolean }> = []; + for (const candidate of candidates) { + const key = `${await realpath(candidate.dir)}\0${candidate.name}`; + const entry = { candidate, alias: (await lstat(candidate.dir)).isSymbolicLink() }; + const index = indexByKey.get(key); + if (index === undefined) { + indexByKey.set(key, result.length); + result.push(entry); + continue; + } + const kept = result[index]!; + if (kept.candidate.origin === candidate.origin && prefersCandidate(entry, kept)) { + result[index] = entry; + } + } + return result.map((entry) => entry.candidate); +} + +function prefersCandidate( + next: { candidate: PluginCandidate; alias: boolean }, + kept: { candidate: PluginCandidate; alias: boolean }, +): boolean { + if (next.alias !== kept.alias) {return !next.alias;} + return next.candidate.path < kept.candidate.path; +} + +/** Returns local marketplace paths; unsupported extension sources are skipped. */ +function localMarketplacePath(entry: MarketplacePluginEntry): string | null { + const { source } = entry; + if (isString(source)) {return stripDotSlash(source);} + + if (source.source === "local" && isString(source.path)) { + return stripDotSlash(source.path); + } + return null; +} + +function marketplaceSourceType(entry: MarketplacePluginEntry): string { + const { source } = entry; + if (isString(source)) {return "local";} + return source.source ?? "extension"; +} + +function stripDotSlash(path: string): string { + return path.replace(/^\.\//, ""); +} + +/** Resolves a selector path while preserving the source-root containment boundary. */ +async function resolveInside( + root: string, + childPath: string, + label: string, + resolveRealpath: PluginResolverServices["realpath"] = realpath, +): Promise { + const rootPath = resolve(root); + const filePath = resolve(rootPath, childPath); + const relPath = relative(rootPath, filePath); + if (isOutsideRelativePath(relPath)) { + throw new Error(`${label} resolves outside source: ${childPath}`); + } + if (existsSync(filePath)) { + await assertInsideSourceRoot(rootPath, filePath, label, childPath, resolveRealpath); + } + return filePath; +} + +async function resolveMarketplaceSource( + sourceRoot: string, + marketplaceRoot: string, + childPath: string, + label: string, +): Promise { + const filePath = resolve(marketplaceRoot, childPath); + const sourceRootPath = resolve(sourceRoot); + const relPath = relative(sourceRootPath, filePath); + if (isOutsideRelativePath(relPath)) { + throw new Error(`${label} resolves outside source: ${childPath}`); + } + if (existsSync(filePath)) { + await assertInsideSourceRoot(sourceRootPath, filePath, label, childPath); + } + return filePath; +} + +async function assertInsideSourceRoot( + root: string, + filePath: string, + label: string, + displayPath = relativePath(root, filePath), + resolveRealpath: PluginResolverServices["realpath"] = realpath, +): Promise { + let rootRealPath: string; + try { + rootRealPath = await resolveRealpath(root); + } catch (err) { + if (isNotFoundError(err)) { + throw new Error(`${label} source root does not exist: ${displayPath}`, { cause: err }); + } + throw err; + } + let fileRealPath: string; + try { + fileRealPath = await resolveRealpath(filePath); + } catch (err) { + if (isNotFoundError(err)) { + throw new Error(`${label} source path does not exist: ${displayPath}`, { cause: err }); + } + throw err; + } + const realRelPath = relative(rootRealPath, fileRealPath); + if (isOutsideRelativePath(realRelPath)) { + throw new Error(`${label} resolves outside source: ${displayPath}`); + } +} + +function isNotFoundError(err: ErrorValue): boolean { + return hasErrorCode(err, "ENOENT"); +} + +function relativePath(root: string, filePath: string): string { + return relative(root, filePath).split("\\").join("/"); +} + +function isOutsideRelativePath(path: string): boolean { + return path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path); +} + +async function readJson(filePath: string): Promise { + const raw = await readFile(filePath, "utf-8"); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid JSON ${filePath}: ${message}`, { cause: err }); + } + if (!isSerializedValue(parsed)) { + throw new Error(`Invalid JSON ${filePath}: value is not safely serializable`); + } + return parsed; +} diff --git a/packages/dotagents/src/plugins/schema.test.ts b/packages/dotagents-lib/src/plugins/schema.test.ts similarity index 100% rename from packages/dotagents/src/plugins/schema.test.ts rename to packages/dotagents-lib/src/plugins/schema.test.ts diff --git a/packages/dotagents/src/plugins/schema.ts b/packages/dotagents-lib/src/plugins/schema.ts similarity index 99% rename from packages/dotagents/src/plugins/schema.ts rename to packages/dotagents-lib/src/plugins/schema.ts index cd51bb71..5f678f0e 100644 --- a/packages/dotagents/src/plugins/schema.ts +++ b/packages/dotagents-lib/src/plugins/schema.ts @@ -1,7 +1,7 @@ import { z } from "zod/v4"; import { validateHeaderName, validateHeaderValue } from "node:http"; import { isIP } from "node:net"; -import { isSerializedObject, type SerializedObject } from "@sentry/dotagents-lib"; +import { isSerializedObject, type SerializedObject } from "../utils/serialized.js"; import { isObject, isString } from "../utils/type-guards.js"; export const AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; diff --git a/packages/dotagents-lib/src/plugins/types.ts b/packages/dotagents-lib/src/plugins/types.ts new file mode 100644 index 00000000..ddcdd7da --- /dev/null +++ b/packages/dotagents-lib/src/plugins/types.ts @@ -0,0 +1,26 @@ +import type { PluginManifest } from "./schema.js"; + +export type NativePluginSource = "claude" | "cursor" | "codex"; + +/** + * A client-specific manifest authored next to the portable plugin core. + * `fallback` is true when the portable core cannot reproduce it, so the + * matching client must keep the authored manifest. + */ +export type AuthoredNativePluginInterface = + | { path: string; fallback: boolean; manifest: PluginManifest; error?: never } + | { path: string; fallback: boolean; manifest?: never; error: string }; + +export type AuthoredNativePluginInterfaces = Partial< + Record +>; + +/** A validated plugin directory with its effective manifest. */ +export interface PluginBundle { + name: string; + source: string; + pluginDir: string; + manifest: PluginManifest; + authoredNativeInterfaces?: AuthoredNativePluginInterfaces; + nativeSource?: NativePluginSource; +} diff --git a/packages/dotagents-lib/src/utils/type-guards.ts b/packages/dotagents-lib/src/utils/type-guards.ts new file mode 100644 index 00000000..300d4d1f --- /dev/null +++ b/packages/dotagents-lib/src/utils/type-guards.ts @@ -0,0 +1,15 @@ +interface ErrorWithCode extends Error { + code: string | number; +} + +export function isString(value: Value): value is Value & string { + return typeof value === "string"; +} + +export function isObject(value: Value): value is Value & object { + return typeof value === "object" && value !== null; +} + +export function hasErrorCode(value: Value, code: string): value is Value & ErrorWithCode { + return value instanceof Error && "code" in value && value.code === code; +} diff --git a/packages/dotagents/src/cli/commands/add.test.ts b/packages/dotagents/src/cli/commands/add.test.ts index 69765cde..73824d5d 100644 --- a/packages/dotagents/src/cli/commands/add.test.ts +++ b/packages/dotagents/src/cli/commands/add.test.ts @@ -671,6 +671,12 @@ describe("runAdd (local sources)", () => { await writeFile(join(sourceDir, "standalone", "SKILL.md"), SKILL_MD("standalone")); const install = mockRunInstall(); + await expect(runAdd({ + scope: resolveScope("project", projectRoot), + specifier: "path:mixed-source", + names: ["bundled"], + })).rejects.toThrow('Plugin "bundled" not found'); + const result = await runAdd({ scope: resolveScope("project", projectRoot), specifier: "path:mixed-source", diff --git a/packages/dotagents/src/cli/commands/add.ts b/packages/dotagents/src/cli/commands/add.ts index 4c0faf4b..87301801 100644 --- a/packages/dotagents/src/cli/commands/add.ts +++ b/packages/dotagents/src/cli/commands/add.ts @@ -10,7 +10,6 @@ import { isWildcardDep, GITHUB_HTTPS_URL, GITLAB_HTTPS_URL, - PLUGIN_NAME_PATTERN, agentsConfigSchema, } from "../../config/schema.js"; import { @@ -37,6 +36,9 @@ import { TrustError, GitError, type CacheReuse, + discoverPlugins, + PLUGIN_NAME_PATTERN, + type PluginCandidate, } from "@sentry/dotagents-lib"; import { getCacheStateDir, HOST_SCAN_DIRS } from "../cache.js"; import { formatGitError, formatTrustError } from "../errors.js"; @@ -44,10 +46,7 @@ import { runInstall } from "./install.js"; import type { ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { commandPrefix, type CommandContext } from "../context.js"; -import { - discoverPlugins, - type PluginCandidate, -} from "../../plugins/store.js"; + import { isNumber, isString } from "../../utils/type-guards.js"; /** Parent paths that are standard/expected — no need to show them in the picker */ @@ -693,6 +692,10 @@ async function executeAdd(opts: AddOptions): Promise { progress?.message(`Inspecting ${specifier}`); if (acquired.pluginEligible) { plugins = await discoverPlugins(acquired.rootDir, namesOverride); + if (plugins.length === 0 && namesOverride?.length) { + // A missing plugin name must not turn a plugin source into a skill source. + plugins = await discoverPlugins(acquired.rootDir); + } } progress?.stop("Source ready"); } catch (err) { diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 3274cb14..74f6cb67 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -5,7 +5,14 @@ import { dirname, join, relative, resolve } from "node:path"; import { tmpdir } from "node:os"; import install, { runInstall as runInstallCommand, InstallError, type InstallOptions, type InstallResult } from "./install.js"; import { runSync } from "./sync.js"; -import { exec, isSerializedObject, type SerializedObject, type SerializedValue } from "@sentry/dotagents-lib"; +import { + AGENT_PLUGIN_MCP_SCHEMA, + AGENT_PLUGIN_SCHEMA, + exec, + isSerializedObject, + type SerializedObject, + type SerializedValue, +} from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import type { Lockfile } from "../../lockfile/schema.js"; @@ -15,7 +22,6 @@ import { DOTAGENTS_MANAGED_PLUGIN_MARKER, DOTAGENTS_NATIVE_FALLBACKS_MARKER, } from "../../plugins/store.js"; -import { AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA } from "../../plugins/schema.js"; const SKILL_MD = (name: string) => `--- name: ${name} diff --git a/packages/dotagents/src/cli/commands/install/plugins.ts b/packages/dotagents/src/cli/commands/install/plugins.ts index aac7012e..31c64e5e 100644 --- a/packages/dotagents/src/cli/commands/install/plugins.ts +++ b/packages/dotagents/src/cli/commands/install/plugins.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { PLUGIN_NAME_PATTERN, type AgentsConfig } from "../../../config/schema.js"; +import type { AgentsConfig } from "../../../config/schema.js"; import type { Lockfile } from "../../../lockfile/schema.js"; import type { ScopeRoot } from "../../../scope.js"; import { @@ -15,7 +15,7 @@ import { resolvePlugin, } from "../../../plugins/store.js"; import type { PluginDeclaration } from "../../../plugins/types.js"; -import { GitError, TrustError, type CacheReuse } from "@sentry/dotagents-lib"; +import { GitError, PLUGIN_NAME_PATTERN, TrustError, type CacheReuse } from "@sentry/dotagents-lib"; import { getCacheStateDir } from "../../cache.js"; import { InstallError } from "./errors.js"; diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 53d0490c..82cd5853 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -12,13 +12,12 @@ import { WildcardSkillRemoveError, } from "./remove.js"; import { runInstall as runInstallCommand } from "./install.js"; -import { exec } from "@sentry/dotagents-lib"; +import { AGENT_PLUGIN_SCHEMA, exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; import { DOTAGENTS_MANAGED_PLUGIN_MARKER } from "../../plugins/store.js"; -import { AGENT_PLUGIN_SCHEMA } from "../../plugins/schema.js"; const SKILL_MD = (name: string) => `--- name: ${name} diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 71db92bc..184a7995 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -9,7 +9,7 @@ import { loadLockfile } from "../../lockfile/loader.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; import { DOTAGENTS_SUBAGENT_MARKER } from "../../subagents/format.js"; -import { AGENT_PLUGIN_SCHEMA } from "../../plugins/schema.js"; +import { AGENT_PLUGIN_SCHEMA } from "@sentry/dotagents-lib"; const SKILL_MD = (name: string) => `--- name: ${name} diff --git a/packages/dotagents/src/config/schema.ts b/packages/dotagents/src/config/schema.ts index f7c5f9e5..cb37d7bd 100644 --- a/packages/dotagents/src/config/schema.ts +++ b/packages/dotagents/src/config/schema.ts @@ -5,6 +5,7 @@ import { GITHUB_SSH_URL, GITLAB_HTTPS_URL, GITLAB_SSH_URL, + PLUGIN_NAME_PATTERN, type RepositorySource, } from "@sentry/dotagents-lib"; @@ -194,8 +195,6 @@ const subagentSchema = z.object({ export type SubagentConfig = z.infer; -export const PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/; - const pluginNameSchema = z .string() .regex( diff --git a/packages/dotagents/src/plugins/runtime/manifest-values.ts b/packages/dotagents/src/plugins/runtime/manifest-values.ts index 5d038bd6..e368f63a 100644 --- a/packages/dotagents/src/plugins/runtime/manifest-values.ts +++ b/packages/dotagents/src/plugins/runtime/manifest-values.ts @@ -1,46 +1,4 @@ -import type { SerializedObject } from "@sentry/dotagents-lib"; -import { isStandardPluginManifest, type LegacyPluginManifest, type PluginManifest } from "../schema.js"; -import { isString } from "../../utils/type-guards.js"; - -export function manifestString( - manifest: PluginManifest, - key: "description" | "version", -): string | undefined { - const value = manifest[key]; - return isString(value) ? value : undefined; -} - -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; -} - /** Formats an already validated plugin-relative component path. */ export function runtimePath(value: string): string { return value.startsWith(".") ? value : `./${value}`; } - -export function titleCase(value: string): string { - return value - .split(/[-.]/) - .filter(Boolean) - .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) - .join(" "); -} - -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"], - }; -} diff --git a/packages/dotagents/src/plugins/runtime/manifests.ts b/packages/dotagents/src/plugins/runtime/manifests.ts index 6aadc738..0905ecbe 100644 --- a/packages/dotagents/src/plugins/runtime/manifests.ts +++ b/packages/dotagents/src/plugins/runtime/manifests.ts @@ -1,21 +1,25 @@ import { existsSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { isStandardPluginManifest, parsePluginMcpBestEffort, type LegacyPluginManifest, type PluginManifest, type PluginMcpConfig } from "../schema.js"; -import type { PluginDeclaration } from "../types.js"; import { + codexPluginInterface, generatedNativeMcpPath, + isStandardPluginManifest, + parsePluginMcpBestEffort, + type LegacyPluginManifest, + type PluginManifest, + type PluginMcpConfig, + type SerializedObject, +} from "@sentry/dotagents-lib"; +import type { PluginDeclaration } from "../types.js"; +import { hasAuthoredNativeInterface, usesLegacyPluginComponents, } from "../targets.js"; import { isManagedJsonFile, removeManagedJsonFile, stableJson, writeManagedJsonIfChanged } from "../managed-files.js"; -import { - codexPluginInterface, - runtimePath, -} from "./manifest-values.js"; +import { runtimePath } from "./manifest-values.js"; import type { PluginWriteWarning } from "./types.js"; import { isSafeComponentPath } from "./component-paths.js"; -import type { SerializedObject } from "@sentry/dotagents-lib"; import { isString, isStringArray } from "../../utils/type-guards.js"; type ComponentManifestKey = diff --git a/packages/dotagents/src/plugins/runtime/marketplace.ts b/packages/dotagents/src/plugins/runtime/marketplace.ts index 48208e2c..61679d8d 100644 --- a/packages/dotagents/src/plugins/runtime/marketplace.ts +++ b/packages/dotagents/src/plugins/runtime/marketplace.ts @@ -3,10 +3,9 @@ import { join, relative } from "node:path"; import type { PluginDeclaration } from "../types.js"; import { selectedAgentIds } from "../targets.js"; import { stableJson } from "../managed-files.js"; -import { legacyManifestString, manifestString } from "./manifest-values.js"; +import { legacyManifestString, manifestString, type SerializedObject } from "@sentry/dotagents-lib"; import type { RuntimeOutput } from "./types.js"; import { normalizePluginRuntimeLayout, type PluginRuntimeRoot } from "./layout.js"; -import type { SerializedObject } from "@sentry/dotagents-lib"; /** Lists managed plugin marketplace files that may be generated or pruned. */ export function marketplaceOutputPaths(root: PluginRuntimeRoot): string[] { diff --git a/packages/dotagents/src/plugins/runtime/opencode-mcp.ts b/packages/dotagents/src/plugins/runtime/opencode-mcp.ts index c875f3be..60f547d0 100644 --- a/packages/dotagents/src/plugins/runtime/opencode-mcp.ts +++ b/packages/dotagents/src/plugins/runtime/opencode-mcp.ts @@ -3,7 +3,7 @@ import { lstat, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises import { join, resolve } from "node:path"; import { reconcileManagedMcpConfig } from "../../targets/mcp-writer.js"; import type { McpDeclaration } from "../../targets/types.js"; -import { isStandardPluginManifest, type PluginMcpConfig } from "../schema.js"; +import { isStandardPluginManifest, type PluginMcpConfig } from "@sentry/dotagents-lib"; import { selectedAgentIds } from "../targets.js"; import type { PluginDeclaration } from "../types.js"; import type { LoadedStandardMcp } from "./manifests.js"; diff --git a/packages/dotagents/src/plugins/runtime/writer.test.ts b/packages/dotagents/src/plugins/runtime/writer.test.ts index a8b191bd..a439a7c7 100644 --- a/packages/dotagents/src/plugins/runtime/writer.test.ts +++ b/packages/dotagents/src/plugins/runtime/writer.test.ts @@ -8,9 +8,12 @@ import type { PluginDeclaration } from "../types.js"; import { AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA, + isSerializedObject, isStandardPluginManifest, type LegacyPluginManifest, -} from "../schema.js"; + type SerializedObject, + type SerializedValue, +} from "@sentry/dotagents-lib"; import { prunePluginOutputs, projectedPiSkillNames, @@ -18,7 +21,6 @@ import { verifyPluginOutputs, writePluginOutputs, } from "./writer.js"; -import { isSerializedObject, type SerializedObject, type SerializedValue } from "@sentry/dotagents-lib"; function parseJsonObject(content: string): SerializedObject { const parsed = JSON.parse(content); diff --git a/packages/dotagents/src/plugins/runtime/writer.ts b/packages/dotagents/src/plugins/runtime/writer.ts index 12ae05b5..2677c4bc 100644 --- a/packages/dotagents/src/plugins/runtime/writer.ts +++ b/packages/dotagents/src/plugins/runtime/writer.ts @@ -1,12 +1,18 @@ import { existsSync } from "node:fs"; import { cp, lstat, mkdir, readdir, readFile, readlink, realpath, rm, rmdir, stat, symlink, writeFile } from "node:fs/promises"; import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { loadSkillMd, type SerializedObject } from "@sentry/dotagents-lib"; -import { AGENT_PLUGIN_SCHEMA, isStandardPluginManifest, parsePluginMcp, type LegacyPluginManifest } from "../schema.js"; +import { + AGENT_PLUGIN_SCHEMA, + HYBRID_LEGACY_ROOTS, + isStandardPluginManifest, + loadSkillMd, + parsePluginMcp, + type LegacyPluginManifest, + type SerializedObject, +} from "@sentry/dotagents-lib"; import { DOTAGENTS_NATIVE_FALLBACKS_MARKER, hasRecordedNativePluginFallback, - HYBRID_LEGACY_ROOTS, preparePluginForTargets, } from "../store.js"; import type { PluginDeclaration } from "../types.js"; diff --git a/packages/dotagents/src/plugins/store.test.ts b/packages/dotagents/src/plugins/store.test.ts index 837711d7..765e7723 100644 --- a/packages/dotagents/src/plugins/store.test.ts +++ b/packages/dotagents/src/plugins/store.test.ts @@ -4,7 +4,11 @@ import { dirname, join, relative } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; import { + AGENT_PLUGIN_MCP_SCHEMA, + AGENT_PLUGIN_SCHEMA, discoverPlugins, +} from "@sentry/dotagents-lib"; +import { installPluginBundle, isSameProjectPluginConfig, loadInstalledPlugins, @@ -13,7 +17,6 @@ import { resolvePlugin, type ResolvedPlugin, } from "./store.js"; -import { AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA } from "./schema.js"; async function removeWithBackupFailure( path: Parameters[0], diff --git a/packages/dotagents/src/plugins/store.ts b/packages/dotagents/src/plugins/store.ts index eaa032fb..602b0d3f 100644 --- a/packages/dotagents/src/plugins/store.ts +++ b/packages/dotagents/src/plugins/store.ts @@ -1,63 +1,37 @@ import { existsSync } from "node:fs"; -import { lstat, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; +import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { - applyDefaultRepositorySource, + assertPluginBundleSymlinksContained, copyDir, - ensureCached, - isSerializedObject, - isSourceExcluded, + HYBRID_LEGACY_ROOTS, + isStandardPluginManifest, + loadInstalledPluginBundle, + NATIVE_PLUGIN_MANIFEST_PATHS, + NATIVE_PLUGIN_SOURCES, + nativePluginDisplayName, parseSource, - resolveLocalSource, - sanitizeCacheKey, - validateTrustedSource, - type RepositorySource, - type TrustPolicy, - type CacheReuse, - isSerializedValue, - type SerializedObject, - type SerializedValue, + resolvePlugin as resolvePluginSource, + type AuthoredNativePluginInterfaces, + type InstalledPluginProvenance, + type NativePluginSource, + type PluginManifest, + type PluginResolveOptions, + type ResolvedPlugin as ResolvedPluginSource, } from "@sentry/dotagents-lib"; -import { PLUGIN_NAME_PATTERN, type PluginConfig } from "../config/schema.js"; +import type { PluginConfig } from "../config/schema.js"; import type { LockedPlugin } from "../lockfile/schema.js"; -import { - parsePluginManifest, - parsePluginMcpBestEffort, - parsePluginMarketplace, - isStandardPluginManifest, - type MarketplacePluginEntry, - type LegacyPluginManifest, - type PluginManifest, - type PluginMcpParseResult, -} from "./schema.js"; -import { - generatedNativeMcpPath, - nativeInterfaceNeedsFallback, - selectedAgentIds, -} from "./targets.js"; -import { codexPluginInterface } from "./runtime/manifest-values.js"; -import type { - AuthoredNativePluginInterfaces, - NativePluginSource, - PluginDeclaration, -} from "./types.js"; +import { selectedAgentIds } from "./targets.js"; +import type { PluginDeclaration } from "./types.js"; import { hasErrorCode, isString } from "../utils/type-guards.js"; -// Owns plugin source discovery and installation into the canonical project tree. -// Resolved sources are never allowed to live inside the same project's -// `.agents/plugins` tree because installs replace managed destination dirs. -export interface PluginResolveOptions { - stateDir: string; - projectRoot: string; - defaultRepositorySource?: RepositorySource; - minimumReleaseAge?: number; - minimumReleaseAgeExclude?: string[]; - trust?: TrustPolicy; - /** Exact git checkout acquired earlier in the current operation. */ - reuse?: CacheReuse; - services?: Partial; -} +// Owns installation into the canonical project tree. Source discovery and +// resolution live in @sentry/dotagents-lib. Resolved sources are never allowed +// to live inside the same project's `.agents/plugins` tree because installs +// replace managed destination dirs. + +export type ResolvedPlugin = ResolvedPluginSource; export interface PluginStoreServices { realpath(path: string): Promise; @@ -67,107 +41,14 @@ export interface PluginStoreServices { const DEFAULT_PLUGIN_STORE_SERVICES: PluginStoreServices = { realpath, rename, rm }; -interface ResolvedLocalPlugin { - type: "local"; - plugin: PluginDeclaration; -} - -interface ResolvedGitPlugin { - type: "git"; - resolvedUrl: string; - resolvedPath: string; - resolvedRef?: string; - commit: string; - plugin: PluginDeclaration; -} - -export type ResolvedPlugin = ResolvedLocalPlugin | ResolvedGitPlugin; - -type PluginCandidateOrigin = "explicit" | "root" | "marketplace" | "canonical" | "conventional"; - -export interface PluginCandidate { - name: string; - dir: string; - path: string; - manifest: PluginManifest; - authoredNativeInterfaces: AuthoredNativePluginInterfaces; - legacyRoots: string[]; - nativeSource?: NativePluginSource; - origin: PluginCandidateOrigin; -} - -interface PluginCatalog { - candidates: PluginCandidate[]; - unsupportedMarketplaceSources: Map; - marketplaceOutcomes: Map>; - issues: Array<{ - name?: string; - path?: string; - origin: Exclude; - error: Error; - blocksNamedResolution: boolean; - }>; -} - -const MARKETPLACE_PATHS = [ - ".agents/plugins/marketplace.json", - "marketplace.json", - ".claude-plugin/marketplace.json", - ".cursor-plugin/marketplace.json", - ".codex-plugin/marketplace.json", - ".plugin/marketplace.json", - ".github/plugin/marketplace.json", -] as const; - -const COPILOT_MARKETPLACE_PATH = ".github/plugin/marketplace.json"; -const COPILOT_HIGHER_PRIORITY_MARKETPLACES = [ - "marketplace.json", - ".plugin/marketplace.json", -] as const; - -const FALLBACK_MANIFEST_PATHS: ReadonlyArray<{ path: string; nativeSource?: NativePluginSource }> = [ - { path: ".codex-plugin/plugin.json", nativeSource: "codex" }, - { path: ".claude-plugin/plugin.json", nativeSource: "claude" }, - { path: ".cursor-plugin/plugin.json", nativeSource: "cursor" }, - { path: ".plugin/plugin.json" }, - { path: ".github/plugin/plugin.json" }, -] as const; - const COPILOT_GITHUB_MANIFEST_PATH = ".github/plugin/plugin.json"; -const NATIVE_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" }, -] as const; - -export const HYBRID_LEGACY_ROOTS = [ - ".agents", - ".claude", - ".cursor", - ".codex", - ".opencode", - "agents", - "apps", - "bin", - "commands", - "rules", - "hooks", - "monitors", - ".mcp.json", - ".lsp.json", - ".app.json", -] as const; - export const DOTAGENTS_MANAGED_PLUGIN_MARKER = ".dotagents-managed"; + export const DOTAGENTS_NATIVE_FALLBACKS_MARKER = ".dotagents-native-fallbacks"; + const DOTAGENTS_NATIVE_SOURCE_MARKER = ".dotagents-native-source"; + const COPILOT_UNSUPPORTED_LEGACY_FIELDS = [ "agents", "commands", @@ -175,9 +56,11 @@ const COPILOT_UNSUPPORTED_LEGACY_FIELDS = [ "lspServers", "extensions", ] as const; + const COPILOT_UNSUPPORTED_STANDARD_RESOURCE_PATHS = [ "com.github.copilot", ] as const; + const COPILOT_UNSUPPORTED_LEGACY_RESOURCE_PATHS = [ ...COPILOT_UNSUPPORTED_STANDARD_RESOURCE_PATHS, "agents", @@ -196,62 +79,8 @@ export async function resolvePlugin( config: PluginConfig, opts: PluginResolveOptions, ): Promise { - const services = { ...DEFAULT_PLUGIN_STORE_SERVICES, ...opts.services }; - const sourceForResolve = applyDefaultRepositorySource( - config.source, - opts.defaultRepositorySource, - ); - if (opts.trust) {validateTrustedSource(sourceForResolve, opts.trust);} - - const parsed = parseSource(sourceForResolve); - - if (parsed.type === "local") { - const sourceDir = await resolveLocalSource(opts.projectRoot, parsed.path!); - const discovered = await resolvePluginCandidate(sourceDir, config, services.realpath); - if (!discovered) { - throw new Error(`Plugin "${config.name}" not found in ${config.source}.`); - } - return { - type: "local", - plugin: toDeclaration(config, discovered), - }; - } - - if (parsed.type === "well-known") { - throw new Error( - `HTTPS well-known sources are not supported for plugins. Use a git: URL, GitHub/GitLab repository, or path: source for "${config.name}".`, - ); - } - - const url = parsed.url!; - const cloneUrl = parsed.cloneUrl ?? url; - const ref = config.ref ?? parsed.ref; - const cacheKey = parsed.type === "github" - ? `${parsed.owner}/${parsed.repo}` - : sanitizeCacheKey(url); - const excluded = isSourceExcluded(config.source, opts.minimumReleaseAgeExclude); - const cached = await ensureCached({ - stateDir: opts.stateDir, - url: cloneUrl, - cacheKey, - ref, - minimumReleaseAge: excluded ? undefined : opts.minimumReleaseAge, - reuse: opts.reuse, - }); - - const discovered = await resolvePluginCandidate(cached.repoDir, config, services.realpath); - if (!discovered) { - throw new Error(`Plugin "${config.name}" not found in ${config.source}.`); - } - - return { - type: "git", - resolvedUrl: cloneUrl, - resolvedPath: discovered.path, - resolvedRef: ref, - commit: cached.commit, - plugin: toDeclaration(config, discovered), - }; + const resolved = await resolvePluginSource(config, opts); + return { ...resolved, plugin: toDeclaration(resolved.plugin, config.targets) }; } /** Copies a resolved plugin bundle into `.agents/plugins//` safely. */ @@ -330,21 +159,15 @@ export async function loadInstalledPlugins( continue; } try { - await assertInsideSourceRoot(pluginsDir, pluginDir, "Installed plugin"); - await assertPluginBundleSymlinksContained(pluginDir); - const loaded = await loadPluginInterfaces(pluginDir, true); - if (!loaded) { - throw new Error("Plugin bundle has no plugin.json or supported native manifest"); - } - const manifest = loaded.manifest; - assertPluginName(config.name, manifest, pluginDir); + const loaded = await loadInstalledPluginBundle( + pluginsDir, + pluginDir, + config.name, + readInstalledPluginProvenance, + ); plugins.push(preparePluginForTargets({ - name: config.name, + ...loaded, source: config.source, - pluginDir, - manifest: normalizeManifest(config.name, manifest), - authoredNativeInterfaces: loaded.authoredNativeInterfaces, - nativeSource: loaded.nativeSource, targets: config.targets, }, agentIds)); } catch (err) { @@ -356,39 +179,6 @@ export async function loadInstalledPlugins( return { plugins, issues }; } -async function assertPluginBundleSymlinksContained(pluginDir: string): Promise { - const rootRealPath = await realpath(pluginDir); - - const visit = async (dirPath: string): Promise => { - for (const entry of await readdir(dirPath, { withFileTypes: true })) { - const entryPath = join(dirPath, entry.name); - if (entry.isSymbolicLink()) { - let targetRealPath: string; - try { - targetRealPath = await realpath(entryPath); - } catch (err) { - throw new Error( - `Plugin bundle contains an invalid symlink: ${relativePath(pluginDir, entryPath)}`, - { cause: err }, - ); - } - const targetRelativePath = relative(rootRealPath, targetRealPath); - if (isOutsideRelativePath(targetRelativePath)) { - throw new Error( - `Plugin bundle symlink resolves outside the plugin directory: ${relativePath(pluginDir, entryPath)}`, - ); - } - continue; - } - if (entry.isDirectory()) { - await visit(entryPath); - } - } - }; - - await visit(pluginDir); -} - /** Removes installed plugin bundles by lockfile name using path-safe names only. */ export async function pruneInstalledPlugins( pluginsDir: string, @@ -482,581 +272,6 @@ export function isSameProjectPluginConfig( } } -async function resolvePluginCandidate( - sourceDir: string, - config: PluginConfig, - resolveRealpath: PluginStoreServices["realpath"] = realpath, -): Promise { - if (config.path) { - const dir = await resolveInside(sourceDir, config.path, "Plugin path", resolveRealpath); - let candidate = await loadPluginCandidate( - sourceDir, - dir, - { name: config.name }, - "Plugin source", - "explicit", - ); - if (!candidate) { - const marketplace = await discoverFromMarketplaces(sourceDir, { name: config.name, dir }); - const outcome = marketplace.outcomes.get(config.name)?.[0]; - if (outcome && "error" in outcome) {throw outcome.error;} - candidate = outcome?.candidate ?? null; - } - if (candidate) {await assertPluginBundleSymlinksContained(candidate.dir);} - return candidate; - } - - const catalog = await discoverPluginCatalog(sourceDir); - const candidate = resolveNamedPluginCandidate(catalog, config); - if (candidate) {await assertPluginBundleSymlinksContained(candidate.dir);} - return candidate; -} - -function resolveNamedPluginCandidate( - catalog: PluginCatalog, - config: Pick, -): PluginCandidate | null { - const { candidates } = catalog; - const canonicalPath = posix.join(".agents", "plugins", config.name); - const canonical = candidates.find((candidate) => candidate.path === canonicalPath); - if (canonical && candidateMatches(config.name, canonical)) { - return canonical; - } - - const canonicalIssue = catalog.issues.find( - (issue) => issue.origin === "canonical" && issue.path === canonicalPath, - ); - if (canonicalIssue) {throw canonicalIssue.error;} - - const namedRootIssue = catalog.issues.find( - (issue) => issue.origin === "root" && issue.name === config.name, - ); - if (namedRootIssue) {throw namedRootIssue.error;} - - const marketplaceOutcome = catalog.marketplaceOutcomes.get(config.name)?.[0]; - if (marketplaceOutcome && "error" in marketplaceOutcome) { - throw marketplaceOutcome.error; - } - if (marketplaceOutcome) {return marketplaceOutcome.candidate;} - - const conventionalIssue = catalog.issues.find( - (issue) => issue.origin === "conventional" && - issue.blocksNamedResolution && issue.name === config.name, - ); - if (conventionalIssue) {throw conventionalIssue.error;} - - const unique = rankedCandidates( - config.name, - candidates.filter((candidate) => candidate.origin !== "marketplace"), - ); - if (unique.length > 1) { - throw new Error( - `Plugin "${config.name}" is ambiguous in ${config.source}: ${unique.map((m) => m.path).join(", ")}`, - ); - } - const unsupportedMarketplaceSources = catalog.unsupportedMarketplaceSources.get(config.name) ?? []; - if (unique.length === 0 && unsupportedMarketplaceSources.length > 0) { - throw new Error( - `Plugin "${config.name}" not found in ${config.source}. Matching marketplace entries use unsupported source types: ${[...new Set(unsupportedMarketplaceSources)].join(", ")}. Only local marketplace sources are supported.`, - ); - } - const rootIssue = catalog.issues.find((issue) => issue.origin === "root"); - if (unique.length === 0 && rootIssue) {throw rootIssue.error;} - return unique[0] ?? null; -} - -/** Discovers valid plugins from supported source locations. */ -export async function discoverPlugins( - sourceDir: string, - requestedNames?: string[], -): Promise { - const catalog = await discoverPluginCatalog(sourceDir); - let candidates = catalog.candidates; - if (requestedNames?.length) { - const requested: PluginCandidate[] = []; - for (const name of requestedNames) { - const candidate = resolveNamedPluginCandidate(catalog, { name, source: sourceDir }); - if (candidate) {requested.push(candidate);} - } - if (requested.length > 0) { - candidates = await dedupeCandidates(requested); - } - } else if (catalog.issues[0]) { - throw catalog.issues[0].error; - } - for (const candidate of candidates) { - await assertPluginBundleSymlinksContained(candidate.dir); - } - return candidates; -} - -async function discoverPluginCatalog(sourceDir: string): Promise { - const candidates: PluginCandidate[] = []; - const issues: PluginCatalog["issues"] = []; - - try { - const root = await loadPluginCandidate(sourceDir, sourceDir, {}, "Plugin source", "root"); - if (root) {candidates.push(root);} - } catch (err) { - issues.push({ - name: err instanceof NamedPluginManifestError ? err.pluginName : undefined, - path: "", - origin: "root", - error: err instanceof Error ? err : new Error(String(err)), - blocksNamedResolution: false, - }); - } - - const marketplace = await discoverFromMarketplaces(sourceDir); - candidates.push(...marketplace.candidates); - issues.push(...marketplace.issues); - candidates.push(...await scanPluginDirectories( - sourceDir, - join(sourceDir, ".agents", "plugins"), - 2, - "Canonical plugin source", - "canonical", - issues, - marketplace.referencedDirs, - )); - candidates.push(...await scanPluginDirectories( - sourceDir, - join(sourceDir, "plugins"), - 2, - "Plugin source", - "conventional", - issues, - marketplace.referencedDirs, - )); - - return { - candidates: await dedupeCandidates(candidates), - unsupportedMarketplaceSources: marketplace.unsupportedSources, - marketplaceOutcomes: marketplace.outcomes, - issues, - }; -} - -/** - * Reads marketplace selector files and resolves only explicit local sources. - * Relative paths prefer the repository root used by current Claude - * marketplaces, then fall back to the marketplace file directory for legacy - * catalogs. Both forms remain contained by the source root. - */ -async function discoverFromMarketplaces( - sourceDir: string, - selection?: { name: string; dir: string }, -): Promise<{ - candidates: PluginCandidate[]; - unsupportedSources: Map; - outcomes: PluginCatalog["marketplaceOutcomes"]; - issues: PluginCatalog["issues"]; - referencedDirs: Set; -}> { - const candidates: PluginCandidate[] = []; - const unsupportedSources = new Map(); - const outcomes: PluginCatalog["marketplaceOutcomes"] = new Map(); - const issues: PluginCatalog["issues"] = []; - const referencedDirs = new Set(); - for (const marketplacePath of MARKETPLACE_PATHS) { - // The Copilot-specific .github locator is invisible whenever either - // higher-priority native locator exists. Other generic discovery inputs - // retain their existing union behavior. - if ( - marketplacePath === COPILOT_MARKETPLACE_PATH && - COPILOT_HIGHER_PRIORITY_MARKETPLACES.some((path) => existsSync(join(sourceDir, path))) - ) { - continue; - } - const filePath = join(sourceDir, marketplacePath); - if (!existsSync(filePath)) {continue;} - - let marketplace: ReturnType; - try { - marketplace = parsePluginMarketplace(await readJson(filePath), filePath); - } catch (err) { - issues.push({ - origin: "marketplace", - error: err instanceof Error ? err : new Error(String(err)), - blocksNamedResolution: false, - }); - continue; - } - const root = isString(marketplace.metadata?.pluginRoot) - ? marketplace.metadata.pluginRoot - : "."; - for (const entry of marketplace.plugins) { - if (selection && entry.name !== selection.name) {continue;} - const path = localMarketplacePath(entry); - if (path === null) { - const sources = unsupportedSources.get(entry.name) ?? []; - sources.push(marketplaceSourceType(entry)); - unsupportedSources.set(entry.name, sources); - continue; - } - const marketplaceRoot = dirname(filePath); - const anchors = [...new Set([sourceDir, marketplaceRoot])].filter( - (anchor) => !selection || resolve(anchor, root, path) === selection.dir, - ); - if (anchors.length === 0) {continue;} - let pluginDir: string | undefined; - let candidate: PluginCandidate | null; - try { - candidate = null; - const resolutionErrors: Error[] = []; - for (const anchor of anchors) { - let resolvedDir: string; - try { - resolvedDir = await resolveMarketplaceSource( - sourceDir, - anchor, - join(root, path), - "Marketplace plugin source", - ); - } catch (err) { - resolutionErrors.push(err instanceof Error ? err : new Error(String(err))); - continue; - } - const resolvedCandidate = await loadPluginCandidate( - sourceDir, - resolvedDir, - marketplaceManifestOverlay(entry), - "Marketplace plugin source", - "marketplace", - { entry, filePath, nativeSource: NATIVE_MANIFEST_PATHS.find( - (native) => dirname(native.path) === dirname(marketplacePath), - )?.source }, - ); - if (!resolvedCandidate) {continue;} - pluginDir = resolvedDir; - candidate = resolvedCandidate; - break; - } - if (!candidate && resolutionErrors.length === anchors.length) { - throw resolutionErrors[0]!; - } - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - issues.push({ - name: entry.name, - origin: "marketplace", - error, - blocksNamedResolution: true, - }); - const namedOutcomes = outcomes.get(entry.name) ?? []; - namedOutcomes.push({ error }); - outcomes.set(entry.name, namedOutcomes); - continue; - } - if (!candidate) { - const error = new Error( - `Marketplace plugin "${entry.name}" in ${filePath} has no supported plugin manifest at ${path || "."}.`, - ); - issues.push({ - name: entry.name, - origin: "marketplace", - error, - blocksNamedResolution: true, - }); - const namedOutcomes = outcomes.get(entry.name) ?? []; - namedOutcomes.push({ error }); - outcomes.set(entry.name, namedOutcomes); - continue; - } - referencedDirs.add(resolve(pluginDir!)); - candidates.push(candidate); - const namedOutcomes = outcomes.get(entry.name) ?? []; - namedOutcomes.push({ candidate }); - outcomes.set(entry.name, namedOutcomes); - } - } - return { candidates, unsupportedSources, outcomes, issues, referencedDirs }; -} - -async function scanPluginDirectories( - sourceRoot: string, - dir: string, - depth = 2, - label = "Plugin source", - origin: PluginCandidateOrigin = "conventional", - issues: PluginCatalog["issues"] = [], - skippedDirs: ReadonlySet = new Set(), -): Promise { - if (depth <= 0 || !await isDirectory(dir)) {return [];} - - const matches: PluginCandidate[] = []; - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if ((!entry.isDirectory() && !entry.isSymbolicLink()) || entry.name.startsWith(".")) {continue;} - const childDir = join(dir, entry.name); - if (skippedDirs.has(resolve(childDir))) {continue;} - let candidate: PluginCandidate | null; - try { - candidate = await loadPluginCandidate(sourceRoot, childDir, {}, label, origin); - } catch (err) { - issues.push({ - name: entry.name, - path: relativePath(sourceRoot, childDir), - origin: origin === "canonical" ? "canonical" : "conventional", - error: err instanceof Error ? err : new Error(String(err)), - blocksNamedResolution: true, - }); - candidate = null; - } - if (candidate) { - matches.push(candidate); - continue; - } - matches.push(...await scanPluginDirectories( - sourceRoot, - childDir, - depth - 1, - label, - origin, - issues, - skippedDirs, - )); - } - return matches; -} - -async function isDirectory(path: string): Promise { - try { - return (await stat(path)).isDirectory(); - } catch (err) { - if (isNotFoundError(err)) {return false;} - throw err; - } -} - -async function loadPluginCandidate( - sourceRoot: string, - pluginDir: string, - overlay: Partial = {}, - label = "Plugin source", - origin: PluginCandidateOrigin = "root", - marketplace?: { entry: MarketplacePluginEntry; filePath: string; nativeSource?: NativePluginSource }, -): Promise { - if (!existsSync(pluginDir)) {return null;} - await assertInsideSourceRoot(sourceRoot, pluginDir, label); - - let loaded = await loadPluginInterfaces(pluginDir, false); - if (!loaded && marketplace?.entry.strict === false && await isDirectory(pluginDir)) { - const manifest = parsePluginManifest(Object.fromEntries( - Object.entries(marketplace.entry).filter(([key]) => !["source", "strict", "category", "tags", "policy"].includes(key)), - ), marketplace.filePath); - const nativeSource = marketplace.nativeSource; - loaded = { - manifest, - nativeSource, - authoredNativeInterfaces: nativeSource ? { - [nativeSource]: { path: `.${nativeSource}-plugin/plugin.json`, fallback: true, manifest }, - } : {}, - }; - } - if (!loaded) {return null;} - const manifest = loaded.manifest; - - const name = manifest && isString(manifest["name"]) - ? String(manifest["name"]) - : isString(overlay["name"]) - ? String(overlay["name"]) - : basename(pluginDir); - // SAFETY: the merged fallback only combines fields from two legacy manifests. - const combined = manifest && isStandardPluginManifest(manifest) - ? manifest - : normalizeManifest(name, { ...overlay, ...manifest } as LegacyPluginManifest); - if (!PLUGIN_NAME_PATTERN.test(name)) { - throw new Error( - `Invalid plugin name "${name}" in ${relativePath(sourceRoot, pluginDir) || "."}. ` + - "Plugin names must be 1-64 lowercase letters, numbers, hyphens, or dots, have alphanumeric ends, and not contain '--' or '..'.", - ); - } - return { - name, - dir: pluginDir, - path: relativePath(sourceRoot, pluginDir), - manifest: combined, - authoredNativeInterfaces: loaded.authoredNativeInterfaces, - legacyRoots: isStandardPluginManifest(combined) - ? HYBRID_LEGACY_ROOTS.filter((path) => existsSync(join(pluginDir, path))) - : [], - nativeSource: loaded?.nativeSource, - origin, - }; -} - -function marketplaceManifestOverlay( - entry: MarketplacePluginEntry, -): Partial { - const overlay: Partial = { name: entry.name }; - if (entry.description) {overlay.description = entry.description;} - if (entry.version) {overlay.version = entry.version;} - if (entry.category) {overlay.category = entry.category;} - return overlay; -} - -async function loadPluginInterfaces( - pluginDir: string, - installed: boolean, -): Promise<{ - manifest: PluginManifest; - authoredNativeInterfaces: AuthoredNativePluginInterfaces; - nativeSource?: NativePluginSource; -} | null> { - const provenance = installed - ? await readInstalledPluginProvenance(pluginDir) - : undefined; - const fallbackSources = provenance?.fallbackSources; - const installedNativeSource = provenance?.nativeSource; - const rootPath = join(pluginDir, "plugin.json"); - if (existsSync(rootPath)) { - const value = await readJson(rootPath); - let manifest: PluginManifest; - try { - manifest = parsePluginManifest(value, rootPath); - } catch (err) { - throw pluginManifestError(err, value); - } - return { - manifest, - authoredNativeInterfaces: await loadAuthoredNativeInterfaces( - pluginDir, - fallbackSources, - installedNativeSource, - manifest, - ), - nativeSource: installedNativeSource, - }; - } - if (installed) { - throw new Error("Installed plugin bundle is missing plugin.json. Reinstall the plugin."); - } - - for (const candidate of FALLBACK_MANIFEST_PATHS) { - const filePath = join(pluginDir, candidate.path); - if (!existsSync(filePath)) {continue;} - const value = await readJson(filePath); - let manifest: PluginManifest; - try { - manifest = parsePluginManifest(value, filePath); - } catch (err) { - throw pluginManifestError(err, value); - } - return { - manifest, - authoredNativeInterfaces: await loadAuthoredNativeInterfaces(pluginDir), - nativeSource: candidate.nativeSource, - }; - } - return null; -} - -async function loadAuthoredNativeInterfaces( - pluginDir: string, - fallbackSources?: ReadonlySet | null, - installedNativeSource?: NativePluginSource, - portableManifest?: PluginManifest, -): Promise { - // undefined classifies source input; null ignores installed adapters; a Set reloads recorded fallbacks. - const interfaces: AuthoredNativePluginInterfaces = {}; - const portableMcp = fallbackSources === undefined && portableManifest && - isStandardPluginManifest(portableManifest) - ? await loadPortableMcpForGeneration(pluginDir) - : undefined; - for (const candidate of NATIVE_MANIFEST_PATHS) { - const filePath = join(pluginDir, candidate.path); - if (!existsSync(filePath)) { - if (fallbackSources?.has(candidate.source)) { - throw new Error( - `Installed plugin records a ${nativeDisplayName(candidate.source)} native fallback, but ${candidate.path} is missing. Reinstall the plugin.`, - ); - } - continue; - } - if (fallbackSources && !fallbackSources.has(candidate.source)) {continue;} - if (fallbackSources === null && installedNativeSource !== candidate.source) {continue;} - let fallback = true; - try { - const value = await readJson(filePath); - const manifest = parsePluginManifest(value, filePath); - if (fallbackSources === undefined && portableManifest) { - const reproducibleFields: SerializedObject = {}; - const mcpPath = await reproducibleNativeMcpPath( - pluginDir, - candidate.source, - portableMcp, - ); - if (mcpPath) {reproducibleFields["mcpServers"] = mcpPath;} - if (candidate.source === "codex" && portableManifest && isString(portableManifest.name)) { - reproducibleFields["interface"] = codexPluginInterface( - portableManifest.name, - portableManifest, - ); - } - fallback = nativeInterfaceNeedsFallback(candidate.source, value, reproducibleFields); - } - interfaces[candidate.source] = { - path: candidate.path, - fallback, - manifest, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - interfaces[candidate.source] = { - path: candidate.path, - fallback, - error: message.replaceAll(filePath, candidate.path), - }; - } - } - return interfaces; -} - -async function reproducibleNativeMcpPath( - pluginDir: string, - source: NativePluginSource, - portableMcp: PluginMcpParseResult | undefined, -): Promise { - const mcpPath = generatedNativeMcpPath(source, portableMcp); - if (!mcpPath || mcpPath === "./mcp.json") {return mcpPath;} - const adapterPath = join(pluginDir, mcpPath); - if (!existsSync(adapterPath)) {return mcpPath;} - try { - return isDeepStrictEqual(await readJson(adapterPath), portableMcp?.config) - ? mcpPath - : undefined; - } catch { - return undefined; - } -} - -async function loadPortableMcpForGeneration( - pluginDir: string, -): Promise { - const filePath = join(pluginDir, "mcp.json"); - if (!existsSync(filePath)) {return undefined;} - try { - return parsePluginMcpBestEffort(await readJson(filePath), filePath); - } catch { - return undefined; - } -} - -class NamedPluginManifestError extends Error { - constructor(message: string, readonly pluginName?: string) { - super(message); - } -} - -function pluginManifestError(err: Thrown, value: SerializedValue): Error { - const message = err instanceof Error ? err.message : String(err); - const name = isSerializedObject(value) && isString(value["name"]) - ? value["name"] - : undefined; - return new NamedPluginManifestError(message, name); -} - async function removeSourceOwnershipMarkers(dir: string): Promise { // Source-controlled markers never establish dotagents ownership or provenance. for (const entry of await readdir(dir, { withFileTypes: true })) { @@ -1075,7 +290,7 @@ async function removeSourceOwnershipMarkers(dir: string): Promise { } async function removeRedundantNativeInterfaces(plugin: PluginDeclaration): Promise { - for (const candidate of NATIVE_MANIFEST_PATHS) { + for (const candidate of NATIVE_PLUGIN_MANIFEST_PATHS) { const nativeInterface = plugin.authoredNativeInterfaces?.[candidate.source]; if (!nativeInterface || nativeInterface.fallback) {continue;} await rm(join(plugin.pluginDir, candidate.path), { force: true }); @@ -1122,7 +337,7 @@ async function writeNativeSourceMarker(plugin: PluginDeclaration): Promise async function writeNativeFallbacksMarker(plugin: PluginDeclaration): Promise { const filePath = join(plugin.pluginDir, DOTAGENTS_NATIVE_FALLBACKS_MARKER); - const sources = nativeSources().filter( + const sources = NATIVE_PLUGIN_SOURCES.filter( (source) => plugin.authoredNativeInterfaces?.[source]?.fallback, ); if (sources.length === 0) { @@ -1146,7 +361,7 @@ async function readNativeFallbackSources( const values = content.split("\n").filter(Boolean); const sources = new Set(); for (const value of values) { - const source = nativeSources().find((candidate) => candidate === value); + const source = NATIVE_PLUGIN_SOURCES.find((candidate) => candidate === value); if (!source || sources.has(source)) { throw new Error(`Invalid native fallback provenance: ${value || ""}`); } @@ -1158,10 +373,7 @@ async function readNativeFallbackSources( return sources; } -async function readInstalledPluginProvenance(pluginDir: string): Promise<{ - fallbackSources: Set | null; - nativeSource: NativePluginSource | undefined; -}> { +async function readInstalledPluginProvenance(pluginDir: string): Promise { const fallbackSources = await readNativeFallbackSources(pluginDir); const nativeSource = await readNativeSourceMarker(pluginDir); if (fallbackSources && nativeSource && !fallbackSources.has(nativeSource)) { @@ -1192,25 +404,19 @@ async function readNativeSourceMarker(pluginDir: string): Promise existsSync(join(plugin.pluginDir, path))) - : []; return { ...plugin, compatibilityWarnings: compatibilityWarnings( plugin.name, plugin.manifest, interfaces, - legacyRoots, + legacyRootsFor(plugin), selectedTargets, ), }; } +function legacyRootsFor(plugin: Pick): string[] { + return isStandardPluginManifest(plugin.manifest) + ? HYBRID_LEGACY_ROOTS.filter((path) => existsSync(join(plugin.pluginDir, path))) + : []; +} + function assertNativeInterfaceNames( expected: string, interfaces: AuthoredNativePluginInterfaces, context: string, selectedTargets: ReadonlySet, ): void { - for (const source of nativeSources()) { + for (const source of NATIVE_PLUGIN_SOURCES) { const nativeInterface = interfaces[source]; if (!nativeInterface?.fallback) {continue;} const manifest = nativeInterface.manifest; @@ -1321,9 +530,9 @@ function nativeInterfaceNameIssue( const actual = manifest["name"]; if (actual === expected) {return undefined;} if (isString(actual)) { - return `${nativeDisplayName(source)} native fallback manifest name "${actual}" does not match portable plugin name "${expected}"`; + return `${nativePluginDisplayName(source)} native fallback manifest name "${actual}" does not match portable plugin name "${expected}"`; } - return `${nativeDisplayName(source)} native fallback manifest is missing the portable plugin name "${expected}"`; + return `${nativePluginDisplayName(source)} native fallback manifest is missing the portable plugin name "${expected}"`; } function compatibilityWarnings( @@ -1335,14 +544,14 @@ function compatibilityWarnings( ): string[] { if (!isStandardPluginManifest(manifest)) {return [];} const warnings: string[] = []; - const sources = nativeSources().filter((source) => interfaces[source] !== undefined); + const sources = NATIVE_PLUGIN_SOURCES.filter((source) => interfaces[source] !== undefined); const fallbackSources = sources.filter((source) => interfaces[source]?.fallback); const validFallbackSources = fallbackSources.filter( (source) => interfaces[source]?.manifest !== undefined, ); if (fallbackSources.length > 0) { warnings.push( - `Plugin "${name}" is a hybrid compatibility bundle: the portable core remains authoritative; authored ${validFallbackSources.map(nativeDisplayName).join(", ") || "native"} interfaces are retained only as matching-client fallbacks.`, + `Plugin "${name}" is a hybrid compatibility bundle: the portable core remains authoritative; authored ${validFallbackSources.map(nativePluginDisplayName).join(", ") || "native"} interfaces are retained only as matching-client fallbacks.`, ); } else if (sources.length > 0) { warnings.push( @@ -1367,13 +576,13 @@ function compatibilityWarnings( const nativeInterface = interfaces[source]!; if (!nativeInterface.fallback) { warnings.push( - `Plugin "${name}" has an authored ${nativeDisplayName(source)} interface that was ignored because the portable core can generate the ${nativeDisplayName(source)} adapter${nativeInterface.error ? `: ${nativeInterface.error}` : "."}`, + `Plugin "${name}" has an authored ${nativePluginDisplayName(source)} interface that was ignored because the portable core can generate the ${nativePluginDisplayName(source)} adapter${nativeInterface.error ? `: ${nativeInterface.error}` : "."}`, ); continue; } if (nativeInterface.error && !selected.has(source)) { warnings.push( - `Plugin "${name}" has a malformed ${nativeDisplayName(source)} native fallback that was ignored because ${nativeDisplayName(source)} is not selected: ${nativeInterface.error}`, + `Plugin "${name}" has a malformed ${nativePluginDisplayName(source)} native fallback that was ignored because ${nativePluginDisplayName(source)} is not selected: ${nativeInterface.error}`, ); continue; } @@ -1382,13 +591,13 @@ function compatibilityWarnings( : undefined; if (nameIssue && !selected.has(source)) { warnings.push( - `Plugin "${name}" has an invalid ${nativeDisplayName(source)} native fallback that was ignored because ${nativeDisplayName(source)} is not selected: ${nameIssue}.`, + `Plugin "${name}" has an invalid ${nativePluginDisplayName(source)} native fallback that was ignored because ${nativePluginDisplayName(source)} is not selected: ${nameIssue}.`, ); continue; } if (nativeInterface.manifest && metadataDiffers(manifest, nativeInterface.manifest)) { warnings.push( - `Plugin "${name}" has differing portable and ${nativeDisplayName(source)} fallback metadata; the portable core remains the source of truth while ${nativeDisplayName(source)} receives the preserved fallback.`, + `Plugin "${name}" has differing portable and ${nativePluginDisplayName(source)} fallback metadata; the portable core remains the source of truth while ${nativePluginDisplayName(source)} receives the preserved fallback.`, ); } } @@ -1408,146 +617,6 @@ function metadataDiffers(portable: PluginManifest, native: PluginManifest): bool return keys.some((key) => !isDeepStrictEqual(portable[key], native[key])); } -function nativeSources(): NativePluginSource[] { - return ["claude", "cursor", "codex"]; -} - -function nativeDisplayName(source: NativePluginSource): string { - return source === "claude" ? "Claude" : source === "cursor" ? "Cursor" : "Codex"; -} - -function assertPluginName( - expected: string, - manifest: PluginManifest, - context: string, -): void { - const actual = manifest["name"]; - if (isString(actual) && actual !== expected) { - throw new Error(`Plugin manifest name "${actual}" does not match configured name "${expected}" in ${context}.`); - } -} - -function normalizeManifest( - name: string, - manifest: PluginManifest, -): PluginManifest { - // SAFETY: replacing the required name field preserves every PluginManifest variant. - return { ...manifest, name } as PluginManifest; -} - -function candidateMatches(name: string, candidate: PluginCandidate): boolean { - return basename(candidate.dir) === name || candidate.name === name; -} - -/** Applies discovery precedence: directory-name matches win before manifest-name fallback. */ -function rankedCandidates(name: string, candidates: PluginCandidate[]): PluginCandidate[] { - const directoryMatches = candidates.filter((candidate) => basename(candidate.dir) === name); - return directoryMatches.length > 0 - ? directoryMatches - : candidates.filter((candidate) => candidate.name === name); -} - -async function dedupeCandidates(candidates: PluginCandidate[]): Promise { - const seen = new Set(); - const result: PluginCandidate[] = []; - for (const candidate of candidates) { - const key = `${await realpath(candidate.dir)}\0${candidate.name}`; - if (seen.has(key)) {continue;} - seen.add(key); - result.push(candidate); - } - return result; -} - -/** Returns local marketplace paths; unsupported extension sources are skipped. */ -function localMarketplacePath(entry: MarketplacePluginEntry): string | null { - const { source } = entry; - if (isString(source)) {return stripDotSlash(source);} - - if (source.source === "local" && isString(source.path)) { - return stripDotSlash(source.path); - } - return null; -} - -function marketplaceSourceType(entry: MarketplacePluginEntry): string { - const { source } = entry; - if (isString(source)) {return "local";} - return source.source ?? "extension"; -} - -function stripDotSlash(path: string): string { - return path.replace(/^\.\//, ""); -} - -/** Resolves a selector path while preserving the source-root containment boundary. */ -async function resolveInside( - root: string, - childPath: string, - label: string, - resolveRealpath: PluginStoreServices["realpath"] = realpath, -): Promise { - const rootPath = resolve(root); - const filePath = resolve(rootPath, childPath); - const relPath = relative(rootPath, filePath); - if (isOutsideRelativePath(relPath)) { - throw new Error(`${label} resolves outside source: ${childPath}`); - } - if (existsSync(filePath)) { - await assertInsideSourceRoot(rootPath, filePath, label, childPath, resolveRealpath); - } - return filePath; -} - -async function resolveMarketplaceSource( - sourceRoot: string, - marketplaceRoot: string, - childPath: string, - label: string, -): Promise { - const filePath = resolve(marketplaceRoot, childPath); - const sourceRootPath = resolve(sourceRoot); - const relPath = relative(sourceRootPath, filePath); - if (isOutsideRelativePath(relPath)) { - throw new Error(`${label} resolves outside source: ${childPath}`); - } - if (existsSync(filePath)) { - await assertInsideSourceRoot(sourceRootPath, filePath, label, childPath); - } - return filePath; -} - -async function assertInsideSourceRoot( - root: string, - filePath: string, - label: string, - displayPath = relativePath(root, filePath), - resolveRealpath: PluginStoreServices["realpath"] = realpath, -): Promise { - let rootRealPath: string; - try { - rootRealPath = await resolveRealpath(root); - } catch (err) { - if (isNotFoundError(err)) { - throw new Error(`${label} source root does not exist: ${displayPath}`, { cause: err }); - } - throw err; - } - let fileRealPath: string; - try { - fileRealPath = await resolveRealpath(filePath); - } catch (err) { - if (isNotFoundError(err)) { - throw new Error(`${label} source path does not exist: ${displayPath}`, { cause: err }); - } - throw err; - } - const realRelPath = relative(rootRealPath, fileRealPath); - if (isOutsideRelativePath(realRelPath)) { - throw new Error(`${label} resolves outside source: ${displayPath}`); - } -} - function isNotFoundError(err: ErrorValue): boolean { return hasErrorCode(err, "ENOENT"); } @@ -1561,10 +630,6 @@ function managedPluginPath(pluginsDir: string, name: string): string | null { return pluginPath; } -function relativePath(root: string, filePath: string): string { - return relative(root, filePath).split("\\").join("/"); -} - function isOutsideRelativePath(path: string): boolean { return path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path); } @@ -1572,18 +637,3 @@ function isOutsideRelativePath(path: string): boolean { function isNotDirectoryError(err: ErrorValue): boolean { return hasErrorCode(err, "ENOTDIR"); } - -async function readJson(filePath: string): Promise { - const raw = await readFile(filePath, "utf-8"); - let parsed; - try { - parsed = JSON.parse(raw); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid JSON ${filePath}: ${message}`, { cause: err }); - } - if (!isSerializedValue(parsed)) { - throw new Error(`Invalid JSON ${filePath}: value is not safely serializable`); - } - return parsed; -} diff --git a/packages/dotagents/src/plugins/targets.ts b/packages/dotagents/src/plugins/targets.ts index d5fed0c9..14811b30 100644 --- a/packages/dotagents/src/plugins/targets.ts +++ b/packages/dotagents/src/plugins/targets.ts @@ -1,18 +1,10 @@ -import { isDeepStrictEqual } from "node:util"; -import type { NativePluginSource, PluginDeclaration } from "./types.js"; +import { isStandardPluginManifest, type NativePluginSource } from "@sentry/dotagents-lib"; +import type { PluginDeclaration } from "./types.js"; import type { PluginWriteWarning } from "./runtime/types.js"; -import { isStandardPluginManifest, type PluginMcpConfig } from "./schema.js"; -import { isSerializedObject, type SerializedObject, type SerializedValue } from "@sentry/dotagents-lib"; -import { isString } from "../utils/type-guards.js"; const PLUGIN_ONLY_AGENT_IDS = ["grok"]; const PLUGIN_AGENT_IDS = ["claude", "copilot", "cursor", "codex", "grok", "opencode", "pi"]; const SUPPORTED_PLUGIN_AGENT_IDS = new Set(allPluginAgentIds()); -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>; /** Returns agent IDs accepted in agents.toml only for plugin runtime output. */ export function allPluginOnlyAgentIds(): string[] { @@ -41,36 +33,6 @@ export function selectedAgentIds( .filter((target) => SUPPORTED_PLUGIN_AGENT_IDS.has(target)); } -/** 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; -} - /** Returns whether the matching-client manifest is authored rather than generated. */ export function hasAuthoredNativeInterface( plugin: Pick, @@ -80,12 +42,6 @@ export function hasAuthoredNativeInterface( plugin.authoredNativeInterfaces?.[source]?.fallback === true; } -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"; -} - /** Keeps native legacy components with their owning client while generalized legacy bundles remain portable. */ export function usesLegacyPluginComponents( plugin: Pick, diff --git a/packages/dotagents/src/plugins/types.ts b/packages/dotagents/src/plugins/types.ts index 3a56d431..a5bcf797 100644 --- a/packages/dotagents/src/plugins/types.ts +++ b/packages/dotagents/src/plugins/types.ts @@ -1,22 +1,7 @@ -import type { PluginManifest } from "./schema.js"; +import type { PluginBundle } from "@sentry/dotagents-lib"; -export type NativePluginSource = "claude" | "cursor" | "codex"; - -export type AuthoredNativePluginInterface = - | { path: string; fallback: boolean; manifest: PluginManifest; error?: never } - | { path: string; fallback: boolean; manifest?: never; error: string }; - -export type AuthoredNativePluginInterfaces = Partial< - Record ->; - -export interface PluginDeclaration { - name: string; - source: string; - pluginDir: string; - manifest: PluginManifest; - authoredNativeInterfaces?: AuthoredNativePluginInterfaces; +/** A resolved or installed plugin plus the dotagents target selection for it. */ +export interface PluginDeclaration extends PluginBundle { compatibilityWarnings?: string[]; - nativeSource?: NativePluginSource; targets?: string[]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cdd067a..58a63c8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: yaml: specifier: ^2.8.3 version: 2.8.3 + zod: + specifier: ^4.3.6 + version: 4.3.6 packages: diff --git a/scripts/verify-pack.mjs b/scripts/verify-pack.mjs index 88122742..dd1c0558 100755 --- a/scripts/verify-pack.mjs +++ b/scripts/verify-pack.mjs @@ -69,26 +69,38 @@ try { rmSync(inspectDir, { recursive: true, force: true }); } -// (c) Real install + CLI smoke from the tarballs. +// (c) Real lib-only consumer and CLI smoke from the tarballs. const installDir = mkdtempSync(join(tmpdir(), "dotagents-install-")); try { - // Use a separate npm registry-like layout: install both tarballs as local files. + // Use a separate npm registry-like layout with no workspace packages. writeFileSync( join(installDir, "package.json"), JSON.stringify({ name: "verify-pack-tmp", version: "0.0.0", private: true }, null, 2), ); execFileSync( "npm", - ["install", "--no-audit", "--no-fund", "--no-package-lock", join(cwd, lib), join(cwd, host)], + ["install", "--no-audit", "--no-fund", "--no-package-lock", join(cwd, lib)], { cwd: installDir, stdio: "inherit" }, ); + // Exercise the public lib API before the host package is installed. + writeFileSync( + join(installDir, "verify-plugin-lib.mjs"), + readFileSync(join(cwd, "scripts", "verify-plugin-lib.mjs")), + ); + execFileSync("node", ["verify-plugin-lib.mjs"], { cwd: installDir, stdio: "inherit" }); + + execFileSync( + "npm", + ["install", "--no-audit", "--no-fund", "--no-package-lock", join(cwd, lib), join(cwd, host)], + { cwd: installDir, stdio: "inherit" }, + ); execFileSync("node", ["node_modules/@sentry/dotagents/dist/cli/index.js", "--help"], { cwd: installDir, stdio: "inherit", }); - console.log("verify-pack: install + CLI smoke OK"); + console.log("verify-pack: standalone lib consumer + CLI smoke OK"); } finally { rmSync(installDir, { recursive: true, force: true }); } diff --git a/scripts/verify-plugin-lib.mjs b/scripts/verify-plugin-lib.mjs new file mode 100644 index 00000000..06fad8d2 --- /dev/null +++ b/scripts/verify-plugin-lib.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AGENT_PLUGIN_SCHEMA, + discoverAllSkills, + discoverPlugins, + resolvePlugin, +} from "@sentry/dotagents-lib"; + +const root = await mkdtemp(join(tmpdir(), "dotagents-plugin-consumer-")); +try { + 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", + })); + await writeFile(join(pluginDir, "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review changes\n---\n"); + + const resolved = await resolvePlugin( + { name: "review-tools", source: "path:source" }, + { projectRoot: root, stateDir: join(root, "state") }, + ); + assert.equal(resolved.plugin.pluginDir, pluginDir); + assert.equal(resolved.plugin.manifest.name, "review-tools"); + assert.deepEqual( + (await discoverAllSkills(join(resolved.plugin.pluginDir, "skills"), { scanDirs: [] })) + .filter(({ path }) => path !== "." && !path.includes("/")) + .map(({ meta }) => meta.name), + ["review"], + ); + assert.deepEqual(await discoverPlugins(join(root, "source"), ["missing"]), []); + console.log("verify-pack: external plugin resolver consumer OK"); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/specs/SPEC.md b/specs/SPEC.md index 172711b7..b5f3baa2 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -6,7 +6,7 @@ dotagents is shared tooling for coding agents. It manages agent skill dependenci Declare what you need, run `dotagents install` for global state or `dotagents --project install` for repository-local state, and skills appear in the selected scope with integrations for each configured tool. MCP, hook, subagent, and plugin configs are generated per agent. -> **Implementation note.** The skill-loading, source-fetching, and trust-validation primitives that drive the CLI are factored into a separate npm package, [`@sentry/dotagents-lib`](../packages/dotagents-lib/), versioned in lock-step with `@sentry/dotagents`. The `agents.toml` grammar and the `.agents/` convention described below remain entirely the host's responsibility — the lib only knows about source strings, SKILL.md, and the cache. +> **Implementation note.** The skill and Agent Plugin resolvers, source-fetching, and trust-validation primitives that drive the CLI live in [`@sentry/dotagents-lib`](../packages/dotagents-lib/), versioned in lock-step with `@sentry/dotagents`. The `agents.toml` grammar, installation lifecycle, and `.agents/` convention remain the host's responsibility. ### Why @@ -937,6 +937,7 @@ dotagents/ src/ index.ts # Library public API skills/ # SKILL.md loader, discovery, resolver + plugins/ # Plugin manifests, discovery, resolution sources/ # git, cache, local, well-known source handling trust/ # Trust policy validation utils/ # exec and filesystem helpers