@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.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/bin/adg.ts +758 -0
- package/docs/agents-spec.md +132 -0
- package/docs/authoring.md +352 -0
- package/package.json +50 -0
- package/schemas/adg-plugin.schema.json +77 -0
- package/schemas/marketplace.schema.json +86 -0
- package/schemas/plugin-lock.schema.json +90 -0
- package/src/adapters/anthropic.ts +54 -0
- package/src/adapters/index.ts +24 -0
- package/src/adapters/openai.ts +37 -0
- package/src/adapters/reverse.ts +60 -0
- package/src/agents/claude.ts +124 -0
- package/src/agents/codex.ts +67 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/registry.ts +30 -0
- package/src/agents/types.ts +47 -0
- package/src/commands/adapt.ts +36 -0
- package/src/commands/import.ts +69 -0
- package/src/commands/init.ts +146 -0
- package/src/commands/install.ts +411 -0
- package/src/commands/link.ts +61 -0
- package/src/commands/list.ts +28 -0
- package/src/commands/marketplace.ts +198 -0
- package/src/commands/migrate.ts +84 -0
- package/src/commands/multiselect-skills.ts +137 -0
- package/src/commands/remove.ts +136 -0
- package/src/commands/select-agents.ts +45 -0
- package/src/commands/select-components.ts +66 -0
- package/src/commands/select-plugins.ts +28 -0
- package/src/commands/select-scope.ts +21 -0
- package/src/commands/update.ts +85 -0
- package/src/commands/validate.ts +57 -0
- package/src/components.ts +90 -0
- package/src/deps.ts +64 -0
- package/src/fsutil.ts +38 -0
- package/src/hash.ts +61 -0
- package/src/lock.ts +57 -0
- package/src/manifest.ts +113 -0
- package/src/marketplace.ts +41 -0
- package/src/package.ts +74 -0
- package/src/paths.ts +129 -0
- package/src/semver.ts +67 -0
- package/src/skills.ts +88 -0
- package/src/sources.ts +159 -0
- package/src/types.ts +140 -0
- package/vendor/skills/LICENSE +29 -0
- package/vendor/skills/PROVENANCE.md +60 -0
- package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
- package/vendor/skills/package.json +143 -0
- package/vendor/skills/src/add.ts +1999 -0
- package/vendor/skills/src/agents.ts +755 -0
- package/vendor/skills/src/blob.ts +567 -0
- package/vendor/skills/src/cli.ts +387 -0
- package/vendor/skills/src/constants.ts +3 -0
- package/vendor/skills/src/detect-agent.ts +62 -0
- package/vendor/skills/src/find.ts +357 -0
- package/vendor/skills/src/frontmatter.ts +16 -0
- package/vendor/skills/src/git-tree.ts +36 -0
- package/vendor/skills/src/git.ts +277 -0
- package/vendor/skills/src/install.ts +91 -0
- package/vendor/skills/src/installer.ts +1097 -0
- package/vendor/skills/src/list.ts +231 -0
- package/vendor/skills/src/local-lock.ts +182 -0
- package/vendor/skills/src/plugin-manifest.ts +183 -0
- package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
- package/vendor/skills/src/providers/index.ts +14 -0
- package/vendor/skills/src/providers/registry.ts +51 -0
- package/vendor/skills/src/providers/types.ts +97 -0
- package/vendor/skills/src/providers/wellknown.ts +804 -0
- package/vendor/skills/src/remove.ts +323 -0
- package/vendor/skills/src/sanitize.ts +65 -0
- package/vendor/skills/src/self-cli.ts +20 -0
- package/vendor/skills/src/skill-lock.ts +329 -0
- package/vendor/skills/src/skills.ts +316 -0
- package/vendor/skills/src/source-parser.ts +438 -0
- package/vendor/skills/src/sync.ts +478 -0
- package/vendor/skills/src/telemetry.ts +186 -0
- package/vendor/skills/src/test-utils.ts +73 -0
- package/vendor/skills/src/types.ts +128 -0
- package/vendor/skills/src/update-source.ts +90 -0
- package/vendor/skills/src/update.ts +749 -0
- package/vendor/skills/src/use.ts +675 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import type { SelectComponentsRequest } from "./install.ts";
|
|
4
|
+
import type { ComponentType, PluginSelection } from "../types.ts";
|
|
5
|
+
import { multiselectSkills } from "./multiselect-skills.ts";
|
|
6
|
+
|
|
7
|
+
function cancelled<T>(value: T | symbol): value is symbol {
|
|
8
|
+
if (p.isCancel(value)) {
|
|
9
|
+
p.cancel("Installation cancelled");
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The "install everything?" gate. Returns true to install each selected plugin
|
|
17
|
+
* in full, false to drop into per-plugin component selection. Used by `add` in
|
|
18
|
+
* an interactive terminal when no --only/--skill flags were given.
|
|
19
|
+
*/
|
|
20
|
+
export async function confirmFullInstall(plugins: string[]): Promise<boolean> {
|
|
21
|
+
const list = plugins.length === 1 ? plugins[0] : `${plugins.length} plugins`;
|
|
22
|
+
const choice = await p.select({
|
|
23
|
+
message: `Install everything in ${pc.cyan(list)}?`,
|
|
24
|
+
options: [
|
|
25
|
+
{ value: true, label: "Yes, install all", hint: "every skill, command, agent, mcp…" },
|
|
26
|
+
{ value: false, label: "No, let me choose", hint: "pick components per plugin" },
|
|
27
|
+
],
|
|
28
|
+
initialValue: true,
|
|
29
|
+
});
|
|
30
|
+
if (cancelled(choice)) return true;
|
|
31
|
+
return choice as boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-plugin component picker (①). Choose which categories to expose; if skills
|
|
36
|
+
* is kept and the plugin has several, drill in to pick individual skills. Files
|
|
37
|
+
* are still installed in full — this only narrows what the runtime sees.
|
|
38
|
+
*/
|
|
39
|
+
export async function selectComponentsInteractive(req: SelectComponentsRequest): Promise<PluginSelection> {
|
|
40
|
+
const components = await p.multiselect({
|
|
41
|
+
message: `${pc.bold(req.name)} — what to install? ${pc.dim("(space to toggle)")}`,
|
|
42
|
+
options: req.present.map((c) => ({ value: c, label: `${c} (${req.contents[c].length})` })),
|
|
43
|
+
initialValues: [...req.present],
|
|
44
|
+
required: true,
|
|
45
|
+
});
|
|
46
|
+
if (cancelled(components)) return { components: [...req.present] };
|
|
47
|
+
const chosen = components as ComponentType[];
|
|
48
|
+
|
|
49
|
+
const selection: PluginSelection = { components: chosen };
|
|
50
|
+
|
|
51
|
+
// Drill into individual skills only when skills is kept and there's a choice.
|
|
52
|
+
if (chosen.includes("skills") && req.contents.skills.length > 1) {
|
|
53
|
+
const message = `${pc.bold(req.name)} — which skills? ${pc.dim("(space to toggle)")}`;
|
|
54
|
+
const options = req.contents.skills.map((s) => ({ value: s, label: s }));
|
|
55
|
+
const initialValues = [...req.contents.skills];
|
|
56
|
+
const skills = req.skillDescription
|
|
57
|
+
? await multiselectSkills({ message, options, initialValues, loadDescription: req.skillDescription })
|
|
58
|
+
: await p.multiselect({ message, options, initialValues, required: true });
|
|
59
|
+
if (!cancelled(skills)) {
|
|
60
|
+
const picked = skills as string[];
|
|
61
|
+
if (picked.length !== req.contents.skills.length) selection.skills = picked;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return selection;
|
|
66
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import type { PluginChoice } from "./install.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Interactive multiselect for choosing which plugins to install from a source
|
|
7
|
+
* that holds several. Mirrors the skills install flow. Native (reverse-adapted)
|
|
8
|
+
* plugins are flagged so the user knows they came from a Claude/Codex manifest.
|
|
9
|
+
*/
|
|
10
|
+
export async function selectPluginsInteractive(choices: PluginChoice[]): Promise<string[]> {
|
|
11
|
+
const selected = await p.multiselect({
|
|
12
|
+
message: `Select plugins to install ${pc.dim("(space to toggle)")}`,
|
|
13
|
+
options: choices.map((c) => ({
|
|
14
|
+
value: c.name,
|
|
15
|
+
label: c.native ? `${c.name} ${pc.dim("(native)")}` : c.name,
|
|
16
|
+
...(c.description
|
|
17
|
+
? { hint: c.description.length > 60 ? c.description.slice(0, 57) + "..." : c.description }
|
|
18
|
+
: {}),
|
|
19
|
+
})),
|
|
20
|
+
required: true,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (p.isCancel(selected)) {
|
|
24
|
+
p.cancel("Installation cancelled");
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
return selected as string[];
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ask whether to install into the project (.agents/plugins) or globally
|
|
5
|
+
* (~/.agents/plugins). Returns true for global. Used by `add` when no scope
|
|
6
|
+
* flag (--global/--project/--dir) was given in an interactive terminal.
|
|
7
|
+
*/
|
|
8
|
+
export async function selectScopeInteractive(): Promise<boolean> {
|
|
9
|
+
const scope = await p.select({
|
|
10
|
+
message: "Installation scope",
|
|
11
|
+
options: [
|
|
12
|
+
{ value: false, label: "Project", hint: ".agents/plugins (committed with your project)" },
|
|
13
|
+
{ value: true, label: "Global", hint: "~/.agents/plugins (available across all projects)" },
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
if (p.isCancel(scope)) {
|
|
17
|
+
p.cancel("Installation cancelled");
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
return scope as boolean;
|
|
21
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { folderHash } from "../hash.ts";
|
|
3
|
+
import { packageFilter } from "../package.ts";
|
|
4
|
+
import { lockPath, installedPluginDir } from "../paths.ts";
|
|
5
|
+
import { readLock, writeLock } from "../lock.ts";
|
|
6
|
+
import { readManifest } from "../manifest.ts";
|
|
7
|
+
import { adaptPlugin } from "./adapt.ts";
|
|
8
|
+
import { resolveAgents, type Agent, type AgentScope, type AgentSyncResult } from "../agents/index.ts";
|
|
9
|
+
|
|
10
|
+
export interface UpdateResult {
|
|
11
|
+
name: string;
|
|
12
|
+
changed: boolean;
|
|
13
|
+
version: string;
|
|
14
|
+
folderHash: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UpdateOptions {
|
|
18
|
+
/**
|
|
19
|
+
* After refreshing the store, re-sync the agents so they pick up the updated
|
|
20
|
+
* content rather than serving a stale cached copy. Off by default so the
|
|
21
|
+
* library function stays side-effect-free.
|
|
22
|
+
*/
|
|
23
|
+
resync?: boolean;
|
|
24
|
+
/** Install scope for re-sync; "user" (global) or "project". */
|
|
25
|
+
scope?: AgentScope;
|
|
26
|
+
/** Injection seam for tests; defaults to every registered agent. */
|
|
27
|
+
agents?: Agent[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UpdateLockResult {
|
|
31
|
+
results: UpdateResult[];
|
|
32
|
+
missing: string[];
|
|
33
|
+
/** Per-agent re-sync outcome (when `resync` was set). */
|
|
34
|
+
agents?: AgentSyncResult[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Re-scan installed plugins in a plugins directory, refreshing each lock
|
|
39
|
+
* entry's version and folderHash from disk. Entries whose content or version
|
|
40
|
+
* changed are reported as `changed`. A missing plugin directory is reported as
|
|
41
|
+
* an issue rather than silently dropped.
|
|
42
|
+
*
|
|
43
|
+
* With `resync`, changed plugins are re-adapted (honoring their selection) and
|
|
44
|
+
* re-installed into the agents so Claude/Codex reflect the new content.
|
|
45
|
+
*/
|
|
46
|
+
export function updateLock(
|
|
47
|
+
pluginsDir: string,
|
|
48
|
+
now: string = new Date().toISOString(),
|
|
49
|
+
opts: UpdateOptions = {},
|
|
50
|
+
): UpdateLockResult {
|
|
51
|
+
const lockFile = lockPath(pluginsDir);
|
|
52
|
+
const lock = readLock(lockFile);
|
|
53
|
+
const results: UpdateResult[] = [];
|
|
54
|
+
const missing: string[] = [];
|
|
55
|
+
const changedNames: string[] = [];
|
|
56
|
+
|
|
57
|
+
for (const [name, entry] of Object.entries(lock.plugins)) {
|
|
58
|
+
const dir = installedPluginDir(pluginsDir, name, entry.origin);
|
|
59
|
+
if (!existsSync(dir)) {
|
|
60
|
+
missing.push(name);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const manifest = readManifest(dir);
|
|
64
|
+
const hash = folderHash(dir, [".claude-plugin", ".codex-plugin"], packageFilter(manifest, { includeProjections: false }));
|
|
65
|
+
const changed = hash !== entry.folderHash || manifest.version !== entry.version;
|
|
66
|
+
if (changed) {
|
|
67
|
+
entry.folderHash = hash;
|
|
68
|
+
entry.version = manifest.version;
|
|
69
|
+
entry.updatedAt = now;
|
|
70
|
+
// Regenerate runtime manifests from the updated source, honoring selection.
|
|
71
|
+
adaptPlugin(dir, ["claude", "codex"], entry.selection);
|
|
72
|
+
changedNames.push(name);
|
|
73
|
+
}
|
|
74
|
+
results.push({ name, changed, version: manifest.version, folderHash: hash });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
writeLock(lockFile, lock);
|
|
78
|
+
|
|
79
|
+
const out: UpdateLockResult = { results, missing };
|
|
80
|
+
if (opts.resync && changedNames.length > 0) {
|
|
81
|
+
const ctx = { pluginsDir, plugins: changedNames, scope: opts.scope ?? "project" };
|
|
82
|
+
out.agents = (opts.agents ?? resolveAgents()).map((a) => a.refresh(ctx));
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ADG_MANIFEST_PATH, collectIssues } from "../manifest.ts";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
export interface ValidateResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
issues: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate a plugin's manifest against the ADG schema and check that the
|
|
13
|
+
* directories/files it references actually exist.
|
|
14
|
+
*/
|
|
15
|
+
export function validatePlugin(pluginDir: string): ValidateResult {
|
|
16
|
+
const manifestFile = join(pluginDir, ADG_MANIFEST_PATH);
|
|
17
|
+
if (!existsSync(manifestFile)) {
|
|
18
|
+
return { ok: false, issues: [`${ADG_MANIFEST_PATH} not found in ${pluginDir}`] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let raw: unknown;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return { ok: false, issues: [`${manifestFile} is not valid JSON: ${(err as Error).message}`] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const issues = collectIssues(raw);
|
|
29
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
30
|
+
|
|
31
|
+
// Reference checks (only when structural validation passed).
|
|
32
|
+
const m = raw as Record<string, unknown>;
|
|
33
|
+
const pathFields: Array<[string, unknown]> = [
|
|
34
|
+
["agents", m.agents],
|
|
35
|
+
["commands", m.commands],
|
|
36
|
+
["apps", m.apps],
|
|
37
|
+
["hooks", m.hooks],
|
|
38
|
+
["mcp", m.mcp],
|
|
39
|
+
];
|
|
40
|
+
for (const [field, value] of pathFields) {
|
|
41
|
+
if (typeof value === "string" && !existsSync(join(pluginDir, value))) {
|
|
42
|
+
issues.push(`${field} points to "${value}" which does not exist`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof m.skills === "string") {
|
|
47
|
+
if (!existsSync(join(pluginDir, m.skills))) {
|
|
48
|
+
issues.push(`skills root "${m.skills}" does not exist`);
|
|
49
|
+
}
|
|
50
|
+
} else if (Array.isArray(m.skills)) {
|
|
51
|
+
for (const s of m.skills as string[]) {
|
|
52
|
+
if (!existsSync(join(pluginDir, s))) issues.push(`skill path "${s}" does not exist`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { ok: issues.length === 0, issues };
|
|
57
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { resolveSkills } from "./skills.ts";
|
|
4
|
+
import { COMPONENT_TYPES, type AdgManifest, type ComponentType, type PluginSelection } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export { COMPONENT_TYPES };
|
|
7
|
+
|
|
8
|
+
/** Member names per component type a plugin declares (empty when absent). */
|
|
9
|
+
export type PluginContents = Record<ComponentType, string[]>;
|
|
10
|
+
|
|
11
|
+
/** Recursively collect regular (non-dotfile) file basenames, without extension. */
|
|
12
|
+
function collectFiles(dir: string): string[] {
|
|
13
|
+
if (!existsSync(dir)) return [];
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
16
|
+
if (e.name.startsWith(".")) continue;
|
|
17
|
+
if (e.isDirectory()) out.push(...collectFiles(join(dir, e.name)));
|
|
18
|
+
else out.push(e.name.replace(/\.[^.]+$/, ""));
|
|
19
|
+
}
|
|
20
|
+
return out.sort();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A single file's member name: its basename without extension. `base` is
|
|
25
|
+
* injectable so the OS-separator handling can be exercised deterministically in
|
|
26
|
+
* tests (path.win32.basename vs path.posix.basename) regardless of host platform.
|
|
27
|
+
*/
|
|
28
|
+
export function fileMemberName(absPath: string, base: typeof basename = basename): string {
|
|
29
|
+
return base(absPath).replace(/\.[^.]+$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Members of a declared path: a directory yields its files, a file yields its own name. */
|
|
33
|
+
function membersOf(dir: string, rel: string | undefined): string[] {
|
|
34
|
+
if (!rel) return [];
|
|
35
|
+
const abs = join(dir, rel);
|
|
36
|
+
if (!existsSync(abs)) return [];
|
|
37
|
+
const files = collectFiles(abs);
|
|
38
|
+
if (files.length > 0) return files;
|
|
39
|
+
return [fileMemberName(abs)]; // a single file
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Server names declared in an MCP config file (mcpServers/servers map). */
|
|
43
|
+
function mcpServers(file: string): string[] {
|
|
44
|
+
if (!existsSync(file)) return [];
|
|
45
|
+
try {
|
|
46
|
+
const json = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
47
|
+
const servers = (json.mcpServers ?? json.servers) as Record<string, unknown> | undefined;
|
|
48
|
+
return servers && typeof servers === "object" ? Object.keys(servers).sort() : [];
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Enumerate what a plugin contains, by reading its manifest's component paths. */
|
|
55
|
+
export function pluginContents(dir: string, manifest: AdgManifest): PluginContents {
|
|
56
|
+
return {
|
|
57
|
+
skills: resolveSkills(dir, manifest),
|
|
58
|
+
agents: membersOf(dir, manifest.agents),
|
|
59
|
+
commands: membersOf(dir, manifest.commands),
|
|
60
|
+
apps: membersOf(dir, manifest.apps),
|
|
61
|
+
hooks: membersOf(dir, manifest.hooks),
|
|
62
|
+
mcp: manifest.mcp ? mcpServers(join(dir, manifest.mcp)) : [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Component types this plugin actually has (non-empty). */
|
|
67
|
+
export function presentComponents(contents: PluginContents): ComponentType[] {
|
|
68
|
+
return COMPONENT_TYPES.filter((c) => contents[c].length > 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether a category is exposed under a selection (no selection = everything). */
|
|
72
|
+
export function isExposed(selection: PluginSelection | undefined, category: ComponentType): boolean {
|
|
73
|
+
return !selection || selection.components.includes(category);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Apply a selection to a contents map, returning only the exposed members. */
|
|
77
|
+
export function exposedContents(contents: PluginContents, selection: PluginSelection | undefined): PluginContents {
|
|
78
|
+
if (!selection) return contents;
|
|
79
|
+
const out = {} as PluginContents;
|
|
80
|
+
for (const c of COMPONENT_TYPES) {
|
|
81
|
+
if (!isExposed(selection, c)) {
|
|
82
|
+
out[c] = [];
|
|
83
|
+
} else if (c === "skills" && selection.skills) {
|
|
84
|
+
out[c] = contents.skills.filter((s) => selection.skills!.includes(s));
|
|
85
|
+
} else {
|
|
86
|
+
out[c] = contents[c];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
package/src/deps.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { satisfies } from "./semver.ts";
|
|
2
|
+
import type { AdgManifest } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface PluginCandidate {
|
|
5
|
+
dir: string;
|
|
6
|
+
manifest: AdgManifest;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class DependencyError extends Error {
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "DependencyError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute a topological install order for `rootName` and its transitive
|
|
18
|
+
* dependencies, using `candidates` (name -> plugin) as the resolution universe.
|
|
19
|
+
*
|
|
20
|
+
* Dependencies are emitted before the plugins that depend on them, with
|
|
21
|
+
* `rootName` last. Missing dependencies, version-constraint violations, and
|
|
22
|
+
* dependency cycles are reported as DependencyError.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveInstallOrder(
|
|
25
|
+
rootName: string,
|
|
26
|
+
candidates: Map<string, PluginCandidate>,
|
|
27
|
+
): string[] {
|
|
28
|
+
if (!candidates.has(rootName)) {
|
|
29
|
+
throw new DependencyError(`plugin "${rootName}" not found among candidates`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const order: string[] = [];
|
|
33
|
+
const visited = new Set<string>(); // fully processed
|
|
34
|
+
const onStack = new Set<string>(); // current DFS path (cycle detection)
|
|
35
|
+
|
|
36
|
+
const visit = (name: string, requiredBy: string | null, constraint: string | null): void => {
|
|
37
|
+
const candidate = candidates.get(name);
|
|
38
|
+
if (!candidate) {
|
|
39
|
+
throw new DependencyError(
|
|
40
|
+
`missing dependency "${name}"${requiredBy ? ` required by "${requiredBy}"` : ""}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (constraint && !satisfies(candidate.manifest.version, constraint)) {
|
|
44
|
+
throw new DependencyError(
|
|
45
|
+
`version conflict: "${requiredBy}" requires "${name}@${constraint}" but found ${candidate.manifest.version}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (visited.has(name)) return;
|
|
49
|
+
if (onStack.has(name)) {
|
|
50
|
+
throw new DependencyError(`dependency cycle detected at "${name}"`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onStack.add(name);
|
|
54
|
+
for (const dep of candidate.manifest.dependencies ?? []) {
|
|
55
|
+
visit(dep.name, name, dep.version);
|
|
56
|
+
}
|
|
57
|
+
onStack.delete(name);
|
|
58
|
+
visited.add(name);
|
|
59
|
+
order.push(name);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
visit(rootName, null, null);
|
|
63
|
+
return order;
|
|
64
|
+
}
|
package/src/fsutil.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cpSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function ensureDir(dir: string): void {
|
|
5
|
+
mkdirSync(dir, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function writeJson(file: string, value: unknown): void {
|
|
9
|
+
ensureDir(dirname(file));
|
|
10
|
+
writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function writeText(file: string, value: string): void {
|
|
14
|
+
ensureDir(dirname(file));
|
|
15
|
+
writeFileSync(file, value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Copy a plugin directory, skipping VCS and dependency noise. When `include` is
|
|
20
|
+
* given (a root-relative path predicate, e.g. from `packageFilter`), only the
|
|
21
|
+
* declared payload is copied — dev cruft like `src/`/`test/` is left behind.
|
|
22
|
+
*/
|
|
23
|
+
export function copyPluginDir(
|
|
24
|
+
src: string,
|
|
25
|
+
dest: string,
|
|
26
|
+
include?: (relPath: string) => boolean,
|
|
27
|
+
): void {
|
|
28
|
+
ensureDir(dirname(dest));
|
|
29
|
+
cpSync(src, dest, {
|
|
30
|
+
recursive: true,
|
|
31
|
+
filter: (from) => {
|
|
32
|
+
const base = from.split(/[/\\]/).pop() ?? "";
|
|
33
|
+
if (base === ".git" || base === "node_modules" || base === ".DS_Store") return false;
|
|
34
|
+
if (!include) return true;
|
|
35
|
+
return include(relative(src, from));
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Directory names that never contribute to a plugin's content hash. */
|
|
6
|
+
const HASH_IGNORE = new Set([".git", "node_modules", ".DS_Store"]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compute a deterministic content hash for a plugin directory.
|
|
10
|
+
*
|
|
11
|
+
* The hash incorporates each file's POSIX-normalized relative path and its
|
|
12
|
+
* bytes, sorted by path so the result is stable across filesystems and walk
|
|
13
|
+
* order. `extraIgnore` drops directory names by segment (e.g. generated adapter
|
|
14
|
+
* manifests). `include`, when given, restricts the hash to the packaged payload
|
|
15
|
+
* (root-relative path predicate, e.g. from `packageFilter`) so an in-place
|
|
16
|
+
* plugin and a copied install hash identically.
|
|
17
|
+
*/
|
|
18
|
+
export function folderHash(
|
|
19
|
+
dir: string,
|
|
20
|
+
extraIgnore: Iterable<string> = [],
|
|
21
|
+
include?: (relPath: string) => boolean,
|
|
22
|
+
): string {
|
|
23
|
+
const ignore = new Set([...HASH_IGNORE, ...extraIgnore]);
|
|
24
|
+
const files: string[] = [];
|
|
25
|
+
collect(dir, dir, ignore, include, files);
|
|
26
|
+
files.sort();
|
|
27
|
+
|
|
28
|
+
const hash = createHash("sha256");
|
|
29
|
+
for (const rel of files) {
|
|
30
|
+
hash.update(rel, "utf8");
|
|
31
|
+
hash.update("\0");
|
|
32
|
+
hash.update(readFileSync(join(dir, rel)));
|
|
33
|
+
hash.update("\0");
|
|
34
|
+
}
|
|
35
|
+
// Self-describing digest (SRI-style) so the algorithm can evolve later.
|
|
36
|
+
return `sha256-${hash.digest("hex")}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function collect(
|
|
40
|
+
root: string,
|
|
41
|
+
current: string,
|
|
42
|
+
ignore: Set<string>,
|
|
43
|
+
include: ((relPath: string) => boolean) | undefined,
|
|
44
|
+
out: string[],
|
|
45
|
+
): void {
|
|
46
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
47
|
+
if (ignore.has(entry.name)) continue;
|
|
48
|
+
const abs = join(current, entry.name);
|
|
49
|
+
const rel = relative(root, abs);
|
|
50
|
+
// Match ignore entries against any path segment (e.g. ".claude-plugin").
|
|
51
|
+
if (rel.split(sep).some((seg) => ignore.has(seg))) continue;
|
|
52
|
+
const relPosix = rel.split(sep).join("/");
|
|
53
|
+
// Prune anything outside the packaged payload (directories too, by segment).
|
|
54
|
+
if (include && !include(relPosix)) continue;
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
collect(root, abs, ignore, include, out);
|
|
57
|
+
} else if (entry.isFile()) {
|
|
58
|
+
out.push(relPosix);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { LOCK_VERSION, type LockEntry, type PluginLock } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function emptyLock(): PluginLock {
|
|
5
|
+
return { version: LOCK_VERSION, plugins: {} };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function readLock(file: string): PluginLock {
|
|
9
|
+
if (!existsSync(file)) return emptyLock();
|
|
10
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as PluginLock;
|
|
11
|
+
if (typeof raw.version !== "number" || typeof raw.plugins !== "object" || raw.plugins === null) {
|
|
12
|
+
throw new Error(`${file} is not a valid .plugin-lock.json`);
|
|
13
|
+
}
|
|
14
|
+
// Pre-release policy: a lock from an older format version is fully
|
|
15
|
+
// regenerable from the plugin directories, so rebuild rather than merge
|
|
16
|
+
// incompatible entry shapes.
|
|
17
|
+
if (raw.version !== LOCK_VERSION) return emptyLock();
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function writeLock(file: string, lock: PluginLock): void {
|
|
22
|
+
writeFileSync(file, JSON.stringify(lock, null, 2) + "\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Remove a plugin entry from the lock. Drops it from `lastSelected` too.
|
|
27
|
+
* Returns true if an entry was actually removed.
|
|
28
|
+
*/
|
|
29
|
+
export function removeEntry(lock: PluginLock, name: string): boolean {
|
|
30
|
+
if (!(name in lock.plugins)) return false;
|
|
31
|
+
delete lock.plugins[name];
|
|
32
|
+
if (lock.lastSelected) {
|
|
33
|
+
lock.lastSelected = lock.lastSelected.filter((n) => n !== name);
|
|
34
|
+
if (lock.lastSelected.length === 0) delete lock.lastSelected;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Insert or update a plugin entry. Preserves the original installedAt on update
|
|
41
|
+
* and always refreshes updatedAt. `lastSelected` is set to the touched plugin.
|
|
42
|
+
*/
|
|
43
|
+
export function upsertEntry(
|
|
44
|
+
lock: PluginLock,
|
|
45
|
+
name: string,
|
|
46
|
+
entry: Omit<LockEntry, "installedAt" | "updatedAt">,
|
|
47
|
+
now: string = new Date().toISOString(),
|
|
48
|
+
): PluginLock {
|
|
49
|
+
const prev = lock.plugins[name];
|
|
50
|
+
lock.plugins[name] = {
|
|
51
|
+
...entry,
|
|
52
|
+
installedAt: prev?.installedAt ?? now,
|
|
53
|
+
updatedAt: now,
|
|
54
|
+
};
|
|
55
|
+
lock.lastSelected = [name];
|
|
56
|
+
return lock;
|
|
57
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ADG_SCHEMA_VERSION, type AdgManifest } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Canonical, vendor-neutral source manifest location (a plugin). */
|
|
6
|
+
export const ADG_MANIFEST_PATH = join(".agents", ".plugin.json");
|
|
7
|
+
/** Canonical, vendor-neutral source catalog location (a marketplace). */
|
|
8
|
+
export const ADG_MARKETPLACE_PATH = join(".agents", ".marketplace.json");
|
|
9
|
+
/** Legacy location, still read (deprecated) so pre-`.agents/` plugins resolve. */
|
|
10
|
+
export const LEGACY_MANIFEST_PATH = join(".adg-plugin", "plugin.json");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a plugin's manifest file, preferring the canonical `.agents/.plugin.json`
|
|
14
|
+
* and falling back to the legacy `.adg-plugin/plugin.json`. Returns undefined
|
|
15
|
+
* when neither exists.
|
|
16
|
+
*/
|
|
17
|
+
export function findManifestFile(pluginDir: string): string | undefined {
|
|
18
|
+
const primary = join(pluginDir, ADG_MANIFEST_PATH);
|
|
19
|
+
if (existsSync(primary)) return primary;
|
|
20
|
+
const legacy = join(pluginDir, LEGACY_MANIFEST_PATH);
|
|
21
|
+
if (existsSync(legacy)) return legacy;
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
26
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
|
|
27
|
+
|
|
28
|
+
export class ManifestError extends Error {
|
|
29
|
+
readonly issues: string[];
|
|
30
|
+
constructor(issues: string[]) {
|
|
31
|
+
super(`Invalid ADG manifest:\n - ${issues.join("\n - ")}`);
|
|
32
|
+
this.name = "ManifestError";
|
|
33
|
+
this.issues = issues;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read and validate a plugin's `.agents/.plugin.json` (or legacy fallback). */
|
|
38
|
+
export function readManifest(pluginDir: string): AdgManifest {
|
|
39
|
+
const file = findManifestFile(pluginDir);
|
|
40
|
+
if (!file) {
|
|
41
|
+
throw new ManifestError([`${ADG_MANIFEST_PATH} not found in ${pluginDir}`]);
|
|
42
|
+
}
|
|
43
|
+
let raw: unknown;
|
|
44
|
+
try {
|
|
45
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new ManifestError([`${file} is not valid JSON: ${(err as Error).message}`]);
|
|
48
|
+
}
|
|
49
|
+
return validateManifest(raw);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Validate an already-parsed manifest object, throwing ManifestError on failure. */
|
|
53
|
+
export function validateManifest(raw: unknown): AdgManifest {
|
|
54
|
+
const issues = collectIssues(raw);
|
|
55
|
+
if (issues.length > 0) throw new ManifestError(issues);
|
|
56
|
+
return raw as AdgManifest;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Collect validation issues without throwing (used by the `validate` command). */
|
|
60
|
+
export function collectIssues(raw: unknown): string[] {
|
|
61
|
+
const issues: string[] = [];
|
|
62
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
63
|
+
return ["manifest must be a JSON object"];
|
|
64
|
+
}
|
|
65
|
+
const m = raw as Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
if (m.schemaVersion !== ADG_SCHEMA_VERSION) {
|
|
68
|
+
issues.push(`schemaVersion must be "${ADG_SCHEMA_VERSION}"`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof m.name !== "string" || !NAME_RE.test(m.name)) {
|
|
71
|
+
issues.push("name is required and must be kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$)");
|
|
72
|
+
}
|
|
73
|
+
if (typeof m.version !== "string" || !SEMVER_RE.test(m.version)) {
|
|
74
|
+
issues.push("version is required and must be semantic (e.g. 0.1.0)");
|
|
75
|
+
}
|
|
76
|
+
if (typeof m.description !== "string" || m.description.length === 0) {
|
|
77
|
+
issues.push("description is required and must be a non-empty string");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (m.skills !== undefined && typeof m.skills !== "string" && !isStringArray(m.skills)) {
|
|
81
|
+
issues.push("skills must be a string or an array of strings");
|
|
82
|
+
}
|
|
83
|
+
for (const key of ["agents", "commands", "apps", "hooks", "mcp", "homepage", "changelog", "license", "category"]) {
|
|
84
|
+
if (m[key] !== undefined && typeof m[key] !== "string") {
|
|
85
|
+
issues.push(`${key} must be a string`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (m.strict !== undefined && typeof m.strict !== "boolean") {
|
|
89
|
+
issues.push("strict must be a boolean");
|
|
90
|
+
}
|
|
91
|
+
if (m.dependencies !== undefined) {
|
|
92
|
+
if (!Array.isArray(m.dependencies)) {
|
|
93
|
+
issues.push("dependencies must be an array");
|
|
94
|
+
} else {
|
|
95
|
+
m.dependencies.forEach((dep, i) => {
|
|
96
|
+
if (typeof dep !== "object" || dep === null) {
|
|
97
|
+
issues.push(`dependencies[${i}] must be an object`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const d = dep as Record<string, unknown>;
|
|
101
|
+
if (typeof d.name !== "string") issues.push(`dependencies[${i}].name must be a string`);
|
|
102
|
+
if (typeof d.version !== "string") issues.push(`dependencies[${i}].version must be a string`);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// `adapters` is no longer part of the DSL. A stray one from an old manifest is
|
|
107
|
+
// tolerated (ignored) rather than rejected — output paths are ADG-internal.
|
|
108
|
+
return issues;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isStringArray(v: unknown): v is string[] {
|
|
112
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
113
|
+
}
|