@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,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import type { Marketplace, MarketplacePlugin } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export function emptyMarketplace(name: string): Marketplace {
|
|
5
|
+
return { name, plugins: [] };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read a marketplace file. This file is the runtime-facing export (consumed by
|
|
10
|
+
* Codex), kept in the de-facto shape; ADG never treats it as its own control
|
|
11
|
+
* surface (that is the lock). The reader is tolerant: only structural fields are
|
|
12
|
+
* checked and unknown fields are preserved.
|
|
13
|
+
*/
|
|
14
|
+
export function readMarketplace(file: string, fallbackName: string): Marketplace {
|
|
15
|
+
if (!existsSync(file)) return emptyMarketplace(fallbackName);
|
|
16
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as Marketplace;
|
|
17
|
+
if (typeof raw.name !== "string" || !Array.isArray(raw.plugins)) {
|
|
18
|
+
throw new Error(`${file} is not a valid marketplace.json`);
|
|
19
|
+
}
|
|
20
|
+
return raw;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeMarketplace(file: string, market: Marketplace): void {
|
|
24
|
+
writeFileSync(file, JSON.stringify(market, null, 2) + "\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Insert or replace a plugin entry by name, preserving array order. */
|
|
28
|
+
export function upsertMarketplacePlugin(market: Marketplace, plugin: MarketplacePlugin): Marketplace {
|
|
29
|
+
const idx = market.plugins.findIndex((p) => p.name === plugin.name);
|
|
30
|
+
if (idx >= 0) market.plugins[idx] = plugin;
|
|
31
|
+
else market.plugins.push(plugin);
|
|
32
|
+
return market;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Remove a plugin entry by name. Returns true if one was removed. */
|
|
36
|
+
export function removeMarketplacePlugin(market: Marketplace, name: string): boolean {
|
|
37
|
+
const idx = market.plugins.findIndex((p) => p.name === name);
|
|
38
|
+
if (idx < 0) return false;
|
|
39
|
+
market.plugins.splice(idx, 1);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
package/src/package.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { AdgManifest } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Manifest-driven packaging allowlist.
|
|
5
|
+
*
|
|
6
|
+
* A plugin ships only its *declared* payload — the component directories named
|
|
7
|
+
* in the manifest plus a small set of canonical metadata files — never the
|
|
8
|
+
* authoring repo's dev cruft (`src/`, `test/`, `docs/`, CI config, …). This is a
|
|
9
|
+
* default-deny model: anything not derivable from the manifest is dropped, which
|
|
10
|
+
* is both safer (no accidental secret/dev-file leakage) and consistent with the
|
|
11
|
+
* lock's content hash.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Top-level metadata files always shipped with a plugin, matched case-insensitively. */
|
|
15
|
+
const META_RE = /^(README|LICEN[CS]E|CHANGELOG|NOTICE)(\..+)?$/i;
|
|
16
|
+
|
|
17
|
+
/** Generated runtime projections — shipped, but excluded from the content hash. */
|
|
18
|
+
export const PROJECTION_DIRS = [".claude-plugin", ".codex-plugin"];
|
|
19
|
+
|
|
20
|
+
/** Extract the first path segment of a manifest component value (e.g. "./skills/" -> "skills"). */
|
|
21
|
+
function topSegment(p: string): string {
|
|
22
|
+
return p.replace(/^\.?[/\\]/, "").split(/[/\\]/)[0] ?? "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The set of root-level entry names that constitute the authored plugin payload,
|
|
27
|
+
* derived from the manifest's declared components plus the canonical `.agents/`
|
|
28
|
+
* manifest home.
|
|
29
|
+
*/
|
|
30
|
+
export function packagedRoots(manifest: AdgManifest): Set<string> {
|
|
31
|
+
// `.agents` is the canonical manifest home; `.adg-plugin` keeps legacy plugins
|
|
32
|
+
// shippable during the deprecation window.
|
|
33
|
+
const roots = new Set<string>([".agents", ".adg-plugin"]);
|
|
34
|
+
const components: Array<string | string[] | undefined> = [
|
|
35
|
+
manifest.skills,
|
|
36
|
+
manifest.agents,
|
|
37
|
+
manifest.commands,
|
|
38
|
+
manifest.mcp,
|
|
39
|
+
manifest.hooks,
|
|
40
|
+
manifest.apps,
|
|
41
|
+
];
|
|
42
|
+
for (const c of components) {
|
|
43
|
+
if (c === undefined) continue;
|
|
44
|
+
for (const p of Array.isArray(c) ? c : [c]) {
|
|
45
|
+
const seg = topSegment(p);
|
|
46
|
+
if (seg) roots.add(seg);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return roots;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a predicate over a root-relative path: whether it belongs to the
|
|
54
|
+
* packaged plugin. Used by both the copy step and the content hash so an
|
|
55
|
+
* in-place plugin (copy skipped) and a copied install hash identically.
|
|
56
|
+
*
|
|
57
|
+
* `includeProjections` controls whether the generated runtime manifests count:
|
|
58
|
+
* true when copying (they ship), false when hashing (so re-adapting is stable).
|
|
59
|
+
*/
|
|
60
|
+
export function packageFilter(
|
|
61
|
+
manifest: AdgManifest,
|
|
62
|
+
opts: { includeProjections: boolean },
|
|
63
|
+
): (relPath: string) => boolean {
|
|
64
|
+
const roots = packagedRoots(manifest);
|
|
65
|
+
return (relPath: string): boolean => {
|
|
66
|
+
if (relPath === "") return true; // the plugin root itself
|
|
67
|
+
const segments = relPath.split(/[/\\]/);
|
|
68
|
+
const first = segments[0]!;
|
|
69
|
+
if (roots.has(first)) return true;
|
|
70
|
+
if (opts.includeProjections && PROJECTION_DIRS.includes(first)) return true;
|
|
71
|
+
// Metadata files only count at the root level (a single segment).
|
|
72
|
+
return segments.length === 1 && META_RE.test(first);
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
4
|
+
import type { PluginSource } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export const LOCK_FILENAME = ".plugin-lock.json";
|
|
7
|
+
export const MARKETPLACE_FILENAME = "marketplace.json";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the global plugins directory.
|
|
11
|
+
*
|
|
12
|
+
* Honors ADG_PLUGINS_HOME, then XDG_STATE_HOME/.agents/plugins, falling back to
|
|
13
|
+
* ~/.agents/plugins. Only the `plugins/` subtree is ever managed by ADG — the
|
|
14
|
+
* sibling AGENTS.md and skills/ are never read or written by this tool.
|
|
15
|
+
*/
|
|
16
|
+
export function globalPluginsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
+
if (env.ADG_PLUGINS_HOME) return env.ADG_PLUGINS_HOME;
|
|
18
|
+
if (env.XDG_STATE_HOME) return join(env.XDG_STATE_HOME, ".agents", "plugins");
|
|
19
|
+
return join(homedir(), ".agents", "plugins");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the project plugins directory by walking up from `start` to find a
|
|
24
|
+
* `.agents/plugins` directory or a repo root (`.git`); defaults to
|
|
25
|
+
* `<start>/.agents/plugins` when none is found.
|
|
26
|
+
*/
|
|
27
|
+
export function projectPluginsDir(start: string = process.cwd()): string {
|
|
28
|
+
let dir = start;
|
|
29
|
+
while (true) {
|
|
30
|
+
const candidate = join(dir, ".agents", "plugins");
|
|
31
|
+
if (existsSync(candidate)) return candidate;
|
|
32
|
+
if (existsSync(join(dir, ".git"))) return candidate;
|
|
33
|
+
const parent = dirname(dir);
|
|
34
|
+
if (parent === dir) return join(start, ".agents", "plugins");
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve Claude's skills-directory plugin root, where a symlinked plugin
|
|
41
|
+
* auto-loads as `<name>@skills-dir`. Global → ~/.claude/skills, project →
|
|
42
|
+
* <cwd>/.claude/skills. This is Claude's own directory, distinct from the
|
|
43
|
+
* never-touched ~/.agents/skills.
|
|
44
|
+
*/
|
|
45
|
+
export function claudeSkillsDir(global: boolean, cwd: string = process.cwd()): string {
|
|
46
|
+
return global ? join(homedir(), ".claude", "skills") : join(cwd, ".claude", "skills");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function lockPath(pluginsDir: string): string {
|
|
50
|
+
return join(pluginsDir, LOCK_FILENAME);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function marketplacePath(pluginsDir: string): string {
|
|
54
|
+
return join(pluginsDir, MARKETPLACE_FILENAME);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Make a filesystem-safe single path segment out of a git URL by replacing the
|
|
59
|
+
* scheme/host/path separators with `__` and dropping a trailing `.git`.
|
|
60
|
+
*/
|
|
61
|
+
function sanitizeGitUrl(url: string): string {
|
|
62
|
+
return url
|
|
63
|
+
.replace(/\.git$/, "")
|
|
64
|
+
.replace(/[:/@]+/g, "__")
|
|
65
|
+
.replace(/^_+|_+$/g, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The directory bucket a plugin's files live under, grouped by the source it
|
|
70
|
+
* came from: remote sources nest beneath a per-marketplace segment
|
|
71
|
+
* (`owner/repo` → `owner__repo`), while local installs stay flat (return null).
|
|
72
|
+
* This is the on-disk grouping only — plugin name remains the unique key in the
|
|
73
|
+
* lock and marketplace.json.
|
|
74
|
+
*/
|
|
75
|
+
export function sourceDirSegment(origin: PluginSource): string | null {
|
|
76
|
+
switch (origin.type) {
|
|
77
|
+
case "local":
|
|
78
|
+
return null;
|
|
79
|
+
case "github":
|
|
80
|
+
return origin.repo.replaceAll("/", "__");
|
|
81
|
+
case "git":
|
|
82
|
+
return sanitizeGitUrl(origin.url);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve where a plugin's files are installed: `<pluginsDir>/<segment>/<name>`
|
|
88
|
+
* for remote sources, or the flat `<pluginsDir>/<name>` for local installs.
|
|
89
|
+
*/
|
|
90
|
+
export function pluginDir(pluginsDir: string, name: string, origin: PluginSource): string {
|
|
91
|
+
const seg = sourceDirSegment(origin);
|
|
92
|
+
return seg ? join(pluginsDir, seg, name) : join(pluginsDir, name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a plugin's *actual* on-disk directory. Prefers the canonical
|
|
97
|
+
* per-marketplace nested path, but falls back to the flat `<pluginsDir>/<name>`
|
|
98
|
+
* for installs made before the nested layout (and not yet `migrate`d). Returns
|
|
99
|
+
* the canonical nested path when neither exists.
|
|
100
|
+
*/
|
|
101
|
+
export function installedPluginDir(pluginsDir: string, name: string, origin: PluginSource): string {
|
|
102
|
+
const nested = pluginDir(pluginsDir, name, origin);
|
|
103
|
+
if (existsSync(nested)) return nested;
|
|
104
|
+
const flat = join(pluginsDir, name);
|
|
105
|
+
if (existsSync(flat)) return flat;
|
|
106
|
+
return nested;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The `source.path` to record in marketplace.json for a plugin at `dest`.
|
|
111
|
+
*
|
|
112
|
+
* For the canonical store layout `<root>/.agents/plugins`, Codex discovers the
|
|
113
|
+
* marketplace via the root that *contains* `.agents/` and resolves each entry's
|
|
114
|
+
* `source.path` relative to that root — so the path keeps its `.agents/plugins/`
|
|
115
|
+
* prefix (e.g. `./.agents/plugins/owner__repo/name`) for `codex plugin add` to
|
|
116
|
+
* find it.
|
|
117
|
+
*
|
|
118
|
+
* Any other store (an explicit `--dir <path>`) has no `.agents/` ancestor to
|
|
119
|
+
* resolve against; `dest` always lives *inside* `pluginsDir`, so we record a
|
|
120
|
+
* path relative to the store dir itself (e.g. `./name` or `./segment/name`).
|
|
121
|
+
* This stays correct regardless of where or how deep the store sits, instead of
|
|
122
|
+
* leaking parent-directory names from a fixed two-levels-up assumption.
|
|
123
|
+
*/
|
|
124
|
+
export function marketplaceSourcePath(pluginsDir: string, dest: string): string {
|
|
125
|
+
const isCanonical =
|
|
126
|
+
basename(pluginsDir) === "plugins" && basename(dirname(pluginsDir)) === ".agents";
|
|
127
|
+
const root = isCanonical ? dirname(dirname(pluginsDir)) : pluginsDir;
|
|
128
|
+
return `./${relative(root, dest).split("\\").join("/")}`;
|
|
129
|
+
}
|
package/src/semver.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal semver comparison and range satisfaction.
|
|
3
|
+
*
|
|
4
|
+
* Supports the range forms ADG manifests use: exact (`1.2.3`), caret (`^1.2.3`),
|
|
5
|
+
* tilde (`~1.2.3`), wildcard (`*` / `x`), and simple comparators
|
|
6
|
+
* (`>=`, `>`, `<=`, `<`, `=`). Pre-release and build metadata are ignored for
|
|
7
|
+
* comparison.
|
|
8
|
+
*/
|
|
9
|
+
export type Semver = [number, number, number];
|
|
10
|
+
|
|
11
|
+
export function parseVersion(v: string): Semver {
|
|
12
|
+
const core = v.trim().replace(/^[v=]/, "").split(/[-+]/)[0] ?? "";
|
|
13
|
+
const parts = core.split(".");
|
|
14
|
+
if (parts.length !== 3 || parts.some((p) => !/^\d+$/.test(p))) {
|
|
15
|
+
throw new Error(`invalid semantic version: "${v}"`);
|
|
16
|
+
}
|
|
17
|
+
return [Number(parts[0]), Number(parts[1]), Number(parts[2])];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function compare(a: Semver, b: Semver): number {
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
if (a[i]! !== b[i]!) return a[i]! < b[i]! ? -1 : 1;
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function satisfies(version: string, range: string): boolean {
|
|
28
|
+
const v = parseVersion(version);
|
|
29
|
+
const r = range.trim();
|
|
30
|
+
|
|
31
|
+
if (r === "" || r === "*" || r === "x" || r === "X") return true;
|
|
32
|
+
|
|
33
|
+
const cmp = r.match(/^(>=|<=|>|<|=)\s*(.+)$/);
|
|
34
|
+
if (cmp) {
|
|
35
|
+
const target = parseVersion(cmp[2]!);
|
|
36
|
+
const c = compare(v, target);
|
|
37
|
+
switch (cmp[1]) {
|
|
38
|
+
case ">=": return c >= 0;
|
|
39
|
+
case "<=": return c <= 0;
|
|
40
|
+
case ">": return c > 0;
|
|
41
|
+
case "<": return c < 0;
|
|
42
|
+
case "=": return c === 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (r.startsWith("^")) {
|
|
47
|
+
const base = parseVersion(r.slice(1));
|
|
48
|
+
if (compare(v, base) < 0) return false;
|
|
49
|
+
const upper = caretUpperBound(base);
|
|
50
|
+
return compare(v, upper) < 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (r.startsWith("~")) {
|
|
54
|
+
const base = parseVersion(r.slice(1));
|
|
55
|
+
if (compare(v, base) < 0) return false;
|
|
56
|
+
return compare(v, [base[0], base[1] + 1, 0]) < 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return compare(v, parseVersion(r)) === 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** npm caret semantics: first non-zero leftmost component is the boundary. */
|
|
63
|
+
function caretUpperBound([major, minor, patch]: Semver): Semver {
|
|
64
|
+
if (major > 0) return [major + 1, 0, 0];
|
|
65
|
+
if (minor > 0) return [0, minor + 1, 0];
|
|
66
|
+
return [0, 0, patch + 1];
|
|
67
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import type { AdgManifest } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** A resolved skill: its kebab-case name and the SKILL.md path (if one exists). */
|
|
7
|
+
export interface SkillEntry {
|
|
8
|
+
name: string;
|
|
9
|
+
skillMd?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the skills a plugin exposes, paired with their SKILL.md paths.
|
|
14
|
+
*
|
|
15
|
+
* - `skills` array → each entry's basename, SKILL.md resolved under the entry.
|
|
16
|
+
* - otherwise → auto-scan the skills root for sub-dirs that contain a SKILL.md.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveSkillEntries(pluginDir: string, manifest: AdgManifest): SkillEntry[] {
|
|
19
|
+
const skills = manifest.skills;
|
|
20
|
+
if (Array.isArray(skills)) {
|
|
21
|
+
return skills.map((p) => {
|
|
22
|
+
const rel = p.replace(/\/+$/, "");
|
|
23
|
+
const name = rel.split("/").pop() ?? p;
|
|
24
|
+
const abs = join(pluginDir, rel);
|
|
25
|
+
let skillMd: string | undefined;
|
|
26
|
+
if (existsSync(abs)) {
|
|
27
|
+
const md = statSync(abs).isDirectory() ? join(abs, "SKILL.md") : abs;
|
|
28
|
+
if (existsSync(md)) skillMd = md;
|
|
29
|
+
}
|
|
30
|
+
return { name, skillMd };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const root = typeof skills === "string" ? skills : "./skills/";
|
|
34
|
+
const abs = join(pluginDir, root);
|
|
35
|
+
if (!existsSync(abs)) return [];
|
|
36
|
+
return readdirSync(abs, { withFileTypes: true })
|
|
37
|
+
.filter((e) => e.isDirectory() && existsSync(join(abs, e.name, "SKILL.md")))
|
|
38
|
+
.map((e) => ({ name: e.name, skillMd: join(abs, e.name, "SKILL.md") }))
|
|
39
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the list of skill names a plugin exposes.
|
|
44
|
+
*
|
|
45
|
+
* - `strict !== false` with an explicit `skills` array → that array (basenames).
|
|
46
|
+
* - otherwise → auto-scan the skills root directory for sub-dirs that contain a
|
|
47
|
+
* SKILL.md file (skill names are the kebab-case directory names).
|
|
48
|
+
*/
|
|
49
|
+
export function resolveSkills(pluginDir: string, manifest: AdgManifest): string[] {
|
|
50
|
+
return resolveSkillEntries(pluginDir, manifest).map((e) => e.name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read a SKILL.md's `description` from its YAML frontmatter (undefined if absent). */
|
|
54
|
+
export function readSkillDescription(skillMd: string): string | undefined {
|
|
55
|
+
try {
|
|
56
|
+
const head = readFileSync(skillMd, "utf8").slice(0, 4096);
|
|
57
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head);
|
|
58
|
+
if (!m) return undefined;
|
|
59
|
+
const fm = parseYaml(m[1] ?? "") as { description?: unknown } | null;
|
|
60
|
+
const d = fm?.description;
|
|
61
|
+
return typeof d === "string" && d.trim() ? d.trim() : undefined;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a lazy, cached description reader keyed by skill name. Files are only
|
|
69
|
+
* read on the first lookup of each name (and never for skills the user doesn't
|
|
70
|
+
* inspect), keeping the interactive picker free of upfront SKILL.md parsing.
|
|
71
|
+
*/
|
|
72
|
+
export function skillDescriptionLoader(
|
|
73
|
+
pluginDir: string,
|
|
74
|
+
manifest: AdgManifest,
|
|
75
|
+
): (name: string) => string | undefined {
|
|
76
|
+
let paths: Map<string, string | undefined> | undefined;
|
|
77
|
+
const cache = new Map<string, string | undefined>();
|
|
78
|
+
return (name) => {
|
|
79
|
+
if (cache.has(name)) return cache.get(name);
|
|
80
|
+
if (!paths) {
|
|
81
|
+
paths = new Map(resolveSkillEntries(pluginDir, manifest).map((e) => [e.name, e.skillMd]));
|
|
82
|
+
}
|
|
83
|
+
const md = paths.get(name);
|
|
84
|
+
const desc = md ? readSkillDescription(md) : undefined;
|
|
85
|
+
cache.set(name, desc);
|
|
86
|
+
return desc;
|
|
87
|
+
};
|
|
88
|
+
}
|
package/src/sources.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readManifest, findManifestFile } from "./manifest.ts";
|
|
5
|
+
import type { PluginCandidate } from "./deps.ts";
|
|
6
|
+
|
|
7
|
+
export interface GitHubSource {
|
|
8
|
+
kind: "github";
|
|
9
|
+
/** Normalized "owner/repo". */
|
|
10
|
+
source: string;
|
|
11
|
+
owner: string;
|
|
12
|
+
repo: string;
|
|
13
|
+
ref?: string;
|
|
14
|
+
sourceUrl: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LocalSource {
|
|
18
|
+
kind: "local";
|
|
19
|
+
dir: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ParsedSource = GitHubSource | LocalSource;
|
|
23
|
+
|
|
24
|
+
const GH_SHORTHAND = /^([\w.-]+)\/([\w.-]+?)(?:@(.+))?$/;
|
|
25
|
+
const GH_URL = /^(?:https?:\/\/github\.com\/|git@github\.com:)([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:@(.+))?$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse an install spec into a local directory or a GitHub source.
|
|
29
|
+
*
|
|
30
|
+
* An existing local directory always wins; otherwise the spec is matched
|
|
31
|
+
* against `owner/repo[@ref]` shorthand or a github.com URL.
|
|
32
|
+
*/
|
|
33
|
+
export function parseSource(spec: string): ParsedSource {
|
|
34
|
+
if (existsSync(spec)) return { kind: "local", dir: spec };
|
|
35
|
+
|
|
36
|
+
const url = spec.match(GH_URL);
|
|
37
|
+
if (url) {
|
|
38
|
+
const [, owner, repo, ref] = url;
|
|
39
|
+
return gh(owner!, repo!, ref);
|
|
40
|
+
}
|
|
41
|
+
const short = spec.match(GH_SHORTHAND);
|
|
42
|
+
if (short) {
|
|
43
|
+
const [, owner, repo, ref] = short;
|
|
44
|
+
return gh(owner!, repo!, ref);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`cannot parse install source: "${spec}" (expected a local path or owner/repo[@ref])`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function gh(owner: string, repo: string, ref?: string): GitHubSource {
|
|
50
|
+
return {
|
|
51
|
+
kind: "github",
|
|
52
|
+
source: `${owner}/${repo}`,
|
|
53
|
+
owner,
|
|
54
|
+
repo,
|
|
55
|
+
ref,
|
|
56
|
+
sourceUrl: `https://github.com/${owner}/${repo}.git`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Shallow-clone a GitHub source into `dest`, optionally restricting the working
|
|
62
|
+
* tree to `sparse` sub-paths (cone-mode sparse checkout) for large monorepos.
|
|
63
|
+
* The git runner is injectable so the flow can be exercised offline in tests.
|
|
64
|
+
*/
|
|
65
|
+
export function cloneGitHub(
|
|
66
|
+
src: GitHubSource,
|
|
67
|
+
dest: string,
|
|
68
|
+
opts: { sparse?: string[]; runner?: GitRunner } = {},
|
|
69
|
+
): string {
|
|
70
|
+
const runner = opts.runner ?? defaultGitRunner;
|
|
71
|
+
const sparse = opts.sparse?.filter(Boolean) ?? [];
|
|
72
|
+
|
|
73
|
+
const clone = ["clone", "--depth", "1"];
|
|
74
|
+
if (src.ref) clone.push("--branch", src.ref);
|
|
75
|
+
if (sparse.length > 0) clone.push("--filter=blob:none", "--sparse");
|
|
76
|
+
clone.push(src.sourceUrl, dest);
|
|
77
|
+
runner(clone);
|
|
78
|
+
|
|
79
|
+
if (sparse.length > 0) {
|
|
80
|
+
runner(["-C", dest, "sparse-checkout", "set", ...sparse]);
|
|
81
|
+
}
|
|
82
|
+
return dest;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type GitRunner = (args: string[]) => void;
|
|
86
|
+
|
|
87
|
+
const defaultGitRunner: GitRunner = (args) => {
|
|
88
|
+
execFileSync("git", args, { stdio: "pipe" });
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Recursively find ADG plugins under `root` (directories containing
|
|
93
|
+
* `.agents/.plugin.json`, or the legacy `.adg-plugin/plugin.json`), keyed by
|
|
94
|
+
* manifest name. Used as the resolution universe for dependency ordering.
|
|
95
|
+
*/
|
|
96
|
+
export function scanPlugins(root: string): Map<string, PluginCandidate> {
|
|
97
|
+
const found = new Map<string, PluginCandidate>();
|
|
98
|
+
walk(root, root, found);
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function walk(root: string, current: string, out: Map<string, PluginCandidate>): void {
|
|
103
|
+
if (findManifestFile(current)) {
|
|
104
|
+
const manifest = readManifest(current);
|
|
105
|
+
if (!out.has(manifest.name)) out.set(manifest.name, { dir: current, manifest });
|
|
106
|
+
return; // do not descend into a plugin directory
|
|
107
|
+
}
|
|
108
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
109
|
+
if (!entry.isDirectory()) continue;
|
|
110
|
+
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
111
|
+
walk(root, join(current, entry.name), out);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const CODEX_MANIFEST_PATH = join(".codex-plugin", "plugin.json");
|
|
116
|
+
export const CLAUDE_MANIFEST_PATH = join(".claude-plugin", "plugin.json");
|
|
117
|
+
|
|
118
|
+
export interface NativePlugin {
|
|
119
|
+
dir: string;
|
|
120
|
+
/** Which runtime-native manifest was found. */
|
|
121
|
+
kind: "adg" | "openai" | "anthropic";
|
|
122
|
+
/** Path to the native manifest file. */
|
|
123
|
+
manifestFile: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Recursively find plugin directories under `root`, recognizing ADG, Codex and
|
|
128
|
+
* Claude manifests. Used by `import` to discover existing plugins to convert.
|
|
129
|
+
*/
|
|
130
|
+
export function scanNativePlugins(root: string): NativePlugin[] {
|
|
131
|
+
const found: NativePlugin[] = [];
|
|
132
|
+
walkNative(root, found);
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function walkNative(current: string, out: NativePlugin[]): void {
|
|
137
|
+
// Resolution priority: canonical .agents/.plugin.json first, then Claude, then
|
|
138
|
+
// Codex. Only matters when a single dir exposes more than one manifest.
|
|
139
|
+
const adg = findManifestFile(current);
|
|
140
|
+
const claude = join(current, CLAUDE_MANIFEST_PATH);
|
|
141
|
+
const codex = join(current, CODEX_MANIFEST_PATH);
|
|
142
|
+
if (adg) {
|
|
143
|
+
out.push({ dir: current, kind: "adg", manifestFile: adg });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (existsSync(claude)) {
|
|
147
|
+
out.push({ dir: current, kind: "anthropic", manifestFile: claude });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (existsSync(codex)) {
|
|
151
|
+
out.push({ dir: current, kind: "openai", manifestFile: codex });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
155
|
+
if (!entry.isDirectory()) continue;
|
|
156
|
+
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
157
|
+
walkNative(join(current, entry.name), out);
|
|
158
|
+
}
|
|
159
|
+
}
|