@rbbtsn0w/adg 0.3.0-beta.2 → 0.3.0-beta.3

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.
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { resolveSkills } from "../skills.js";
2
+ import { resolveProjectedSkills } from "../skills.js";
3
3
  import { isExposed } from "../components.js";
4
4
  /**
5
5
  * Generate a Claude (.claude-plugin/plugin.json) manifest from an ADG manifest.
@@ -31,24 +31,16 @@ export function toAnthropicManifest(pluginDir, manifest, selection) {
31
31
  out.hooks = manifest.hooks;
32
32
  if (manifest.mcp && isExposed(selection, "mcp"))
33
33
  out.mcp = manifest.mcp;
34
- if (selection) {
35
- // Partial install: always an explicit (possibly empty) skill list.
34
+ // Claude's array form is already `./skills/<id>` paths, so a strict array is
35
+ // passed through verbatim; an explicit id list (selection or strict:false) is
36
+ // mapped to paths and marks the manifest non-strict.
37
+ const projected = resolveProjectedSkills(pluginDir, manifest, selection, { passthroughArray: true });
38
+ if (projected.explicit) {
36
39
  out.strict = false;
37
- const names = isExposed(selection, "skills")
38
- ? selection.skills ?? resolveSkills(pluginDir, manifest)
39
- : [];
40
- out.skills = names.map((name) => `./skills/${name}`);
40
+ out.skills = projected.skills.map((name) => `./skills/${name}`);
41
41
  }
42
42
  else {
43
- const strict = manifest.strict !== false;
44
- if (strict && manifest.skills !== undefined) {
45
- out.skills = manifest.skills;
46
- }
47
- else {
48
- // skill-bundle form: explicit list, strict:false
49
- out.strict = false;
50
- out.skills = resolveSkills(pluginDir, manifest).map((name) => `./skills/${name}`);
51
- }
43
+ out.skills = projected.skills;
52
44
  }
53
45
  return { defaultPath: join(".claude-plugin", "plugin.json"), manifest: out };
54
46
  }
@@ -1,6 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { resolveSkills } from "../skills.js";
3
- import { isExposed } from "../components.js";
2
+ import { resolveProjectedSkills } from "../skills.js";
4
3
  /**
5
4
  * Generate a Codex (.codex-plugin/plugin.json) manifest from an ADG manifest.
6
5
  *
@@ -14,18 +13,9 @@ import { isExposed } from "../components.js";
14
13
  * rather than passed through. Codex only consumes skills.
15
14
  */
