@rbbtsn0w/adg 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/bin/adg.ts +758 -0
  4. package/docs/agents-spec.md +132 -0
  5. package/docs/authoring.md +352 -0
  6. package/package.json +50 -0
  7. package/schemas/adg-plugin.schema.json +77 -0
  8. package/schemas/marketplace.schema.json +86 -0
  9. package/schemas/plugin-lock.schema.json +90 -0
  10. package/src/adapters/anthropic.ts +54 -0
  11. package/src/adapters/index.ts +24 -0
  12. package/src/adapters/openai.ts +37 -0
  13. package/src/adapters/reverse.ts +60 -0
  14. package/src/agents/claude.ts +124 -0
  15. package/src/agents/codex.ts +67 -0
  16. package/src/agents/index.ts +12 -0
  17. package/src/agents/registry.ts +30 -0
  18. package/src/agents/types.ts +47 -0
  19. package/src/commands/adapt.ts +36 -0
  20. package/src/commands/import.ts +69 -0
  21. package/src/commands/init.ts +146 -0
  22. package/src/commands/install.ts +411 -0
  23. package/src/commands/link.ts +61 -0
  24. package/src/commands/list.ts +28 -0
  25. package/src/commands/marketplace.ts +198 -0
  26. package/src/commands/migrate.ts +84 -0
  27. package/src/commands/multiselect-skills.ts +137 -0
  28. package/src/commands/remove.ts +136 -0
  29. package/src/commands/select-agents.ts +45 -0
  30. package/src/commands/select-components.ts +66 -0
  31. package/src/commands/select-plugins.ts +28 -0
  32. package/src/commands/select-scope.ts +21 -0
  33. package/src/commands/update.ts +85 -0
  34. package/src/commands/validate.ts +57 -0
  35. package/src/components.ts +90 -0
  36. package/src/deps.ts +64 -0
  37. package/src/fsutil.ts +38 -0
  38. package/src/hash.ts +61 -0
  39. package/src/lock.ts +57 -0
  40. package/src/manifest.ts +113 -0
  41. package/src/marketplace.ts +41 -0
  42. package/src/package.ts +74 -0
  43. package/src/paths.ts +129 -0
  44. package/src/semver.ts +67 -0
  45. package/src/skills.ts +88 -0
  46. package/src/sources.ts +159 -0
  47. package/src/types.ts +140 -0
  48. package/vendor/skills/LICENSE +29 -0
  49. package/vendor/skills/PROVENANCE.md +60 -0
  50. package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
  51. package/vendor/skills/package.json +143 -0
  52. package/vendor/skills/src/add.ts +1999 -0
  53. package/vendor/skills/src/agents.ts +755 -0
  54. package/vendor/skills/src/blob.ts +567 -0
  55. package/vendor/skills/src/cli.ts +387 -0
  56. package/vendor/skills/src/constants.ts +3 -0
  57. package/vendor/skills/src/detect-agent.ts +62 -0
  58. package/vendor/skills/src/find.ts +357 -0
  59. package/vendor/skills/src/frontmatter.ts +16 -0
  60. package/vendor/skills/src/git-tree.ts +36 -0
  61. package/vendor/skills/src/git.ts +277 -0
  62. package/vendor/skills/src/install.ts +91 -0
  63. package/vendor/skills/src/installer.ts +1097 -0
  64. package/vendor/skills/src/list.ts +231 -0
  65. package/vendor/skills/src/local-lock.ts +182 -0
  66. package/vendor/skills/src/plugin-manifest.ts +183 -0
  67. package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
  68. package/vendor/skills/src/providers/index.ts +14 -0
  69. package/vendor/skills/src/providers/registry.ts +51 -0
  70. package/vendor/skills/src/providers/types.ts +97 -0
  71. package/vendor/skills/src/providers/wellknown.ts +804 -0
  72. package/vendor/skills/src/remove.ts +323 -0
  73. package/vendor/skills/src/sanitize.ts +65 -0
  74. package/vendor/skills/src/self-cli.ts +20 -0
  75. package/vendor/skills/src/skill-lock.ts +329 -0
  76. package/vendor/skills/src/skills.ts +316 -0
  77. package/vendor/skills/src/source-parser.ts +438 -0
  78. package/vendor/skills/src/sync.ts +478 -0
  79. package/vendor/skills/src/telemetry.ts +186 -0
  80. package/vendor/skills/src/test-utils.ts +73 -0
  81. package/vendor/skills/src/types.ts +128 -0
  82. package/vendor/skills/src/update-source.ts +90 -0
  83. package/vendor/skills/src/update.ts +749 -0
  84. package/vendor/skills/src/use.ts +675 -0