16
15
  export function toCodexManifest(pluginDir, manifest, selection) {
17
- let skills;
18
- if (selection) {
19
- skills = isExposed(selection, "skills")
20
- ? selection.skills ?? resolveSkills(pluginDir, manifest)
21
- : [];
22
- }
23
- else {
24
- const strict = manifest.strict !== false;
25
- skills = strict && typeof manifest.skills === "string"
26
- ? manifest.skills
27
- : resolveSkills(pluginDir, manifest);
28
- }
16
+ // Codex's array form is bare ids, so a strict skills *array* is resolved to
17
+ // ids rather than passed through; only a declared root string passes through.
18
+ const { skills } = resolveProjectedSkills(pluginDir, manifest, selection, { passthroughArray: false });
29
19
  const out = {
30
20
  name: manifest.name,
31
21
  version: manifest.version,
@@ -1,11 +1,13 @@
1
1
  import { toAnthropicManifest } from "./anthropic.js";
2
- import { toCodexManifest } from "./openai.js";
2
+ import { toCodexManifest } from "./codex.js";
3
3
  import { toAntigravityManifest } from "./antigravity.js";
4
+ // `anthropic` is kept as a synonym because Claude's plugin.json *is* the
5
+ // "anthropic" manifest shape. There is deliberately no `openai` key: the runtime
6
+ // is Codex, and an `openai` alias would imply OpenAI support that does not exist.
4
7
  export const ADAPTERS = {
5
8
  claude: toAnthropicManifest,
6
9
  anthropic: toAnthropicManifest,
7
10
  codex: toCodexManifest,
8
- openai: toCodexManifest,
9
11
  antigravity: toAntigravityManifest,
10
12
  agy: toAntigravityManifest,
11
13
  gemini: toAntigravityManifest,
@@ -1,5 +1,6 @@
1
1
  import { ADG_SCHEMA_VERSION } from "../types.js";
2
2
  import { validateManifest } from "../manifest.js";
3
+ import { toPosix } from "../fsutil.js";
3
4
  /**
4
5
  * Reverse-adapt a runtime-native manifest (.claude-plugin/plugin.json or
5
6
  * .codex-plugin/plugin.json) into a canonical ADG manifest. This is the inverse
@@ -7,8 +8,14 @@ import { validateManifest } from "../manifest.js";
7
8
  *
8
9
  * Missing `version` falls back to 0.0.0 (callers may override with a git SHA);
9
10
  * skills normalize to the manifest's array/string or the default ./skills/.
11
+ *
12
+ * `kind` disambiguates the two native skills-array conventions: Claude arrays are
13
+ * already `./skills/<id>` paths, while Codex arrays are bare ids. Both canonicalize
14
+ * to ADG's path-array contract so a later cross-adapt (e.g. codex → ADG → claude)
15
+ * emits valid `./skills/<id>` entries instead of leaking bare ids into a Claude
16
+ * manifest.
10
17
  */
11
- export function fromNativeManifest(raw, _kind) {
18
+ export function fromNativeManifest(raw, kind) {
12
19
  if (typeof raw !== "object" || raw === null) {
13
20
  throw new Error("native manifest must be a JSON object");
14
21
  }
@@ -35,9 +42,16 @@ export function fromNativeManifest(raw, _kind) {
35
42
  else if (typeof n.author === "string") {
36
43
  manifest.author = { name: n.author };
37
44
  }
38
- if (typeof n.skills === "string" || isStringArray(n.skills)) {
45
+ if (typeof n.skills === "string") {
39
46
  manifest.skills = n.skills;
40
47
  }
48
+ else if (isStringArray(n.skills)) {
49
+ // Codex arrays are bare ids; map them to ADG's `./skills/<id>` path form.
50
+ // Claude arrays are already paths, but a Windows-authored manifest may use
51
+ // backslashes, so normalize separators to keep ADG manifests POSIX-pathed
52
+ // (downstream `resolveSkillEntries` splits on `/`).
53
+ manifest.skills = kind === "codex" ? n.skills.map(toSkillPath) : n.skills.map(toPosix);
54
+ }
41
55
  else {
42
56
  manifest.skills = "./skills/";
43
57
  }
@@ -51,3 +65,12 @@ function copyIfString(src, dst, key) {
51
65
  function isStringArray(v) {
52
66
  return Array.isArray(v) && v.every((x) => typeof x === "string");
53
67
  }
68
+ /**
69
+ * Canonicalize a skill reference (bare id or path) to ADG's `./skills/<id>` form.
70
+ * Accepts both `/` and `\` separators so a Windows-authored native manifest
71
+ * (e.g. `skills\\foo`) still yields a valid `./skills/foo` entry.
72
+ */
73
+ function toSkillPath(ref) {
74
+ const name = ref.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || ref;
75
+ return `./skills/${name}`;
76
+ }
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join, relative } from "node:path";
5
- import { writeJson } from "../fsutil.js";
5
+ import { toPosix, writeJson } from "../fsutil.js";
6
6
  import { readManifest } from "../manifest.js";
7
7
  import { installedPluginDir, lockPath } from "../paths.js";
8
8
  import { readLock } from "../lock.js";
@@ -15,9 +15,6 @@ import { readLock } from "../lock.js";
15
15
  * versions) rather than hand-editing Claude's internal state.
16
16
  */
17
17
  const MARKETPLACE = "adg";
18
- function toPosix(p) {
19
- return p.split("\\").join("/");
20
- }
21
18
  function claudeHome(env) {
22
19
  return env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
23
20
  }
@@ -4,7 +4,7 @@ import { basename, join, relative, resolve } from "node:path";
4
4
  import { ADAPTER_TARGETS } from "../adapters/index.js";
5
5
  import { fromNativeManifest } from "../adapters/reverse.js";
6
6
  import { adaptPlugin } from "./adapt.js";
7
- import { copyPluginDir, writeJson } from "../fsutil.js";
7
+ import { copyPluginDir, toPosix, writeJson } from "../fsutil.js";
8
8
  import { folderHash } from "../hash.js";
9
9
  import { packageFilter, PROJECTION_DIRS } from "../package.js";
10
10
  import { lockPath, marketplacePath, marketplaceSourcePath, pluginDir } from "../paths.js";
@@ -19,9 +19,6 @@ import { skillDescriptionLoader } from "../skills.js";
19
19
  import { resolveAgents } from "../agents/index.js";
20
20
  // Generated runtime projections never count toward a plugin's content hash.
21
21
  const HASH_IGNORE = PROJECTION_DIRS;
22
- function toPosix(p) {
23
- return p.split("\\").join("/");
24
- }
25
22
  /**
26
23
  * Install a single local plugin directory into a plugins directory: copy the
27
24
  * source, generate adapter manifests, compute the folder hash, and update both
@@ -3,6 +3,10 @@ import { dirname, relative } from "node:path";
3
3
  export function ensureDir(dir) {
4
4
  mkdirSync(dir, { recursive: true });
5
5
  }
6
+ /** Normalize a path to forward slashes (stable across Windows and POSIX hosts). */
7
+ export function toPosix(p) {
8
+ return p.split("\\").join("/");
9
+ }
6
10
  export function writeJson(file, value) {
7
11
  ensureDir(dirname(file));
8
12
  writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
@@ -19,6 +19,82 @@ export function compare(a, b) {
19
19
  }
20
20
  return 0;
21
21
  }
22
+ /**
23
+ * Extract the dot-separated pre-release identifiers from a version string.
24
+ *
25
+ * Returns the segment after the first `-` (and before any `+` build metadata),
26
+ * with numeric-only identifiers coerced to `number`. A stable version (no
27
+ * pre-release) yields `[]`.
28
+ */
29
+ export function parsePrerelease(v) {
30
+ // Drop build metadata first so a hyphen inside it (e.g. `1.2.3+build-1`) is
31
+ // not mistaken for the pre-release separator.
32
+ const withoutBuild = v.split("+")[0] ?? "";
33
+ const dashIndex = withoutBuild.indexOf("-");
34
+ if (dashIndex === -1)
35
+ return [];
36
+ const pre = withoutBuild.slice(dashIndex + 1);
37
+ if (pre === "")
38
+ return [];
39
+ return pre.split(".").map((id) => (/^\d+$/.test(id) ? Number(id) : id));
40
+ }
41
+ /**
42
+ * Compare two pre-release identifier lists per SemVer §11.
43
+ *
44
+ * A stable version (empty list) outranks any pre-release. Otherwise identifiers
45
+ * are compared left to right: numeric identifiers rank below alphanumeric ones,
46
+ * numerics compare numerically, strings compare by ASCII, and when every shared
47
+ * field is equal the longer list wins.
48
+ */
49
+ export function comparePrerelease(a, b) {
50
+ if (a.length === 0 && b.length === 0)
51
+ return 0;
52
+ if (a.length === 0)
53
+ return 1; // stable > pre-release
54
+ if (b.length === 0)
55
+ return -1;
56
+ const len = Math.min(a.length, b.length);
57
+ for (let i = 0; i < len; i++) {
58
+ const x = a[i];
59
+ const y = b[i];
60
+ if (x === y)
61
+ continue;
62
+ const xNum = typeof x === "number";
63
+ const yNum = typeof y === "number";
64
+ if (xNum && yNum)
65
+ return x < y ? -1 : 1;
66
+ if (xNum !== yNum)
67
+ return xNum ? -1 : 1; // numeric < alphanumeric
68
+ return x < y ? -1 : 1;
69
+ }
70
+ // All shared fields equal: the list with more fields has higher precedence.
71
+ return a.length === b.length ? 0 : a.length < b.length ? -1 : 1;
72
+ }
73
+ /**
74
+ * Full version comparison including pre-release precedence.
75
+ *
76
+ * Unlike {@link compare}, this honors the pre-release suffix so that, e.g.,
77
+ * `0.3.0-beta.2 < 0.3.0-beta.3 < 0.3.0`. Used by the update check; range
78
+ * matching ({@link satisfies}) deliberately ignores pre-release.
79
+ */
80
+ export function compareVersions(a, b) {
81
+ const core = compare(parseVersion(a), parseVersion(b));
82
+ if (core !== 0)
83
+ return core;
84
+ return comparePrerelease(parsePrerelease(a), parsePrerelease(b));
85
+ }
86
+ /**
87
+ * Return the pre-release channel of a version — its first non-numeric
88
+ * pre-release identifier (e.g. `0.3.0-beta.2` -> `"beta"`), or `null` for a
89
+ * stable version. Used to pick the matching npm dist-tag.
90
+ */
91
+ export function prereleaseChannel(v) {
92
+ for (const id of parsePrerelease(v)) {
93
+ if (typeof id === "string")
94
+ return id;
95
+ }
96
+ return null;
97
+ }
22
98
  export function satisfies(version, range) {
23
99
  const v = parseVersion(version);
24
100
  const r = range.trim();
@@ -42,6 +42,37 @@ export function resolveSkillEntries(pluginDir, manifest) {
42
42
  export function resolveSkills(pluginDir, manifest) {
43
43
  return resolveSkillEntries(pluginDir, manifest).map((e) => e.name);
44
44
  }
45
+ /**
46
+ * Decide the skills a forward adapter should project from an ADG manifest, the
47
+ * one strict/selection decision both adapters share (previously copy-pasted, so
48
+ * it could drift — see the Codex/Claude strict regression that motivated it).
49
+ *
50
+ * In the default strict case the declared skills *root* string is passed through
51
+ * (the runtime discovers skills from the directory); an omitted `skills` is
52
+ * treated as the default `./skills/` root so the strict default stays
53
+ * directory-discovery rather than an explicit enumeration. A `selection` or
54
+ * `strict: false` always yields an explicit resolved id list. A declared `skills`
55
+ * *array* is runtime-dependent: `passthroughArray` keeps it verbatim (Claude,
56
+ * whose entries are already `./skills/<id>` paths), otherwise it resolves to ids
57
+ * (Codex, whose array form is bare ids).
58
+ */
59
+ export function resolveProjectedSkills(pluginDir, manifest, selection, opts) {
60
+ if (selection) {
61
+ const skills = selection.components.includes("skills")
62
+ ? selection.skills ?? resolveSkills(pluginDir, manifest)
63
+ : [];
64
+ return { skills, explicit: true };
65
+ }
66
+ const strict = manifest.strict !== false;
67
+ // An omitted `skills` defaults to the `./skills/` root, so a strict manifest
68
+ // without a declaration keeps directory discovery instead of being forced to
69
+ // an explicit `strict: false` enumeration.
70
+ const declared = manifest.skills ?? "./skills/";
71
+ const passthrough = strict && (typeof declared === "string" || (opts.passthroughArray && Array.isArray(declared)));
72
+ if (passthrough)
73
+ return { skills: declared, explicit: false };
74
+ return { skills: resolveSkills(pluginDir, manifest), explicit: true };
75
+ }
45
76
  /** Read a SKILL.md's `description` from its YAML frontmatter (undefined if absent). */
46
77
  export function readSkillDescription(skillMd) {
47
78
  try {
@@ -105,11 +105,11 @@ function walkNative(current, out) {
105
105
  return;
106
106
  }
107
107
  if (existsSync(claude)) {
108
- out.push({ dir: current, kind: "anthropic", manifestFile: claude });
108
+ out.push({ dir: current, kind: "claude", manifestFile: claude });
109
109
  return;
110
110
  }
111
111
  if (existsSync(codex)) {
112
- out.push({ dir: current, kind: "openai", manifestFile: codex });
112
+ out.push({ dir: current, kind: "codex", manifestFile: codex });
113
113
  return;
114
114
  }
115
115
  for (const entry of readdirSync(current, { withFileTypes: true })) {
@@ -10,12 +10,20 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import https from "node:https";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
- import { compare, parseVersion } from "./semver.js";
13
+ import { compareVersions, prereleaseChannel } from "./semver.js";
14
14
  const PACKAGE_NAME = "@rbbtsn0w/adg";
15
15
  // URL-encode the slash in the scoped package name for the npm registry API.
16
- const REGISTRY_URL = `https://registry.npmjs.org/@rbbtsn0w%2Fadg/latest`;
16
+ // The abbreviated packument (vnd.npm.install-v1+json) is small and exposes
17
+ // `dist-tags`, which we need to follow the caller's release channel (e.g. the
18
+ // `beta` dist-tag for pre-release users, not just `latest`).
19
+ const REGISTRY_URL = `https://registry.npmjs.org/@rbbtsn0w%2Fadg`;
20
+ const REGISTRY_ACCEPT = "application/vnd.npm.install-v1+json";
17
21
  const CACHE_FILENAME = "update-check.json";
18
22
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
23
+ // Cap the accumulated response body. The abbreviated packument is small, but a
24
+ // registry that ignores the abbreviated Accept header (or returns an unexpected
25
+ // payload) could stream a much larger body; abort rather than grow unbounded.
26
+ const MAX_RESPONSE_BYTES = 1024 * 1024; // 1 MiB
19
27
  /** Resolve the directory that holds the update-check cache file. */
20
28
  export function updateCacheDir(env = process.env) {
21
29
  const stateHome = env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
@@ -46,6 +54,29 @@ export function writeUpdateCache(cache, env = process.env) {
46
54
  // Ignore write errors (read-only FS, permissions, etc.)
47
55
  }
48
56
  }
57
+ /**
58
+ * Pick the newest version relevant to the caller's release channel from the
59
+ * registry's `dist-tags`.
60
+ *
61
+ * Always considers `latest` (stable). When `currentVersion` is a pre-release
62
+ * (e.g. `0.3.0-beta.2`) it also considers the matching channel tag (e.g.
63
+ * `beta`), so pre-release users are notified of newer pre-releases as well as a
64
+ * newer stable. Returns the max candidate by pre-release-aware comparison, or
65
+ * `undefined` when no usable tag is present.
66
+ */
67
+ export function resolveLatestForChannel(currentVersion, distTags) {
68
+ if (!distTags)
69
+ return undefined;
70
+ const candidates = [];
71
+ if (typeof distTags.latest === "string")
72
+ candidates.push(distTags.latest);
73
+ const channel = prereleaseChannel(currentVersion);
74
+ if (channel && typeof distTags[channel] === "string")
75
+ candidates.push(distTags[channel]);
76
+ if (candidates.length === 0)
77
+ return undefined;
78
+ return candidates.reduce((best, v) => (compareVersions(v, best) > 0 ? v : best));
79
+ }
49
80
  /**
50
81
  * Fire-and-forget background fetch of the latest version from the npm registry.
51
82
  * The socket is unreffed so Node can exit naturally without waiting for the
@@ -53,14 +84,26 @@ export function writeUpdateCache(cache, env = process.env) {
53
84
  */
54
85
  export function scheduleUpdateCacheRefresh(currentVersion, env = process.env) {
55
86
  try {
56
- const req = https.get(REGISTRY_URL, { headers: { "User-Agent": `adg/${currentVersion}`, Accept: "application/json" } }, (res) => {
87
+ const req = https.get(REGISTRY_URL, { headers: { "User-Agent": `adg/${currentVersion}`, Accept: REGISTRY_ACCEPT } }, (res) => {
57
88
  let body = "";
58
- res.on("data", (chunk) => { body += String(chunk); });
89
+ let byteCount = 0;
90
+ res.on("data", (chunk) => {
91
+ byteCount += Buffer.byteLength(chunk);
92
+ if (byteCount > MAX_RESPONSE_BYTES) {
93
+ // Oversized payload: stop reading and abort so we neither buffer
94
+ // unbounded memory nor parse a partial body.
95
+ body = "";
96
+ req.destroy();
97
+ return;
98
+ }
99
+ body += String(chunk);
100
+ });
59
101
  res.on("end", () => {
60
102
  try {
61
103
  const data = JSON.parse(body);
62
- if (typeof data.version === "string") {
63
- writeUpdateCache({ latestVersion: data.version, checkedAt: new Date().toISOString() }, env);
104
+ const latestVersion = resolveLatestForChannel(currentVersion, data["dist-tags"]);
105
+ if (latestVersion !== undefined) {
106
+ writeUpdateCache({ latestVersion, checkedAt: new Date().toISOString() }, env);
64
107
  }
65
108
  }
66
109
  catch {
@@ -103,9 +146,7 @@ export function checkForUpdate(currentVersion, env = process.env, refresh = sche
103
146
  if (!cache)
104
147
  return undefined;
105
148
  try {
106
- const current = parseVersion(currentVersion);
107
- const latest = parseVersion(cache.latestVersion);
108
- return compare(latest, current) > 0 ? cache.latestVersion : undefined;
149
+ return compareVersions(cache.latestVersion, currentVersion) > 0 ? cache.latestVersion : undefined;
109
150
  }
110
151
  catch {
111
152
  return undefined;
@@ -113,6 +154,11 @@ export function checkForUpdate(currentVersion, env = process.env, refresh = sche
113
154
  }
114
155
  /** Format an update notice for display on stderr. */
115
156
  export function formatUpdateNotice(currentVersion, latestVersion) {
157
+ // A pre-release suggestion lives on its channel dist-tag (e.g. `beta`), not
158
+ // `latest`; installing `@latest` would pull the stable release instead of the
159
+ // advertised version. Pin to the exact version so the right artifact installs.
160
+ const channel = prereleaseChannel(latestVersion);
161
+ const installTarget = channel ? latestVersion : "latest";
116
162
  return (`\n Update available: ${currentVersion} → ${latestVersion}\n` +
117
- ` Run: npm install -g ${PACKAGE_NAME}@latest\n`);
163
+ ` Run: npm install -g ${PACKAGE_NAME}@${installTarget}\n`);
118
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rbbtsn0w/adg",
3
- "version": "0.3.0-beta.2",
3
+ "version": "0.3.0-beta.3",
4
4
  "description": "Agent Directory Group (ADG) toolkit — two domains: plugins and skills.",
5
5
  "type": "module",
6
6
  "license": "MIT",