@@ -0,0 +1,69 @@
1
+ import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { ADG_MANIFEST_PATH } from "../manifest.ts";
5
+ import { writeJson, writeText } from "../fsutil.ts";
6
+ import { installPlugin, type InstallResult } from "./install.ts";
7
+ import { ADG_SCHEMA_VERSION, type AdgManifest } from "../types.ts";
8
+
9
+ export interface ImportSkillsOptions {
10
+ /** Directory holding flat <name>/SKILL.md skill folders. */
11
+ skillsDir: string;
12
+ /** Name of the synthesized plugin. */
13
+ as: string;
14
+ /** Only include skills whose folder name starts with this prefix. */
15
+ prefix?: string;
16
+ pluginsDir: string;
17
+ version?: string;
18
+ description?: string;
19
+ marketplaceName?: string;
20
+ now?: string;
21
+ }
22
+
23
+ /**
24
+ * Wrap a flat directory of `<name>/SKILL.md` skills into a single ADG plugin and
25
+ * install it. Skill folders are copied verbatim under the new plugin's skills/.
26
+ */
27
+ export function importSkills(opts: ImportSkillsOptions): InstallResult {
28
+ const src = resolve(opts.skillsDir);
29
+ const names = readdirSync(src, { withFileTypes: true })
30
+ .filter((e) => e.isDirectory() && existsSync(join(src, e.name, "SKILL.md")))
31
+ .map((e) => e.name)
32
+ .filter((name) => !opts.prefix || name.startsWith(opts.prefix))
33
+ .sort();
34
+
35
+ if (names.length === 0) {
36
+ throw new Error(`no SKILL.md skills found in ${src}${opts.prefix ? ` with prefix "${opts.prefix}"` : ""}`);
37
+ }
38
+
39
+ const staging = mkdtempSync(join(tmpdir(), "adg-skills-"));
40
+ try {
41
+ const manifest: AdgManifest = {
42
+ schemaVersion: ADG_SCHEMA_VERSION,
43
+ name: opts.as,
44
+ version: opts.version ?? "0.1.0",
45
+ description: opts.description ?? `Imported skills bundle (${names.length}).`,
46
+ skills: "./skills/",
47
+ strict: false,
48
+ };
49
+ writeJson(join(staging, ADG_MANIFEST_PATH), manifest);
50
+ for (const name of names) {
51
+ copySkill(join(src, name), join(staging, "skills", name));
52
+ }
53
+ writeText(join(staging, "README.md"), `# ${opts.as}\n\n${manifest.description}\n`);
54
+
55
+ return installPlugin({
56
+ source: staging,
57
+ pluginsDir: opts.pluginsDir,
58
+ origin: { type: "local", path: `./${opts.as}` },
59
+ marketplaceName: opts.marketplaceName,
60
+ now: opts.now,
61
+ });
62
+ } finally {
63
+ rmSync(staging, { recursive: true, force: true });
64
+ }
65
+ }
66
+
67
+ function copySkill(from: string, to: string): void {
68
+ cpSync(from, to, { recursive: true });
69
+ }
@@ -0,0 +1,146 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ADG_MANIFEST_PATH, ADG_MARKETPLACE_PATH } from "../manifest.ts";
4
+ import { writeJson, writeText } from "../fsutil.ts";
5
+ import { ADG_SCHEMA_VERSION, type AdgManifest, type Marketplace } from "../types.ts";
6
+
7
+ /**
8
+ * Authoring scenario — what `.agents/` artifact to scaffold. Vendor projections
9
+ * (.claude-plugin / .codex-plugin) are never produced here: they are a
10
+ * consumption/publish concern, generated at install time or via explicit
11
+ * `adapt`.
12
+ */
13
+ export type InitType = "plugin" | "marketplace" | "all";
14
+
15
+ export interface InitOptions {
16
+ name: string;
17
+ dir: string;
18
+ description?: string;
19
+ author?: string;
20
+ skill?: string;
21
+ /** Which `.agents/` artifact(s) to scaffold. Default: "plugin". */
22
+ type?: InitType;
23
+ }
24
+
25
+ export interface InitResult {
26
+ /** The root directory created for this scaffold. */
27
+ pluginDir: string;
28
+ created: string[];
29
+ }
30
+
31
+ const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
32
+
33
+ /**
34
+ * Dispatch on the authoring scenario: a plugin (`.agents/.plugin.json`), a
35
+ * marketplace catalog (`.agents/.marketplace.json`), or `all` — a catalog root
36
+ * with one starter member plugin in a subdirectory.
37
+ */
38
+ export function initScaffold(opts: InitOptions): InitResult {
39
+ switch (opts.type ?? "plugin") {
40
+ case "plugin":
41
+ return initPlugin(opts);
42
+ case "marketplace":
43
+ return initMarketplace(opts);
44
+ case "all":
45
+ return initAll(opts);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Scaffold a new ADG plugin directory: .agents/.plugin.json, a starter
51
+ * skill (SKILL.md) and README.md.
52
+ */
53
+ export function initPlugin(opts: InitOptions): InitResult {
54
+ if (!NAME_RE.test(opts.name)) {
55
+ throw new Error(`plugin name must be kebab-case, got "${opts.name}"`);
56
+ }
57
+ const pluginDir = join(opts.dir, opts.name);
58
+ const manifestFile = join(pluginDir, ADG_MANIFEST_PATH);
59
+ if (existsSync(manifestFile)) {
60
+ throw new Error(`${manifestFile} already exists; refusing to overwrite`);
61
+ }
62
+
63
+ const skillName = opts.skill ?? "getting-started";
64
+ const manifest: AdgManifest = {
65
+ schemaVersion: ADG_SCHEMA_VERSION,
66
+ name: opts.name,
67
+ version: "0.1.0",
68
+ description: opts.description ?? `${opts.name} plugin.`,
69
+ author: { name: opts.author ?? "Agent Directory Group" },
70
+ license: "Apache-2.0",
71
+ skills: "./skills/",
72
+ strict: true,
73
+ };
74
+
75
+ const created: string[] = [];
76
+
77
+ writeJson(manifestFile, manifest);
78
+ created.push(manifestFile);
79
+
80
+ const skillFile = join(pluginDir, "skills", skillName, "SKILL.md");
81
+ writeText(
82
+ skillFile,
83
+ `---\nname: ${skillName}\ndescription: Describe when this skill should trigger.\n---\n\n# ${skillName}\n\nDocument the skill's behavior here.\n`,
84
+ );
85
+ created.push(skillFile);
86
+
87
+ const readme = join(pluginDir, "README.md");
88
+ writeText(readme, `# ${opts.name}\n\n${manifest.description}\n`);
89
+ created.push(readme);
90
+
91
+ return { pluginDir, created };
92
+ }
93
+
94
+ /**
95
+ * Scaffold a marketplace catalog: .agents/.marketplace.json (empty member list)
96
+ * and README.md. Members are added later, or scaffold them with `--type all`.
97
+ */
98
+ export function initMarketplace(opts: InitOptions): InitResult {
99
+ if (!NAME_RE.test(opts.name)) {
100
+ throw new Error(`marketplace name must be kebab-case, got "${opts.name}"`);
101
+ }
102
+ const catalogDir = join(opts.dir, opts.name);
103
+ const catalogFile = join(catalogDir, ADG_MARKETPLACE_PATH);
104
+ if (existsSync(catalogFile)) {
105
+ throw new Error(`${catalogFile} already exists; refusing to overwrite`);
106
+ }
107
+
108
+ const description = opts.description ?? `${opts.name} marketplace.`;
109
+ const catalog: Marketplace = {
110
+ name: opts.name,
111
+ description,
112
+ ...(opts.author ? { owner: { name: opts.author } } : {}),
113
+ plugins: [],
114
+ };
115
+
116
+ const created: string[] = [];
117
+ writeJson(catalogFile, catalog);
118
+ created.push(catalogFile);
119
+
120
+ const readme = join(catalogDir, "README.md");
121
+ writeText(readme, `# ${opts.name}\n\n${description}\n`);
122
+ created.push(readme);
123
+
124
+ return { pluginDir: catalogDir, created };
125
+ }
126
+
127
+ /**
128
+ * Scaffold both: a catalog root plus one starter member plugin in a
129
+ * subdirectory (a plugin and a marketplace cannot share one `.agents/` dir, so
130
+ * the member lives under `<name>/<name>/`). The catalog lists the member.
131
+ */
132
+ function initAll(opts: InitOptions): InitResult {
133
+ const market = initMarketplace(opts);
134
+ const member = initPlugin({ ...opts, dir: join(opts.dir, opts.name) });
135
+ // Link the starter member into the catalog (source relative to the catalog's
136
+ // grandparent, i.e. the repo root that holds `.agents/`).
137
+ const catalogFile = join(opts.dir, opts.name, ADG_MARKETPLACE_PATH);
138
+ const catalog: Marketplace = {
139
+ name: opts.name,
140
+ description: opts.description ?? `${opts.name} marketplace.`,
141
+ ...(opts.author ? { owner: { name: opts.author } } : {}),
142
+ plugins: [{ name: opts.name, source: { source: "local", path: `./${opts.name}` } }],
143
+ };
144
+ writeJson(catalogFile, catalog);
145
+ return { pluginDir: join(opts.dir, opts.name), created: [...market.created, ...member.created] };
146
+ }
@@ -0,0 +1,411 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, join, relative, resolve } from "node:path";
4
+ import { ADAPTER_TARGETS, type AdapterTarget } from "../adapters/index.ts";
5
+ import { fromNativeManifest } from "../adapters/reverse.ts";
6
+ import { adaptPlugin } from "./adapt.ts";
7
+ import { copyPluginDir, writeJson } from "../fsutil.ts";
8
+ import { folderHash } from "../hash.ts";
9
+ import { packageFilter } from "../package.ts";
10
+ import { lockPath, marketplacePath, marketplaceSourcePath, pluginDir } from "../paths.ts";
11
+ import { readLock, upsertEntry, writeLock } from "../lock.ts";
12
+ import { ADG_MANIFEST_PATH, readManifest } from "../manifest.ts";
13
+ import { readMarketplace, upsertMarketplacePlugin, writeMarketplace } from "../marketplace.ts";
14
+ import { resolveInstallOrder, type PluginCandidate } from "../deps.ts";
15
+ import { cloneGitHub, parseSource, scanNativePlugins, scanPlugins, type GitRunner } from "../sources.ts";
16
+ import { sameSource, COMPONENT_TYPES, type ComponentType, type LockEntry, type PluginSelection, type PluginSource } from "../types.ts";
17
+ import { pluginContents, presentComponents } from "../components.ts";
18
+ import { skillDescriptionLoader } from "../skills.ts";
19
+ import { resolveAgents, type Agent, type AgentScope, type AgentSyncResult } from "../agents/index.ts";
20
+
21
+ export interface InstallOneOptions {
22
+ /** Local directory containing the plugin (already fetched). */
23
+ source: string;
24
+ /** Destination plugins directory. */
25
+ pluginsDir: string;
26
+ /** Upstream provenance recorded in the lock; defaults to local copy-in. */
27
+ origin?: PluginSource;
28
+ marketplaceName?: string;
29
+ targets?: AdapterTarget[];
30
+ now?: string;
31
+ /**
32
+ * Partial-install selection narrowing what the generated manifests expose.
33
+ * When omitted, a prior lock entry's selection is reused (so it survives
34
+ * re-installs / upgrades); absent on both = expose everything.
35
+ */
36
+ selection?: PluginSelection;
37
+ }
38
+
39
+ export interface InstallResult {
40
+ name: string;
41
+ installedTo: string;
42
+ folderHash: string;
43
+ adapted: string[];
44
+ }
45
+
46
+ const HASH_IGNORE = [".claude-plugin", ".codex-plugin"];
47
+
48
+ function toPosix(p: string): string {
49
+ return p.split("\\").join("/");
50
+ }
51
+
52
+ /**
53
+ * Install a single local plugin directory into a plugins directory: copy the
54
+ * source, generate adapter manifests, compute the folder hash, and update both
55
+ * .plugin-lock.json and marketplace.json (with denormalized discovery metadata
56
+ * and an integrity digest).
57
+ *
58
+ * Refuses to overwrite a same-named plugin that came from a different upstream
59
+ * source (cross-marketplace name collision). Only files under `pluginsDir` are
60
+ * written — sibling files such as AGENTS.md or a global skills/ are untouched.
61
+ */
62
+ export function installPlugin(opts: InstallOneOptions): InstallResult {
63
+ const source = resolve(opts.source);
64
+ const manifest = readManifest(source);
65
+ const name = manifest.name;
66
+ // Local installs stay flat; remote sources derive a per-marketplace dir from
67
+ // their origin. The default (no origin) is a flat local copy-in.
68
+ const origin: PluginSource =
69
+ opts.origin ?? { type: "local", path: `./${toPosix(name)}` };
70
+ const dest = pluginDir(opts.pluginsDir, name, origin);
71
+
72
+ // When the source already lives at the destination (e.g. adapting an
73
+ // in-repo reference plugin) skip the copy to avoid copying onto itself.
74
+ // Only the manifest-declared payload is copied (projections included) — dev
75
+ // cruft like src/ or test/ never ships.
76
+ if (resolve(dest) !== source) {
77
+ copyPluginDir(source, dest, packageFilter(manifest, { includeProjections: true }));
78
+ }
79
+
80
+ const lockFile = lockPath(opts.pluginsDir);
81
+ const lock = readLock(lockFile);
82
+ const prev = lock.plugins[name];
83
+ if (prev && !sameSource(prev.origin, origin)) {
84
+ throw new Error(
85
+ `name collision: "${name}" is already installed from a different source ` +
86
+ `(${describe(prev.origin)} vs ${describe(origin)}). Rename one to avoid the conflict.`,
87
+ );
88
+ }
89
+
90
+ // A new selection wins; otherwise keep whatever a prior install recorded so
91
+ // partial installs survive re-install / `marketplace upgrade`.
92
+ const selection = opts.selection ?? prev?.selection;
93
+
94
+ const targets = opts.targets ?? [...ADAPTER_TARGETS];
95
+ const adapted = adaptPlugin(dest, targets, selection).map((r) => r.file);
96
+
97
+ const hash = folderHash(dest, HASH_IGNORE, packageFilter(manifest, { includeProjections: false }));
98
+
99
+ const entry: Omit<LockEntry, "installedAt" | "updatedAt"> = {
100
+ origin,
101
+ version: manifest.version,
102
+ folderHash: hash,
103
+ };
104
+ if (manifest.dependencies?.length) {
105
+ entry.dependencies = Object.fromEntries(manifest.dependencies.map((d) => [d.name, d.version]));
106
+ }
107
+ if (selection) entry.selection = selection;
108
+ upsertEntry(lock, name, entry, opts.now);
109
+ writeLock(lockFile, lock);
110
+
111
+ const marketFile = marketplacePath(opts.pluginsDir);
112
+ const fallbackName = opts.marketplaceName ?? basename(opts.pluginsDir);
113
+ const market = readMarketplace(marketFile, fallbackName);
114
+ // marketplace.json is a pure runtime-facing export in the de-facto shape;
115
+ // version/integrity/provenance live in the lock, not here.
116
+ upsertMarketplacePlugin(market, {
117
+ name,
118
+ source: { source: "local", path: marketplaceSourcePath(opts.pluginsDir, dest) },
119
+ policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
120
+ ...(manifest.category ? { category: manifest.category } : {}),
121
+ });
122
+ writeMarketplace(marketFile, market);
123
+
124
+ return { name, installedTo: dest, folderHash: hash, adapted };
125
+ }
126
+
127
+ function describe(s: PluginSource): string {
128
+ switch (s.type) {
129
+ case "local": return `local:${s.path}`;
130
+ case "github": return `github:${s.repo}${s.path ? `/${s.path}` : ""}`;
131
+ case "git": return `git:${s.url}${s.path ? `/${s.path}` : ""}`;
132
+ }
133
+ }
134
+
135
+ /** One installable plugin discovered in a source, shown to an interactive picker. */
136
+ export interface PluginChoice {
137
+ name: string;
138
+ description: string;
139
+ /** True when reverse-adapted from a native Claude/Codex manifest. */
140
+ native: boolean;
141
+ }
142
+
143
+ /** What an interactive component picker is shown for one plugin. */
144
+ export interface SelectComponentsRequest {
145
+ name: string;
146
+ /** Member names per category (skills/agents/commands/…). */
147
+ contents: import("../components.ts").PluginContents;
148
+ /** Categories the plugin actually has (non-empty). */
149
+ present: ComponentType[];
150
+ /** Lazy, cached `description` lookup for a skill (drives the on-demand toggle). */
151
+ skillDescription?: (name: string) => string | undefined;
152
+ }
153
+
154
+ export interface AddOptions {
155
+ /** Local path or owner/repo[@ref] / github URL. */
156
+ spec: string;
157
+ pluginsDir: string;
158
+ /** Override the ref parsed from the spec. */
159
+ ref?: string;
160
+ /** Restrict a GitHub checkout to these sub-paths (sparse checkout). */
161
+ sparse?: string[];
162
+ /** Injectable git clone runner (for offline testing). */
163
+ gitRunner?: GitRunner;
164
+
165
+ // ── selection (a source may hold one plugin or a whole marketplace) ──
166
+ /** Install every plugin found in the source. */
167
+ all?: boolean;
168
+ /** Install only these plugin names. */
169
+ plugins?: string[];
170
+ /** Install the single plugin at this sub-path. */
171
+ path?: string;
172
+ /** Resolve and install transitive plugin dependencies. Default true. */
173
+ withDeps?: boolean;
174
+ /**
175
+ * Interactive picker, used only when the source holds multiple plugins and
176
+ * none of all/plugins/path narrowed the selection. Returns chosen names.
177
+ */
178
+ selectPlugins?: (choices: PluginChoice[]) => Promise<string[]> | string[];
179
+
180
+ targets?: AdapterTarget[];
181
+ /**
182
+ * Resolve adapter targets after plugins are chosen (so an interactive agent
183
+ * picker runs second, once the user knows what they're installing). Ignored
184
+ * when `targets` is set.
185
+ */
186
+ selectTargets?: () => Promise<AdapterTarget[]> | AdapterTarget[];
187
+ marketplaceName?: string;
188
+ now?: string;
189
+
190
+ // ── partial install: narrow which component categories / skills are exposed ──
191
+ /** Non-interactive: expose only these component categories. */
192
+ only?: ComponentType[];
193
+ /** Non-interactive: expose only these skill names (implies skills selected). */
194
+ skillsSubset?: string[];
195
+ /**
196
+ * Interactive gate (the "install everything?" question). Returning false
197
+ * drops into per-plugin component selection. Skipped when only/skillsSubset
198
+ * are set. Applies only to the user-chosen plugins, not auto-deps.
199
+ */
200
+ confirmFull?: (plugins: string[]) => Promise<boolean> | boolean;
201
+ /** Interactive per-plugin component picker; returns the selection to expose. */
202
+ selectComponents?: (req: SelectComponentsRequest) => Promise<PluginSelection> | PluginSelection;
203
+
204
+ /**
205
+ * After installing, make the plugins usable in the selected agents (not just
206
+ * recorded in the store) by enabling them via each agent's CLI. A no-op for an
207
+ * agent whose CLI isn't installed. Off by default (kept out of tests).
208
+ */
209
+ activate?: boolean;
210
+ /** Install scope for activation; "user" (global) or "project". Default project. */
211
+ scope?: AgentScope;
212
+ /** Injection seam for tests; defaults to the agents matching `targets`. */
213
+ agents?: Agent[];
214
+ }
215
+
216
+ export interface AddResult {
217
+ order: string[];
218
+ installed: InstallResult[];
219
+ /** Plugins reverse-adapted from a native manifest during discovery. */
220
+ converted: string[];
221
+ /** Every plugin name discovered in the source (installed or not). */
222
+ available: string[];
223
+ /** Per-agent activation outcome (when `activate` was requested). */
224
+ agents?: AgentSyncResult[];
225
+ }
226
+
227
+ /**
228
+ * Reverse-adapt any native (Claude/Codex) manifests under `root` into
229
+ * `.agents/.plugin.json`, then return every ADG plugin found. After this the
230
+ * whole source speaks ADG, so selection and install treat all plugins uniformly.
231
+ */
232
+ function discoverPlugins(root: string): { candidates: Map<string, PluginCandidate>; converted: string[] } {
233
+ const converted: string[] = [];
234
+ for (const native of scanNativePlugins(root)) {
235
+ if (native.kind === "adg") continue;
236
+ const raw = JSON.parse(readFileSync(native.manifestFile, "utf8"));
237
+ const manifest = fromNativeManifest(raw, native.kind);
238
+ writeJson(join(native.dir, ADG_MANIFEST_PATH), manifest);
239
+ converted.push(manifest.name);
240
+ }
241
+ return { candidates: scanPlugins(root), converted };
242
+ }
243
+
244
+ /** Resolve which discovered plugins to install from the selection options. */
245
+ async function selectPluginNames(
246
+ opts: AddOptions,
247
+ candidates: Map<string, PluginCandidate>,
248
+ workRoot: string,
249
+ converted: string[],
250
+ ): Promise<string[]> {
251
+ const names = [...candidates.keys()];
252
+
253
+ if (opts.plugins?.length) {
254
+ const missing = opts.plugins.filter((p) => !candidates.has(p));
255
+ if (missing.length) {
256
+ throw new Error(`plugin(s) not found in source: ${missing.join(", ")}.\nAvailable: ${names.join(", ")}`);
257
+ }
258
+ return opts.plugins;
259
+ }
260
+ if (opts.path) {
261
+ const target = resolve(join(workRoot, opts.path));
262
+ const hit = [...candidates.values()].find((c) => resolve(c.dir) === target);
263
+ if (!hit) throw new Error(`no plugin found at --path ${opts.path}`);
264
+ return [hit.manifest.name];
265
+ }
266
+ if (opts.all || candidates.size === 1) return names;
267
+
268
+ if (opts.selectPlugins) {
269
+ const convertedSet = new Set(converted);
270
+ const choices: PluginChoice[] = [...candidates.values()].map((c) => ({
271
+ name: c.manifest.name,
272
+ description: c.manifest.description,
273
+ native: convertedSet.has(c.manifest.name),
274
+ }));
275
+ return opts.selectPlugins(choices);
276
+ }
277
+
278
+ throw new Error(
279
+ `source "${opts.spec}" contains ${candidates.size} plugins: ${names.join(", ")}.\n` +
280
+ `Pick with --plugin <name> (repeatable), --all for everything, or run in a terminal to choose interactively.`,
281
+ );
282
+ }
283
+
284
+ /**
285
+ * Decide a partial-install selection for each user-chosen plugin.
286
+ *
287
+ * Precedence: explicit flags (--only / --skill) win and apply to every chosen
288
+ * plugin; otherwise an interactive gate asks whether to install in full, and if
289
+ * not, a per-plugin component picker runs (skipped for plugins with nothing
290
+ * meaningful to choose). No selection for a plugin = expose everything.
291
+ */
292
+ async function resolveSelections(
293
+ opts: AddOptions,
294
+ selected: string[],
295
+ candidates: Map<string, PluginCandidate>,
296
+ ): Promise<Map<string, PluginSelection>> {
297
+ const selections = new Map<string, PluginSelection>();
298
+
299
+ if (opts.only || opts.skillsSubset) {
300
+ const flagSelection: PluginSelection = {
301
+ components: opts.only ?? [...COMPONENT_TYPES],
302
+ ...(opts.skillsSubset ? { skills: opts.skillsSubset } : {}),
303
+ };
304
+ for (const name of selected) selections.set(name, flagSelection);
305
+ return selections;
306
+ }
307
+
308
+ if (!opts.confirmFull || !opts.selectComponents) return selections; // non-interactive default: full
309
+ if (await opts.confirmFull(selected)) return selections; // user kept everything
310
+
311
+ for (const name of selected) {
312
+ const cand = candidates.get(name)!;
313
+ const contents = pluginContents(cand.dir, cand.manifest);
314
+ const present = presentComponents(contents);
315
+ // Nothing meaningful to pick: a lone category with at most one member.
316
+ if (present.length <= 1 && contents.skills.length <= 1) continue;
317
+ const skillDescription = skillDescriptionLoader(cand.dir, cand.manifest);
318
+ selections.set(name, await opts.selectComponents({ name, contents, present, skillDescription }));
319
+ }
320
+ return selections;
321
+ }
322
+
323
+ /**
324
+ * The unified install entrypoint. Treats any source as a marketplace: clone or
325
+ * read it, discover every plugin (ADG plus reverse-adapted native), choose a
326
+ * subset (--all / --plugin / --path / sole plugin / interactive picker), then
327
+ * install the selection in dependency-first order.
328
+ */
329
+ export async function addPlugins(opts: AddOptions): Promise<AddResult> {
330
+ const parsed = parseSource(opts.spec);
331
+ let workRoot: string;
332
+ let buildOrigin: (dir: string) => PluginSource;
333
+ let cleanup: (() => void) | undefined;
334
+
335
+ if (parsed.kind === "local") {
336
+ workRoot = resolve(parsed.dir);
337
+ buildOrigin = (dir) => ({ type: "local", path: `./${toPosix(relative(workRoot, dir)) || basename(dir)}` });
338
+ } else {
339
+ const ref = opts.ref ?? parsed.ref;
340
+ const tmp = mkdtempSync(join(tmpdir(), "adg-clone-"));
341
+ cleanup = () => rmSync(tmp, { recursive: true, force: true });
342
+ cloneGitHub({ ...parsed, ref }, tmp, { sparse: opts.sparse, runner: opts.gitRunner });
343
+ workRoot = tmp;
344
+ buildOrigin = (dir) => ({
345
+ type: "github",
346
+ repo: parsed.source,
347
+ ...(ref ? { ref } : {}),
348
+ path: toPosix(relative(tmp, dir)) || ".",
349
+ });
350
+ }
351
+
352
+ try {
353
+ const { candidates, converted } = discoverPlugins(workRoot);
354
+ if (candidates.size === 0) {
355
+ throw new Error(
356
+ `no plugin found in "${opts.spec}" (no .agents/.plugin.json, .claude-plugin or .codex-plugin manifest).`,
357
+ );
358
+ }
359
+
360
+ const selected = await selectPluginNames(opts, candidates, workRoot, converted);
361
+ if (selected.length === 0) throw new Error("no plugins selected");
362
+
363
+ // Resolve adapter targets after the plugin choice (lets a CLI agent picker
364
+ // run once we know what's being installed). undefined → installPlugin's all.
365
+ const targets = opts.targets ?? (opts.selectTargets ? await opts.selectTargets() : undefined);
366
+
367
+ // Partial-install selection per user-chosen plugin (auto-deps install full).
368
+ const selections = await resolveSelections(opts, selected, candidates);
369
+
370
+ // Dependency-first order across every selected plugin (chains deduped).
371
+ const order: string[] = [];
372
+ const seen = new Set<string>();
373
+ for (const name of selected) {
374
+ const chain = opts.withDeps === false ? [name] : resolveInstallOrder(name, candidates);
375
+ for (const n of chain) {
376
+ if (!seen.has(n)) {
377
+ seen.add(n);
378
+ order.push(n);
379
+ }
380
+ }
381
+ }
382
+
383
+ const installed: InstallResult[] = [];
384
+ for (const name of order) {
385
+ const candidate = candidates.get(name)!;
386
+ installed.push(
387
+ installPlugin({
388
+ source: candidate.dir,
389
+ pluginsDir: opts.pluginsDir,
390
+ origin: buildOrigin(candidate.dir),
391
+ marketplaceName: opts.marketplaceName,
392
+ targets,
393
+ selection: selections.get(name),
394
+ now: opts.now,
395
+ }),
396
+ );
397
+ }
398
+ // Activate into the selected agents so the plugins are actually usable, not
399
+ // just recorded/discoverable — each agent enables them via its own CLI.
400
+ // undefined targets = all registered agents.
401
+ let agents: AgentSyncResult[] | undefined;
402
+ if (opts.activate) {
403
+ const ctx = { pluginsDir: opts.pluginsDir, plugins: installed.map((r) => r.name), scope: opts.scope ?? "project" };
404
+ agents = (opts.agents ?? resolveAgents(targets)).map((a) => a.activate(ctx));
405
+ }
406
+
407
+ return { order, installed, converted, available: [...candidates.keys()], agents };
408
+ } finally {
409
+ cleanup?.();
410
+ }
411
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "node:fs";
2
+ import { adaptPlugin } from "./adapt.ts";
3
+ import { listPlugins } from "./list.ts";
4
+ import { installedPluginDir } from "../paths.ts";
5
+ import { getAgent, type Agent } from "../agents/index.ts";
6
+
7
+ export type LinkTarget = "claude" | "codex";
8
+
9
+ export interface LinkOptions {
10
+ /** Source plugins directory (where installed plugins live). */
11
+ pluginsDir: string;
12
+ target: LinkTarget;
13
+ /** Use the user (global) scope instead of project scope. */
14
+ global?: boolean;
15
+ /** Injection seam for tests; defaults to the registered agent for `target`. */
16
+ agent?: Agent;
17
+ }
18
+
19
+ export interface LinkAction {
20
+ name: string;
21
+ /** Adapter manifest(s) (re)generated. */
22
+ adapted: string[];
23
+ /** The agent the plugin was enabled in, if activation succeeded. */
24
+ linkedTo?: string;
25
+ }
26
+
27
+ export interface LinkResult {
28
+ target: LinkTarget;
29
+ actions: LinkAction[];
30
+ /** True when the target agent's CLI was missing (manifests written, nothing enabled). */
31
+ cliSkipped?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Project installed plugins into an agent: (re)generate that agent's manifest
36
+ * from each plugin (honoring its partial-install selection), then enable the
37
+ * plugins through the agent's CLI. The pure manifest transform comes from
38
+ * `ADAPTERS[agent.adaptTarget]`; the enable step is the agent's `activate`.
39
+ */
40
+ export function linkPlugins(opts: LinkOptions): LinkResult {
41
+ const agent = opts.agent ?? getAgent(opts.target);
42
+ const adaptTarget = agent?.adaptTarget ?? opts.target;
43
+
44
+ const actions: LinkAction[] = [];
45
+ for (const p of listPlugins(opts.pluginsDir)) {
46
+ const dir = installedPluginDir(opts.pluginsDir, p.name, p.origin);
47
+ if (!existsSync(dir)) continue;
48
+ const adapted = adaptPlugin(dir, [adaptTarget], p.selection).map((r) => r.file);
49
+ actions.push({ name: p.name, adapted });
50
+ }
51
+
52
+ if (!agent) return { target: opts.target, actions };
53
+
54
+ const res = agent.activate({
55
+ pluginsDir: opts.pluginsDir,
56
+ plugins: actions.map((a) => a.name),
57
+ scope: opts.global ? "user" : "project",
58
+ });
59
+ for (const a of actions) if (res.affected.includes(a.name)) a.linkedTo = agent.displayName;
60
+ return { target: opts.target, actions, cliSkipped: res.skipped };
61
+ }