📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -41,7 +41,9 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
let riskArg = null;
|
||||
let categoryArg = null;
|
||||
let tagsArg = null;
|
||||
let skillsArg = null;
|
||||
let versionInfo = false;
|
||||
let dryRun = false;
|
||||
let cursor = false,
|
||||
claude = false,
|
||||
gemini = false,
|
||||
@@ -56,7 +58,7 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
versionInfo = true;
|
||||
continue;
|
||||
}
|
||||
if (["--path", "--release", "--tag", "--risk", "--category", "--tags"].includes(a[i])) {
|
||||
if (["--path", "--release", "--tag", "--risk", "--category", "--tags", "--skills"].includes(a[i])) {
|
||||
const value = a[i + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`Option ${a[i]} requires a value.`);
|
||||
@@ -67,9 +69,14 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
if (a[i] === "--risk") riskArg = value;
|
||||
if (a[i] === "--category") categoryArg = value;
|
||||
if (a[i] === "--tags") tagsArg = value;
|
||||
if (a[i] === "--skills") skillsArg = value;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (a[i] === "--dry-run") {
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (a[i] === "--cursor") {
|
||||
cursor = true;
|
||||
continue;
|
||||
@@ -109,7 +116,9 @@ function parseArgs(argv = process.argv.slice(2)) {
|
||||
riskArg,
|
||||
categoryArg,
|
||||
tagsArg,
|
||||
skillsArg,
|
||||
versionInfo,
|
||||
dryRun,
|
||||
cursor,
|
||||
claude,
|
||||
gemini,
|
||||
@@ -179,6 +188,8 @@ Options:
|
||||
--risk <csv> Install only skills matching these risk labels
|
||||
--category <csv> Install only skills matching these categories
|
||||
--tags <csv> Install only skills matching these tags
|
||||
--skills <csv> Set exact managed skill names, ids, or nested skill paths
|
||||
--dry-run Preview installs/updates/removals for every target without writing
|
||||
--version Print the installer version
|
||||
--release <ver> Clone tag v<ver> (e.g. 4.6.0 -> v4.6.0)
|
||||
--tag <tag> Clone this tag or branch (e.g. v4.6.0, main)
|
||||
@@ -191,6 +202,7 @@ Examples:
|
||||
npx agentic-awesome-skills --agy
|
||||
npx agentic-awesome-skills --path .agents/skills --category development,backend --risk safe,none
|
||||
npx agentic-awesome-skills --path .agents/skills --tags debugging,typescript-legacy-
|
||||
npx agentic-awesome-skills --codex --skills frontend-design,game-development/2d-games --dry-run
|
||||
npx agentic-awesome-skills --release 4.6.0
|
||||
npx agentic-awesome-skills --path ./my-skills
|
||||
npx agentic-awesome-skills --claude --codex Install to multiple targets
|
||||
@@ -230,6 +242,19 @@ function parseSelectorArg(raw) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseExactSkillArg(raw) {
|
||||
if (typeof raw !== "string" || !raw.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = raw.split(",").map((value) => value.trim());
|
||||
if (values.some((value) => !value)) {
|
||||
throw new Error("--skills must be a comma-separated list of non-empty exact skill names, ids, or paths.");
|
||||
}
|
||||
|
||||
return uniqueValues(values);
|
||||
}
|
||||
|
||||
function hasActiveSelector(selector) {
|
||||
return selector.include.length > 0 || selector.exclude.length > 0;
|
||||
}
|
||||
@@ -422,21 +447,51 @@ function replaceManagedEntry(
|
||||
}
|
||||
|
||||
/** Copy contents of repo's skills/ into target so each skill is target/skill-name/ (for Claude Code etc.). */
|
||||
function getInstallEntries(tempDir, selectors = buildInstallSelectors({})) {
|
||||
function resolveExactSkillSelections(repoSkills, skillEntries, requestedSkills = []) {
|
||||
if (requestedSkills.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skills = skillEntries.map((skillId) => readSkill(repoSkills, skillId));
|
||||
const resolvedEntries = new Set();
|
||||
|
||||
for (const requestedSkill of requestedSkills) {
|
||||
const matches = skills.filter((skill) => (
|
||||
skill.name === requestedSkill ||
|
||||
skill.id === requestedSkill ||
|
||||
path.basename(skill.id) === requestedSkill
|
||||
));
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`Unknown skill requested by --skills: ${requestedSkill}`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`Ambiguous skill requested by --skills: ${requestedSkill}. Use one exact nested skill path: ${matches.map((skill) => skill.id).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
resolvedEntries.add(matches[0].id);
|
||||
}
|
||||
|
||||
return resolvedEntries;
|
||||
}
|
||||
|
||||
function getInstallEntries(tempDir, selectors = buildInstallSelectors({}), requestedSkills = []) {
|
||||
const repoSkills = path.join(tempDir, "skills");
|
||||
if (!fs.existsSync(repoSkills)) {
|
||||
console.error("Cloned repo has no skills/ directory.");
|
||||
process.exit(1);
|
||||
throw new Error("Cloned repo has no skills/ directory.");
|
||||
}
|
||||
|
||||
const skillEntries = listSkillIdsRecursive(repoSkills);
|
||||
const filteredEntries = hasInstallSelectors(selectors)
|
||||
? skillEntries.filter((skillId) => matchesInstallSelectors(readSkill(repoSkills, skillId), selectors))
|
||||
: skillEntries;
|
||||
const selectedEntries = resolveExactSkillSelections(repoSkills, skillEntries, requestedSkills);
|
||||
const filteredEntries = skillEntries.filter((skillId) => (
|
||||
(!selectedEntries || selectedEntries.has(skillId)) &&
|
||||
(!hasInstallSelectors(selectors) || matchesInstallSelectors(readSkill(repoSkills, skillId), selectors))
|
||||
));
|
||||
|
||||
if (hasInstallSelectors(selectors) && filteredEntries.length === 0) {
|
||||
console.error("No skills matched the requested --risk/--category/--tags filters.");
|
||||
process.exit(1);
|
||||
if ((selectedEntries || hasInstallSelectors(selectors)) && filteredEntries.length === 0) {
|
||||
throw new Error("No skills matched the requested --skills/--risk/--category/--tags selection.");
|
||||
}
|
||||
|
||||
const entries = [...filteredEntries];
|
||||
@@ -550,6 +605,22 @@ function readInstallManifest(targetPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeManifestEntries(entries) {
|
||||
const normalized = [];
|
||||
const invalid = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
normalized.push(normalizeInstallEntry(entry));
|
||||
} catch (error) {
|
||||
invalid.push(entry);
|
||||
}
|
||||
}
|
||||
return {
|
||||
entries: uniqueValues(normalized).sort(),
|
||||
invalid: uniqueValues(invalid).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function writeInstallManifest(targetPath, installEntries) {
|
||||
const manifestPath = resolveInstallManifestPath(targetPath);
|
||||
const normalizedEntries = [...new Set(installEntries.map(normalizeInstallEntry).filter(Boolean))].sort();
|
||||
@@ -572,14 +643,17 @@ function writeInstallManifest(targetPath, installEntries) {
|
||||
|
||||
function pruneRemovedEntries(targetPath, previousEntries, installEntries) {
|
||||
const next = new Set(installEntries.map(normalizeInstallEntry));
|
||||
for (const entry of previousEntries) {
|
||||
const normalizedEntry = normalizeInstallEntry(entry);
|
||||
const normalizedPrevious = normalizeManifestEntries(previousEntries);
|
||||
for (const entry of normalizedPrevious.invalid) {
|
||||
console.warn(` Skipping unsafe managed entry path from manifest: ${entry}`);
|
||||
}
|
||||
for (const normalizedEntry of normalizedPrevious.entries) {
|
||||
if (next.has(normalizedEntry)) {
|
||||
continue;
|
||||
}
|
||||
const candidate = resolveManagedPath(targetPath, entry);
|
||||
const candidate = resolveManagedPath(targetPath, normalizedEntry);
|
||||
if (!candidate) {
|
||||
console.warn(` Skipping unsafe managed entry path from manifest: ${entry}`);
|
||||
console.warn(` Skipping unsafe managed entry path from manifest: ${normalizedEntry}`);
|
||||
continue;
|
||||
}
|
||||
assertSafeDestinationPath(candidate, targetPath);
|
||||
@@ -658,7 +732,16 @@ function resolveInstallRef(opts) {
|
||||
return DEFAULT_RELEASE_REF;
|
||||
}
|
||||
|
||||
function installForTarget(tempDir, target, selectors = buildInstallSelectors({})) {
|
||||
function installForTarget(
|
||||
tempDir,
|
||||
target,
|
||||
selectors = buildInstallSelectors({}),
|
||||
installEntries = null,
|
||||
requestedSkills = [],
|
||||
) {
|
||||
// Resolve all selection errors before creating or changing the target.
|
||||
const resolvedInstallEntries = installEntries || getInstallEntries(tempDir, selectors, requestedSkills);
|
||||
|
||||
if (fs.existsSync(target.path)) {
|
||||
ensureTargetIsDirectory(target.path);
|
||||
const targetStats = fs.lstatSync(target.path);
|
||||
@@ -698,10 +781,9 @@ function installForTarget(tempDir, target, selectors = buildInstallSelectors({})
|
||||
fs.mkdirSync(target.path, { recursive: true });
|
||||
}
|
||||
|
||||
const installEntries = getInstallEntries(tempDir, selectors);
|
||||
const managedEntries = getManagedEntries(installEntries, target);
|
||||
const managedEntries = getManagedEntries(resolvedInstallEntries, target);
|
||||
const previousEntries = readInstallManifest(target.path);
|
||||
installSkillsIntoTarget(tempDir, target.path, installEntries);
|
||||
installSkillsIntoTarget(tempDir, target.path, resolvedInstallEntries);
|
||||
pruneRemovedEntries(target.path, previousEntries, managedEntries);
|
||||
writeInstallManifest(target.path, managedEntries);
|
||||
console.log(` ✓ Installed to ${target.path}`);
|
||||
@@ -737,7 +819,7 @@ function getPostInstallMessages(targets, selectors = buildInstallSelectors({}))
|
||||
|
||||
if (targets.some((target) => isOpenCodeStylePath(target.path))) {
|
||||
const baseMessage =
|
||||
"For Antigravity 2.0, OpenCode, or other .agents/skills installs, prefer a reduced install with --risk, --category, or --tags to avoid context overload.";
|
||||
"For Antigravity 2.0, OpenCode, or other .agents/skills installs, prefer a reduced install with --skills, --risk, --category, or --tags to avoid context overload.";
|
||||
messages.push(baseMessage);
|
||||
if (!hasInstallSelectors(selectors)) {
|
||||
messages.push(
|
||||
@@ -749,6 +831,82 @@ function getPostInstallMessages(targets, selectors = buildInstallSelectors({}))
|
||||
return messages;
|
||||
}
|
||||
|
||||
function buildDryRunTargetPlan(target, installEntries) {
|
||||
const desiredEntries = uniqueValues(getManagedEntries(installEntries, target)).sort();
|
||||
const targetExists = fs.existsSync(target.path);
|
||||
|
||||
if (targetExists) {
|
||||
const stats = fs.lstatSync(target.path);
|
||||
if (stats.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to preview through symlinked target: ${target.path}`);
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Install path exists but is not a directory: ${target.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const previous = normalizeManifestEntries(readInstallManifest(target.path));
|
||||
const desiredSet = new Set(desiredEntries);
|
||||
const remove = previous.entries.filter((entry) => !desiredSet.has(entry));
|
||||
|
||||
// Match the apply-time destination checks without creating any path. This
|
||||
// makes the preview fail before a later install could encounter a symlinked
|
||||
// managed destination or an unsafe stale manifest entry.
|
||||
for (const entry of [...desiredEntries, ...remove]) {
|
||||
const candidate = resolveManagedPath(target.path, entry);
|
||||
if (!candidate) {
|
||||
throw new Error(`Refusing unsafe managed entry in dry-run plan: ${entry}`);
|
||||
}
|
||||
assertSafeDestinationPath(candidate, target.path);
|
||||
}
|
||||
|
||||
return {
|
||||
name: target.name,
|
||||
path: target.path,
|
||||
targetExists,
|
||||
replacesRepositoryClone: targetExists && fs.existsSync(path.join(target.path, ".git")),
|
||||
installOrUpdate: desiredEntries,
|
||||
remove,
|
||||
ignoredUnsafeManifestEntries: previous.invalid,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDryRunPlan(ref, targets, installEntries) {
|
||||
return {
|
||||
ref: ref || "default release",
|
||||
targets: targets.map((target) => buildDryRunTargetPlan(target, installEntries)),
|
||||
skills: installEntries.filter((entry) => entry !== "docs").sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function printDryRunPlan(plan) {
|
||||
console.log("\nDry run: no target files or directories will be created, changed, or removed.");
|
||||
console.log(`Ref: ${plan.ref}`);
|
||||
console.log(`Exact skill set (${plan.skills.length}):`);
|
||||
for (const skill of plan.skills) {
|
||||
console.log(` ${skill}`);
|
||||
}
|
||||
console.log("Target mutation plans:");
|
||||
for (const target of plan.targets) {
|
||||
console.log(` ${target.name}: ${target.path}`);
|
||||
console.log(` target: ${target.targetExists ? "existing directory" : "will be created"}`);
|
||||
if (target.replacesRepositoryClone) {
|
||||
console.log(" migration: existing repository clone will be backed up and replaced");
|
||||
}
|
||||
console.log(` install/update managed entries (${target.installOrUpdate.length}):`);
|
||||
for (const entry of target.installOrUpdate) {
|
||||
console.log(` ${entry}`);
|
||||
}
|
||||
console.log(` remove stale managed entries (${target.remove.length}):`);
|
||||
for (const entry of target.remove) {
|
||||
console.log(` ${entry}`);
|
||||
}
|
||||
for (const entry of target.ignoredUnsafeManifestEntries) {
|
||||
console.log(` ignored unsafe manifest entry: ${entry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
let opts;
|
||||
try {
|
||||
@@ -759,6 +917,14 @@ function main() {
|
||||
return;
|
||||
}
|
||||
const selectors = buildInstallSelectors(opts);
|
||||
let requestedSkills;
|
||||
try {
|
||||
requestedSkills = parseExactSkillArg(opts.skillsArg);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ref = resolveInstallRef(opts);
|
||||
|
||||
if (opts.help) {
|
||||
@@ -788,10 +954,38 @@ function main() {
|
||||
}
|
||||
run("git", buildCloneArgs(REPO, tempDir, ref));
|
||||
|
||||
// Resolve the exact set once before touching any target. This keeps an
|
||||
// unknown/ambiguous --skills value or an empty filter intersection atomic
|
||||
// across multi-target installs.
|
||||
let installEntries;
|
||||
try {
|
||||
installEntries = getInstallEntries(tempDir, selectors, requestedSkills);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Preflight every target before mutating the first one. The same plan is
|
||||
// printed for --dry-run and acts as the multi-target safety gate for apply.
|
||||
let dryRunPlan;
|
||||
try {
|
||||
dryRunPlan = buildDryRunPlan(ref, targets, installEntries);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
printDryRunPlan(dryRunPlan);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nInstalling for ${targets.length} target(s):`);
|
||||
for (const target of targets) {
|
||||
console.log(`\n${target.name}:`);
|
||||
installForTarget(tempDir, target, selectors);
|
||||
installForTarget(tempDir, target, selectors, installEntries);
|
||||
}
|
||||
|
||||
for (const message of getPostInstallMessages(targets, selectors)) {
|
||||
@@ -821,6 +1015,8 @@ module.exports = {
|
||||
replaceManagedEntry,
|
||||
getPostInstallMessages,
|
||||
buildCloneArgs,
|
||||
buildDryRunPlan,
|
||||
buildDryRunTargetPlan,
|
||||
buildInstallSelectors,
|
||||
getInstallEntries,
|
||||
getManagedEntries,
|
||||
@@ -831,10 +1027,14 @@ module.exports = {
|
||||
main,
|
||||
matchesInstallSelectors,
|
||||
normalizeInstallEntry,
|
||||
normalizeManifestEntries,
|
||||
parseExactSkillArg,
|
||||
parseSelectorArg,
|
||||
printDryRunPlan,
|
||||
parseArgs,
|
||||
pruneRemovedEntries,
|
||||
readInstallManifest,
|
||||
resolveExactSkillSelections,
|
||||
resolveInstallRef,
|
||||
writeInstallManifest,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"mixedFiles": [
|
||||
"README.md",
|
||||
"package.json",
|
||||
"apps/web-app/index.html",
|
||||
"apps/web-app/public/llms.txt",
|
||||
"docs/users/getting-started.md",
|
||||
"docs/users/bundles.md",
|
||||
"docs/users/claude-code-skills.md",
|
||||
|
||||
@@ -831,6 +831,18 @@ def coerce_metadata_text(value):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def coerce_metadata_list(value):
|
||||
if isinstance(value, set):
|
||||
values = [coerce_metadata_text(item) for item in sorted(value, key=str)]
|
||||
elif isinstance(value, (list, tuple)):
|
||||
values = [coerce_metadata_text(item) for item in value]
|
||||
elif isinstance(value, str):
|
||||
values = value.split(",") if "," in value else value.split()
|
||||
else:
|
||||
return []
|
||||
return list(dict.fromkeys(item.strip() for item in values if item and item.strip()))
|
||||
|
||||
def parse_frontmatter(content):
|
||||
"""
|
||||
Parses YAML frontmatter, sanitizing unquoted values containing @.
|
||||
@@ -936,6 +948,11 @@ def generate_index(skills_dir, output_file, compatibility_report=None):
|
||||
license_source = coerce_metadata_text(metadata.get("license_source"))
|
||||
date_added = coerce_metadata_text(metadata.get("date_added"))
|
||||
category = coerce_metadata_text(metadata.get("category"))
|
||||
tags_value = metadata.get("tags")
|
||||
nested_metadata = metadata.get("metadata")
|
||||
if tags_value is None and isinstance(nested_metadata, Mapping):
|
||||
tags_value = nested_metadata.get("tags")
|
||||
tags = coerce_metadata_list(tags_value)
|
||||
|
||||
if name is not None:
|
||||
skill_info["name"] = name
|
||||
@@ -955,6 +972,8 @@ def generate_index(skills_dir, output_file, compatibility_report=None):
|
||||
skill_info["license_source"] = license_source
|
||||
if date_added is not None:
|
||||
skill_info["date_added"] = date_added
|
||||
if tags:
|
||||
skill_info["tags"] = tags
|
||||
|
||||
# Category: prefer frontmatter, then folder structure, then conservative inference
|
||||
if category is not None:
|
||||
|
||||
@@ -174,12 +174,15 @@ function prepareRelease(projectRoot, version) {
|
||||
console.log(`[release] package.json already set to ${version}; keeping current version.`);
|
||||
}
|
||||
|
||||
runReleaseSuite(projectRoot);
|
||||
runCommand(
|
||||
"npm",
|
||||
["run", "sync:metadata", "--", "--refresh-volatile"],
|
||||
projectRoot,
|
||||
);
|
||||
// Volatile metadata is an input to catalog timestamps. Refresh it before the
|
||||
// canonical release sync so the tagged tree is exactly what publish CI will
|
||||
// regenerate and verify.
|
||||
runReleaseSuite(projectRoot);
|
||||
|
||||
const refreshedReleaseNotes = ensureChangelogSection(projectRoot, version);
|
||||
const notesPath = writeReleaseNotes(projectRoot, version, refreshedReleaseNotes);
|
||||
|
||||
@@ -655,6 +655,238 @@ def _materialize_plugin_skills(root: Path, destination_root: Path, skill_ids: li
|
||||
_copy_skill_directory(root, skill_id, destination_root)
|
||||
|
||||
|
||||
def _skill_tree_files(root: Path, skill_ids: list[str]) -> dict[str, Path]:
|
||||
files: dict[str, Path] = {}
|
||||
skills_root = root / "skills"
|
||||
resolved_skills_root = skills_root.resolve()
|
||||
|
||||
def collect(source_path: Path, relative_path: Path) -> None:
|
||||
resolved_source = source_path.resolve(strict=True)
|
||||
resolved_source.relative_to(resolved_skills_root)
|
||||
if resolved_source.is_dir():
|
||||
for child in resolved_source.iterdir():
|
||||
collect(child, relative_path / child.name)
|
||||
return
|
||||
files[relative_path.as_posix()] = resolved_source
|
||||
|
||||
for skill_id in skill_ids:
|
||||
source_root = skills_root / skill_id
|
||||
if not source_root.is_dir():
|
||||
raise ValueError(f"Expected canonical skill directory is missing: {skill_id}")
|
||||
for child in source_root.iterdir():
|
||||
collect(child, Path(skill_id) / child.name)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def _assert_skill_mirror_matches(
|
||||
root: Path,
|
||||
destination_root: Path,
|
||||
skill_ids: list[str],
|
||||
label: str,
|
||||
) -> None:
|
||||
if destination_root.is_symlink():
|
||||
raise ValueError(f"{label} skills directory must not be a symlink: {destination_root}")
|
||||
if not destination_root.is_dir():
|
||||
raise ValueError(f"{label} skills directory is missing: {destination_root}")
|
||||
|
||||
expected_files = _skill_tree_files(root, skill_ids)
|
||||
actual_files = {
|
||||
path.relative_to(destination_root).as_posix(): path
|
||||
for path in destination_root.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
mirrored_symlinks = sorted(
|
||||
path.relative_to(destination_root).as_posix()
|
||||
for path in destination_root.rglob("*")
|
||||
if path.is_symlink()
|
||||
)
|
||||
if mirrored_symlinks:
|
||||
raise ValueError(f"{label} contains unexpected symlink: {mirrored_symlinks[0]}")
|
||||
expected_paths = set(expected_files)
|
||||
actual_paths = set(actual_files)
|
||||
|
||||
missing = sorted(expected_paths - actual_paths)
|
||||
if missing:
|
||||
raise ValueError(f"{label} is missing mirrored file: {missing[0]}")
|
||||
|
||||
unexpected = sorted(actual_paths - expected_paths)
|
||||
if unexpected:
|
||||
raise ValueError(f"{label} contains unexpected mirrored file: {unexpected[0]}")
|
||||
|
||||
for relative_path in sorted(expected_paths):
|
||||
if expected_files[relative_path].read_bytes() != actual_files[relative_path].read_bytes():
|
||||
raise ValueError(f"{label} contains stale mirrored file: {relative_path}")
|
||||
|
||||
|
||||
def _assert_json_matches(
|
||||
path: Path,
|
||||
expected: dict[str, Any],
|
||||
label: str,
|
||||
allowed_root: Path,
|
||||
) -> None:
|
||||
relative_path = path.relative_to(allowed_root)
|
||||
current_path = allowed_root
|
||||
for part in relative_path.parts:
|
||||
current_path /= part
|
||||
if current_path.is_symlink():
|
||||
raise ValueError(f"{label} path must not contain a symlink: {current_path}")
|
||||
if not path.is_file():
|
||||
raise ValueError(f"{label} is missing: {path}")
|
||||
expected_content = (json.dumps(expected, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
if path.read_bytes() != expected_content:
|
||||
raise ValueError(f"{label} is out of sync: {path}")
|
||||
|
||||
|
||||
def _assert_plugin_metadata_layout(
|
||||
plugin_root: Path,
|
||||
expected_relative_paths: set[str],
|
||||
label: str,
|
||||
) -> None:
|
||||
if plugin_root.is_symlink():
|
||||
raise ValueError(f"{label} root must not be a symlink: {plugin_root}")
|
||||
if not plugin_root.is_dir():
|
||||
raise ValueError(f"{label} root is missing: {plugin_root}")
|
||||
actual_relative_paths = {
|
||||
path.relative_to(plugin_root).as_posix()
|
||||
for path in plugin_root.rglob("*")
|
||||
if path.is_file() and "skills" not in path.relative_to(plugin_root).parts[:1]
|
||||
}
|
||||
unexpected_symlinks = sorted(
|
||||
path.relative_to(plugin_root).as_posix()
|
||||
for path in plugin_root.rglob("*")
|
||||
if path.is_symlink() and "skills" not in path.relative_to(plugin_root).parts[:1]
|
||||
)
|
||||
if unexpected_symlinks:
|
||||
raise ValueError(f"{label} contains unexpected metadata symlink: {unexpected_symlinks[0]}")
|
||||
if actual_relative_paths != expected_relative_paths:
|
||||
missing = sorted(expected_relative_paths - actual_relative_paths)
|
||||
unexpected = sorted(actual_relative_paths - expected_relative_paths)
|
||||
detail = f"missing {missing[0]}" if missing else f"unexpected {unexpected[0]}"
|
||||
raise ValueError(f"{label} metadata layout is out of sync: {detail}")
|
||||
|
||||
|
||||
def check_editorial_bundle_plugins(
|
||||
root: Path,
|
||||
metadata: dict[str, Any],
|
||||
bundles: list[dict[str, Any]],
|
||||
compatibility: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
bundle_support = {
|
||||
bundle["id"]: _bundle_target_status(bundle, compatibility)
|
||||
for bundle in bundles
|
||||
}
|
||||
codex_skill_ids = _supported_skill_ids(compatibility, "codex")
|
||||
claude_skill_ids = _supported_skill_ids(compatibility, "claude")
|
||||
|
||||
_assert_json_matches(
|
||||
root / CODEX_MARKETPLACE_PATH,
|
||||
_render_codex_marketplace(bundles, bundle_support),
|
||||
"Codex marketplace",
|
||||
root,
|
||||
)
|
||||
_assert_json_matches(
|
||||
root / CLAUDE_MARKETPLACE_PATH,
|
||||
_render_claude_marketplace(metadata, bundles, bundle_support),
|
||||
"Claude marketplace",
|
||||
root,
|
||||
)
|
||||
_assert_json_matches(
|
||||
root / CLAUDE_PLUGIN_PATH,
|
||||
_root_claude_plugin_manifest(metadata, len(claude_skill_ids)),
|
||||
"root Claude plugin manifest",
|
||||
root,
|
||||
)
|
||||
|
||||
codex_root = root / "plugins" / ROOT_CODEX_PLUGIN_NAME
|
||||
claude_root = root / "plugins" / ROOT_CLAUDE_PLUGIN_DIRNAME
|
||||
_assert_json_matches(
|
||||
codex_root / ".codex-plugin" / "plugin.json",
|
||||
_root_codex_plugin_manifest(metadata, len(codex_skill_ids)),
|
||||
"root Codex plugin manifest",
|
||||
root,
|
||||
)
|
||||
_assert_json_matches(
|
||||
claude_root / ".claude-plugin" / "plugin.json",
|
||||
_root_claude_plugin_manifest(metadata, len(claude_skill_ids)),
|
||||
"root Claude plugin manifest",
|
||||
root,
|
||||
)
|
||||
_assert_plugin_metadata_layout(
|
||||
codex_root,
|
||||
{".codex-plugin/plugin.json"},
|
||||
"root Codex plugin",
|
||||
)
|
||||
_assert_plugin_metadata_layout(
|
||||
claude_root,
|
||||
{".claude-plugin/plugin.json"},
|
||||
"root Claude plugin",
|
||||
)
|
||||
_assert_skill_mirror_matches(root, codex_root / "skills", codex_skill_ids, "root Codex plugin")
|
||||
_assert_skill_mirror_matches(root, claude_root / "skills", claude_skill_ids, "root Claude plugin")
|
||||
|
||||
expected_bundle_names = {
|
||||
_bundle_plugin_name(bundle["id"])
|
||||
for bundle in bundles
|
||||
if bundle_support[bundle["id"]]["codex"] or bundle_support[bundle["id"]]["claude"]
|
||||
}
|
||||
actual_bundle_names = {
|
||||
path.name
|
||||
for path in (root / "plugins").glob("agentic-bundle-*")
|
||||
if path.is_dir()
|
||||
}
|
||||
if actual_bundle_names != expected_bundle_names:
|
||||
missing = sorted(expected_bundle_names - actual_bundle_names)
|
||||
unexpected = sorted(actual_bundle_names - expected_bundle_names)
|
||||
detail = f"missing {missing[0]}" if missing else f"unexpected {unexpected[0]}"
|
||||
raise ValueError(f"Generated bundle plugin directories are out of sync: {detail}")
|
||||
|
||||
for bundle in bundles:
|
||||
support = bundle_support[bundle["id"]]
|
||||
if not support["codex"] and not support["claude"]:
|
||||
continue
|
||||
plugin_root = root / "plugins" / _bundle_plugin_name(bundle["id"])
|
||||
skill_ids = [skill["id"] for skill in bundle["skills"]]
|
||||
_assert_skill_mirror_matches(
|
||||
root,
|
||||
plugin_root / "skills",
|
||||
skill_ids,
|
||||
f'bundle plugin {bundle["id"]}',
|
||||
)
|
||||
|
||||
manifest_specs = (
|
||||
(
|
||||
"codex",
|
||||
plugin_root / ".codex-plugin" / "plugin.json",
|
||||
_bundle_codex_plugin_manifest(metadata, bundle),
|
||||
),
|
||||
(
|
||||
"claude",
|
||||
plugin_root / ".claude-plugin" / "plugin.json",
|
||||
_bundle_claude_plugin_manifest(metadata, bundle),
|
||||
),
|
||||
)
|
||||
expected_manifest_paths: set[str] = set()
|
||||
for target, manifest_path, expected_manifest in manifest_specs:
|
||||
if support[target]:
|
||||
expected_manifest_paths.add(manifest_path.relative_to(plugin_root).as_posix())
|
||||
_assert_json_matches(
|
||||
manifest_path,
|
||||
expected_manifest,
|
||||
f'bundle {bundle["id"]} {target} manifest',
|
||||
root,
|
||||
)
|
||||
elif manifest_path.exists():
|
||||
raise ValueError(
|
||||
f'Bundle {bundle["id"]} contains an unsupported {target} manifest: {manifest_path}'
|
||||
)
|
||||
_assert_plugin_metadata_layout(
|
||||
plugin_root,
|
||||
expected_manifest_paths,
|
||||
f'bundle plugin {bundle["id"]}',
|
||||
)
|
||||
|
||||
|
||||
def _remove_path(path: Path) -> None:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink()
|
||||
@@ -860,7 +1092,11 @@ def main() -> int:
|
||||
current_doc = (root / "docs" / "users" / "bundles.md").read_text(encoding="utf-8")
|
||||
if current_doc != expected_doc:
|
||||
raise SystemExit("docs/users/bundles.md is out of sync with data/editorial-bundles.json")
|
||||
print("✅ Editorial bundles manifest and generated doc are in sync.")
|
||||
try:
|
||||
check_editorial_bundle_plugins(root, metadata, bundles, compatibility)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
print("✅ Editorial bundles, marketplaces, manifests, and plugin mirrors are in sync.")
|
||||
return 0
|
||||
sync_editorial_bundles(root)
|
||||
print("✅ Editorial bundles synced.")
|
||||
|
||||
@@ -191,6 +191,30 @@ def sync_getting_started(content: str, metadata: dict) -> str:
|
||||
return content
|
||||
|
||||
|
||||
def sync_web_index_shell(content: str, metadata: dict) -> str:
|
||||
skill_label = metadata["total_skills_label"]
|
||||
return sync_regex_text(
|
||||
content,
|
||||
[
|
||||
(r"\d[\d,]*\+ installable agentic skills", f"{skill_label} installable agentic skills"),
|
||||
(r"\d[\d,]*\+ AI coding skills", f"{skill_label} AI coding skills"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def sync_llms_text(content: str, metadata: dict) -> str:
|
||||
skill_label = metadata["total_skills_label"]
|
||||
return sync_regex_text(
|
||||
content,
|
||||
[
|
||||
(r"Current release: V[\d.]+\.", f"Current release: V{metadata['version']}."),
|
||||
(r"\d[\d,]*\+ agentic SKILL\.md playbooks", f"{skill_label} agentic SKILL.md playbooks"),
|
||||
(r"Skill count: \d[\d,]*\+\.", f"Skill count: {skill_label}."),
|
||||
(r"\d[\d,]*\+ reusable SKILL\.md playbooks", f"{skill_label} reusable SKILL.md playbooks"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def sync_bundles_doc(content: str, metadata: dict, base_dir: str | Path | None = None) -> str:
|
||||
root = Path(base_dir) if base_dir is not None else Path(find_repo_root(__file__))
|
||||
manifest_path = root / "data" / "editorial-bundles.json"
|
||||
@@ -316,6 +340,8 @@ def sync_curated_docs(base_dir: str, metadata: dict, dry_run: bool) -> int:
|
||||
updated_files = 0
|
||||
updated_files += int(update_text_file(root / "README.md", sync_readme_copy, metadata, dry_run))
|
||||
updated_files += int(update_text_file(root / "docs" / "users" / "getting-started.md", sync_getting_started, metadata, dry_run))
|
||||
updated_files += int(update_text_file(root / "apps" / "web-app" / "index.html", sync_web_index_shell, metadata, dry_run))
|
||||
updated_files += int(update_text_file(root / "apps" / "web-app" / "public" / "llms.txt", sync_llms_text, metadata, dry_run))
|
||||
updated_files += int(
|
||||
update_text_file(
|
||||
root / "docs" / "users" / "bundles.md",
|
||||
|
||||
@@ -15,6 +15,11 @@ const publishWorkflow = readText(".github/workflows/publish-npm.yml");
|
||||
const releaseWorkflowScript = readText("tools/scripts/release_workflow.js");
|
||||
const hygieneWorkflowPath = path.join(repoRoot, ".github", "workflows", "repo-hygiene.yml");
|
||||
|
||||
const prepareReleaseBlock = releaseWorkflowScript.slice(
|
||||
releaseWorkflowScript.indexOf("function prepareRelease"),
|
||||
releaseWorkflowScript.indexOf("function publishRelease"),
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
packageJson.scripts["sync:release-state"],
|
||||
"package.json should expose a deterministic release-state sync command",
|
||||
@@ -90,6 +95,8 @@ assert.match(
|
||||
for (const filePath of [
|
||||
"README.md",
|
||||
"package.json",
|
||||
"apps/web-app/index.html",
|
||||
"apps/web-app/public/llms.txt",
|
||||
"docs/users/getting-started.md",
|
||||
"docs/users/bundles.md",
|
||||
"docs/users/claude-code-skills.md",
|
||||
@@ -277,6 +284,11 @@ assert.match(
|
||||
/runCommand\("npm", \["run", "app:install"\], projectRoot\);[\s\S]*runCommand\("npm", \["run", "app:build"\], projectRoot\);/,
|
||||
"release workflow should install web-app dependencies before building the app",
|
||||
);
|
||||
assert.ok(
|
||||
prepareReleaseBlock.indexOf('["run", "sync:metadata", "--", "--refresh-volatile"]') <
|
||||
prepareReleaseBlock.indexOf("runReleaseSuite(projectRoot)"),
|
||||
"release preparation should refresh volatile metadata before generating canonical release artifacts",
|
||||
);
|
||||
assert.match(
|
||||
publishWorkflow,
|
||||
/npm pack --dry-run --json/,
|
||||
|
||||
@@ -15,6 +15,23 @@ const release = installer.parseArgs(['--release', '14.0.0']);
|
||||
assert.strictEqual(release.versionArg, '14.0.0');
|
||||
assert.strictEqual(release.versionInfo, false);
|
||||
|
||||
const exactPreview = installer.parseArgs([
|
||||
'--codex',
|
||||
'--skills',
|
||||
'frontend-design,game-development/2d-games',
|
||||
'--dry-run',
|
||||
]);
|
||||
assert.strictEqual(exactPreview.skillsArg, 'frontend-design,game-development/2d-games');
|
||||
assert.strictEqual(exactPreview.dryRun, true);
|
||||
assert.deepStrictEqual(
|
||||
installer.parseExactSkillArg(exactPreview.skillsArg),
|
||||
['frontend-design', 'game-development/2d-games'],
|
||||
);
|
||||
assert.throws(
|
||||
() => installer.parseExactSkillArg('frontend-design,,backend-dev-guidelines'),
|
||||
/non-empty exact skill/i,
|
||||
);
|
||||
|
||||
const version = spawnSync(process.execPath, [installerPath, '--version'], { encoding: 'utf8' });
|
||||
assert.strictEqual(version.status, 0, version.stderr);
|
||||
assert.strictEqual(version.stdout.trim(), packageVersion);
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const installer = require(path.resolve(__dirname, "..", "..", "bin", "install.js"));
|
||||
const packageVersion = require(path.resolve(__dirname, "..", "..", "..", "package.json")).version;
|
||||
const pinnedRef = `v${packageVersion}`;
|
||||
|
||||
function writeSkill(repoRoot, skillPath, frontmatter) {
|
||||
const skillDir = path.join(repoRoot, "skills", skillPath);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---\n${frontmatter}\n---\n\n# ${skillPath}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "installer-exact-selection-"));
|
||||
|
||||
try {
|
||||
const repoRoot = path.join(tmpRoot, "repo");
|
||||
fs.mkdirSync(path.join(repoRoot, "skills"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, "docs"), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoRoot, "docs", "README.md"), "# Docs\n", "utf8");
|
||||
|
||||
writeSkill(
|
||||
repoRoot,
|
||||
"frontend-design",
|
||||
"name: frontend-design\ncategory: development\nrisk: safe\ntags: [frontend]",
|
||||
);
|
||||
writeSkill(
|
||||
repoRoot,
|
||||
path.join("game-development", "2d-games"),
|
||||
"name: 2D Games\ncategory: games\nrisk: safe\ntags: [game]",
|
||||
);
|
||||
writeSkill(
|
||||
repoRoot,
|
||||
path.join("legacy-games", "2d-games"),
|
||||
"name: Legacy 2D Games\ncategory: games\nrisk: safe\ntags: [game]",
|
||||
);
|
||||
writeSkill(
|
||||
repoRoot,
|
||||
"shared-one",
|
||||
"name: Shared Skill\ncategory: development\nrisk: safe\ntags: [shared]",
|
||||
);
|
||||
writeSkill(
|
||||
repoRoot,
|
||||
"shared-two",
|
||||
"name: Shared Skill\ncategory: development\nrisk: safe\ntags: [shared]",
|
||||
);
|
||||
|
||||
const requested = installer.parseExactSkillArg("frontend-design,game-development/2d-games");
|
||||
assert.deepStrictEqual(
|
||||
installer.getInstallEntries(repoRoot, installer.buildInstallSelectors({}), requested),
|
||||
["frontend-design", "game-development/2d-games", "docs"],
|
||||
"--skills should resolve exact root ids and full nested paths",
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
installer.getInstallEntries(
|
||||
repoRoot,
|
||||
installer.buildInstallSelectors({ categoryArg: "development" }),
|
||||
requested,
|
||||
),
|
||||
["frontend-design", "docs"],
|
||||
"--skills must combine with metadata filters using AND",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => installer.getInstallEntries(
|
||||
repoRoot,
|
||||
installer.buildInstallSelectors({ categoryArg: "games" }),
|
||||
installer.parseExactSkillArg("frontend-design"),
|
||||
),
|
||||
/No skills matched/i,
|
||||
"an empty exact-selection/filter intersection must fail instead of installing docs or a broad fallback",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => installer.getInstallEntries(
|
||||
repoRoot,
|
||||
installer.buildInstallSelectors({}),
|
||||
installer.parseExactSkillArg("does-not-exist"),
|
||||
),
|
||||
/unknown skill requested/i,
|
||||
"unknown exact skill selections must fail",
|
||||
);
|
||||
assert.throws(
|
||||
() => installer.getInstallEntries(
|
||||
repoRoot,
|
||||
installer.buildInstallSelectors({}),
|
||||
installer.parseExactSkillArg("2d-games"),
|
||||
),
|
||||
/ambiguous skill requested/i,
|
||||
"ambiguous basename selections must require a full nested path",
|
||||
);
|
||||
assert.throws(
|
||||
() => installer.getInstallEntries(
|
||||
repoRoot,
|
||||
installer.buildInstallSelectors({}),
|
||||
installer.parseExactSkillArg("Shared Skill"),
|
||||
),
|
||||
/ambiguous skill requested/i,
|
||||
"ambiguous canonical names must fail",
|
||||
);
|
||||
|
||||
const absentTarget = path.join(tmpRoot, "absent-target");
|
||||
assert.throws(
|
||||
() => installer.installForTarget(
|
||||
repoRoot,
|
||||
{ name: "Absent", path: absentTarget },
|
||||
installer.buildInstallSelectors({}),
|
||||
null,
|
||||
installer.parseExactSkillArg("does-not-exist"),
|
||||
),
|
||||
/unknown skill requested/i,
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(absentTarget),
|
||||
false,
|
||||
"invalid --skills selection must fail before an absent target is created",
|
||||
);
|
||||
|
||||
const existingTarget = path.join(tmpRoot, "existing-target");
|
||||
fs.mkdirSync(existingTarget, { recursive: true });
|
||||
const sentinelPath = path.join(existingTarget, "sentinel.txt");
|
||||
fs.writeFileSync(sentinelPath, "keep", "utf8");
|
||||
const manifestPath = path.join(existingTarget, ".antigravity-install-manifest.json");
|
||||
fs.writeFileSync(
|
||||
manifestPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
entries: ["frontend-design", "legacy-skill", "docs", "../outside"],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const plan = installer.buildDryRunPlan(
|
||||
pinnedRef,
|
||||
[
|
||||
{ name: "Existing", path: existingTarget },
|
||||
{ name: "Absent", path: absentTarget },
|
||||
],
|
||||
["game-development/2d-games", "frontend-design", "docs"],
|
||||
);
|
||||
assert.deepStrictEqual(plan.targets[0].remove, ["legacy-skill"]);
|
||||
assert.deepStrictEqual(plan.targets[0].ignoredUnsafeManifestEntries, ["../outside"]);
|
||||
assert.strictEqual(plan.targets[1].targetExists, false);
|
||||
const output = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (message) => output.push(String(message));
|
||||
try {
|
||||
installer.printDryRunPlan(plan);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
assert.strictEqual(fs.readFileSync(sentinelPath, "utf8"), "keep");
|
||||
assert.strictEqual(fs.existsSync(absentTarget), false);
|
||||
assert.match(output.join("\n"), new RegExp(`Ref: ${pinnedRef.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
||||
assert.match(output.join("\n"), new RegExp(existingTarget.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(output.join("\n"), /Exact skill set \(2\):/);
|
||||
assert.match(output.join("\n"), /frontend-design/);
|
||||
assert.match(output.join("\n"), /remove stale managed entries \(1\)/i);
|
||||
assert.match(output.join("\n"), /legacy-skill/);
|
||||
assert.match(output.join("\n"), /ignored unsafe manifest entry: \.\.\/outside/i);
|
||||
|
||||
// Exercise the real CLI boundary with a deterministic fake git clone. This
|
||||
// proves --dry-run returns before installForTarget can mutate either an
|
||||
// existing target or a missing one.
|
||||
const fakeBin = path.join(tmpRoot, "fake-bin");
|
||||
fs.mkdirSync(fakeBin, { recursive: true });
|
||||
const fakeGit = path.join(fakeBin, "git");
|
||||
fs.writeFileSync(
|
||||
fakeGit,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const destination = process.argv[process.argv.length - 1];
|
||||
fs.appendFileSync(process.env.FAKE_GIT_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");
|
||||
fs.cpSync(process.env.FAKE_GIT_SOURCE, destination, { recursive: true, force: true });
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
fs.chmodSync(fakeGit, 0o755);
|
||||
|
||||
const fakeGitLog = path.join(tmpRoot, "fake-git-log.jsonl");
|
||||
const cliEnv = {
|
||||
...process.env,
|
||||
FAKE_GIT_SOURCE: repoRoot,
|
||||
FAKE_GIT_LOG: fakeGitLog,
|
||||
PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ""}`,
|
||||
};
|
||||
const installerPath = path.resolve(__dirname, "..", "..", "bin", "install.js");
|
||||
const beforeDryRunEntries = fs.readdirSync(existingTarget).sort();
|
||||
const dryRun = spawnSync(
|
||||
process.execPath,
|
||||
[installerPath, "--path", existingTarget, "--release", packageVersion, "--skills", "frontend-design", "--dry-run"],
|
||||
{ encoding: "utf8", env: cliEnv },
|
||||
);
|
||||
assert.strictEqual(dryRun.status, 0, dryRun.stderr);
|
||||
assert.match(dryRun.stdout, new RegExp(`Ref: ${pinnedRef.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
||||
assert.match(dryRun.stdout, /Exact skill set \(1\):/);
|
||||
assert.match(dryRun.stdout, /remove stale managed entries \(1\)/i);
|
||||
assert.match(dryRun.stdout, /legacy-skill/);
|
||||
assert.deepStrictEqual(fs.readdirSync(existingTarget).sort(), beforeDryRunEntries);
|
||||
assert.strictEqual(fs.readFileSync(sentinelPath, "utf8"), "keep");
|
||||
const cloneArgs = JSON.parse(fs.readFileSync(fakeGitLog, "utf8").trim().split("\n")[0]);
|
||||
assert.deepStrictEqual(
|
||||
cloneArgs.slice(0, 5),
|
||||
["clone", "--depth", "1", "--branch", pinnedRef],
|
||||
"the real CLI dry run must clone the explicitly pinned release",
|
||||
);
|
||||
|
||||
const outsideSentinel = path.join(tmpRoot, "outside");
|
||||
fs.writeFileSync(outsideSentinel, "outside stays", "utf8");
|
||||
assert.doesNotThrow(() => installer.pruneRemovedEntries(existingTarget, ["../outside"], []));
|
||||
assert.strictEqual(fs.readFileSync(outsideSentinel, "utf8"), "outside stays");
|
||||
|
||||
const unknownTarget = path.join(tmpRoot, "unknown-target");
|
||||
const unknown = spawnSync(
|
||||
process.execPath,
|
||||
[installerPath, "--path", unknownTarget, "--skills", "does-not-exist", "--dry-run"],
|
||||
{ encoding: "utf8", env: cliEnv },
|
||||
);
|
||||
assert.strictEqual(unknown.status, 1);
|
||||
assert.match(unknown.stderr, /Unknown skill requested/);
|
||||
assert.strictEqual(fs.existsSync(unknownTarget), false);
|
||||
|
||||
const multiHome = path.join(tmpRoot, "multi-home");
|
||||
const firstTarget = path.join(multiHome, ".claude", "skills");
|
||||
const codexHome = path.join(tmpRoot, "codex-home");
|
||||
const unsafeTarget = path.join(codexHome, "skills");
|
||||
const unsafeRealTarget = path.join(tmpRoot, "unsafe-real-target");
|
||||
fs.mkdirSync(firstTarget, { recursive: true });
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.mkdirSync(unsafeRealTarget, { recursive: true });
|
||||
const firstSentinel = path.join(firstTarget, "first-sentinel.txt");
|
||||
fs.writeFileSync(firstSentinel, "first stays", "utf8");
|
||||
let createdUnsafeSymlink = false;
|
||||
try {
|
||||
fs.symlinkSync(unsafeRealTarget, unsafeTarget, "dir");
|
||||
createdUnsafeSymlink = true;
|
||||
} catch (error) {
|
||||
// Some platforms disallow unprivileged directory symlinks.
|
||||
}
|
||||
if (createdUnsafeSymlink) {
|
||||
const multiTarget = spawnSync(
|
||||
process.execPath,
|
||||
[installerPath, "--claude", "--codex", "--skills", "frontend-design"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: { ...cliEnv, HOME: multiHome, CODEX_HOME: codexHome },
|
||||
},
|
||||
);
|
||||
assert.strictEqual(multiTarget.status, 1);
|
||||
assert.match(multiTarget.stderr, /symlinked target/i);
|
||||
assert.deepStrictEqual(fs.readdirSync(firstTarget), ["first-sentinel.txt"]);
|
||||
assert.strictEqual(fs.readFileSync(firstSentinel, "utf8"), "first stays");
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -65,7 +65,13 @@ class EditorialBundlesTests(unittest.TestCase):
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_get_bundle_skills_reads_json_manifest_by_name_and_id(self):
|
||||
expected = ["concise-planning", "git-pushing", "kaizen", "lint-and-validate", "systematic-debugging"]
|
||||
expected = [
|
||||
"concise-planning",
|
||||
"git-pushing",
|
||||
"lint-and-validate",
|
||||
"systematic-debugging",
|
||||
"test-driven-development",
|
||||
]
|
||||
self.assertEqual(get_bundle_skills.get_bundle_skills(["Essentials"]), expected)
|
||||
self.assertEqual(get_bundle_skills.get_bundle_skills(["essentials"]), expected)
|
||||
web_wizard_skills = get_bundle_skills.get_bundle_skills(["web-wizard"])
|
||||
@@ -91,13 +97,13 @@ class EditorialBundlesTests(unittest.TestCase):
|
||||
sample_skill_dir = essentials_plugin / "concise-planning"
|
||||
self.assertTrue((sample_skill_dir / "SKILL.md").is_file())
|
||||
|
||||
def test_generated_plugin_count_matches_manifest(self):
|
||||
generated_plugins = sorted(
|
||||
def test_generated_plugins_cover_manifest_during_source_only_prs(self):
|
||||
generated_plugins = {
|
||||
path.name
|
||||
for path in (REPO_ROOT / "plugins").iterdir()
|
||||
if path.is_dir() and path.name.startswith("agentic-bundle-")
|
||||
)
|
||||
expected_plugins = sorted(
|
||||
}
|
||||
expected_plugins = {
|
||||
f'agentic-bundle-{bundle["id"]}'
|
||||
for bundle in self.manifest_bundles
|
||||
if any(
|
||||
@@ -107,8 +113,22 @@ class EditorialBundlesTests(unittest.TestCase):
|
||||
)
|
||||
for target in ("codex", "claude")
|
||||
)
|
||||
}
|
||||
self.assertFalse(
|
||||
expected_plugins - generated_plugins,
|
||||
f"generated bundle plugins are missing: {sorted(expected_plugins - generated_plugins)}",
|
||||
)
|
||||
self.assertEqual(generated_plugins, expected_plugins)
|
||||
|
||||
def test_plugin_sync_prunes_stale_bundle_directories(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = pathlib.Path(temp_dir)
|
||||
stale_plugin = root / "plugins" / "agentic-bundle-retired"
|
||||
stale_plugin.mkdir(parents=True)
|
||||
(stale_plugin / "marker.txt").write_text("stale", encoding="utf-8")
|
||||
|
||||
editorial_bundles.sync_editorial_bundle_plugins(root, {}, [], {})
|
||||
|
||||
self.assertFalse(stale_plugin.exists())
|
||||
|
||||
def test_codex_bundle_plugin_names_keep_qualified_skill_names_valid(self):
|
||||
max_name_length = 64
|
||||
@@ -207,6 +227,113 @@ class EditorialBundlesTests(unittest.TestCase):
|
||||
f"Claude root plugin inclusion mismatch for {skill_id}",
|
||||
)
|
||||
|
||||
def test_skill_mirror_check_rejects_stale_and_unexpected_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = pathlib.Path(temp_dir)
|
||||
canonical = root / "skills" / "sample"
|
||||
mirror = root / "plugin" / "skills" / "sample"
|
||||
canonical.mkdir(parents=True)
|
||||
mirror.mkdir(parents=True)
|
||||
(canonical / "SKILL.md").write_text("canonical\n", encoding="utf-8")
|
||||
(mirror / "SKILL.md").write_text("stale\n", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "stale mirrored file: sample/SKILL.md"):
|
||||
editorial_bundles._assert_skill_mirror_matches(
|
||||
root,
|
||||
root / "plugin" / "skills",
|
||||
["sample"],
|
||||
"sample plugin",
|
||||
)
|
||||
|
||||
(mirror / "SKILL.md").write_text("canonical\n", encoding="utf-8")
|
||||
(mirror / "unexpected.txt").write_text("extra\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "unexpected mirrored file: sample/unexpected.txt"):
|
||||
editorial_bundles._assert_skill_mirror_matches(
|
||||
root,
|
||||
root / "plugin" / "skills",
|
||||
["sample"],
|
||||
"sample plugin",
|
||||
)
|
||||
|
||||
def test_skill_mirror_check_rejects_symlinks(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = pathlib.Path(temp_dir)
|
||||
canonical = root / "skills" / "sample"
|
||||
mirror = root / "plugin" / "skills" / "sample"
|
||||
canonical.mkdir(parents=True)
|
||||
mirror.mkdir(parents=True)
|
||||
(canonical / "SKILL.md").write_text("canonical\n", encoding="utf-8")
|
||||
(mirror / "SKILL.md").symlink_to(canonical / "SKILL.md")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "unexpected symlink: sample/SKILL.md"):
|
||||
editorial_bundles._assert_skill_mirror_matches(
|
||||
root,
|
||||
root / "plugin" / "skills",
|
||||
["sample"],
|
||||
"sample plugin",
|
||||
)
|
||||
|
||||
mirror_root_link = root / "linked-skills"
|
||||
mirror_root_link.symlink_to(root / "plugin" / "skills", target_is_directory=True)
|
||||
with self.assertRaisesRegex(ValueError, "skills directory must not be a symlink"):
|
||||
editorial_bundles._assert_skill_mirror_matches(
|
||||
root,
|
||||
mirror_root_link,
|
||||
["sample"],
|
||||
"sample plugin",
|
||||
)
|
||||
|
||||
def test_plugin_metadata_layout_rejects_unexpected_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
plugin_root = pathlib.Path(temp_dir) / "plugin"
|
||||
manifest = plugin_root / ".codex-plugin" / "plugin.json"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text("{}\n", encoding="utf-8")
|
||||
(plugin_root / "README.md").write_text("stale\n", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "metadata layout is out of sync: unexpected README.md"):
|
||||
editorial_bundles._assert_plugin_metadata_layout(
|
||||
plugin_root,
|
||||
{".codex-plugin/plugin.json"},
|
||||
"sample plugin",
|
||||
)
|
||||
|
||||
def test_json_check_requires_canonical_serialization_and_regular_file(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = pathlib.Path(temp_dir)
|
||||
manifest = root / "plugin.json"
|
||||
manifest.write_text('{"name":"sample"}\n', encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "is out of sync"):
|
||||
editorial_bundles._assert_json_matches(
|
||||
manifest,
|
||||
{"name": "sample"},
|
||||
"sample manifest",
|
||||
root,
|
||||
)
|
||||
|
||||
manifest.write_bytes(b'{\r\n "name": "sample"\r\n}\r\n')
|
||||
with self.assertRaisesRegex(ValueError, "is out of sync"):
|
||||
editorial_bundles._assert_json_matches(
|
||||
manifest,
|
||||
{"name": "sample"},
|
||||
"sample manifest",
|
||||
root,
|
||||
)
|
||||
|
||||
external = root / "external"
|
||||
external.mkdir()
|
||||
(external / "plugin.json").write_text('{\n "name": "sample"\n}\n', encoding="utf-8")
|
||||
linked_parent = root / "linked"
|
||||
linked_parent.symlink_to(external, target_is_directory=True)
|
||||
with self.assertRaisesRegex(ValueError, "path must not contain a symlink"):
|
||||
editorial_bundles._assert_json_matches(
|
||||
linked_parent / "plugin.json",
|
||||
{"name": "sample"},
|
||||
"sample manifest",
|
||||
root,
|
||||
)
|
||||
|
||||
def test_remove_tree_retries_on_enotempty(self):
|
||||
target = REPO_ROOT / "plugins" / "agentic-awesome-skills" / "skills"
|
||||
calls = {"count": 0}
|
||||
|
||||
@@ -100,6 +100,38 @@ class GenerateIndexCategoryTests(unittest.TestCase):
|
||||
self.assertEqual(categories["nested-skill"], "bundles")
|
||||
self.assertEqual(categories["playwright-skill"], "test-automation")
|
||||
|
||||
def test_generate_index_preserves_top_level_and_nested_tags(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = pathlib.Path(temp_dir)
|
||||
skills_dir = base / "skills"
|
||||
output_file = base / "skills_index.json"
|
||||
|
||||
top_level = skills_dir / "top-level"
|
||||
top_level.mkdir(parents=True)
|
||||
(top_level / "SKILL.md").write_text(
|
||||
"---\nname: top-level\ndescription: Top level tags\ntags: [api, typescript, api]\n---\nbody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
nested_tags = skills_dir / "nested-tags"
|
||||
nested_tags.mkdir(parents=True)
|
||||
(nested_tags / "SKILL.md").write_text(
|
||||
"---\nname: nested-tags\ndescription: Nested tags\nmetadata:\n tags: data, python\n---\nbody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
skills = generate_index.generate_index(str(skills_dir), str(output_file))
|
||||
by_id = {skill["id"]: skill for skill in skills}
|
||||
|
||||
self.assertEqual(by_id["top-level"]["tags"], ["api", "typescript"])
|
||||
self.assertEqual(by_id["nested-tags"]["tags"], ["data", "python"])
|
||||
|
||||
def test_tag_coercion_is_deterministic_for_yaml_sets(self):
|
||||
self.assertEqual(
|
||||
generate_index.coerce_metadata_list({"typescript", "api"}),
|
||||
["api", "typescript"],
|
||||
)
|
||||
|
||||
def test_generate_index_rejects_duplicate_route_ids(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
base = pathlib.Path(temp_dir)
|
||||
|
||||
@@ -59,6 +59,20 @@ class SyncRepoMetadataTests(unittest.TestCase):
|
||||
(root / "docs" / "users").mkdir(parents=True)
|
||||
(root / "docs" / "maintainers").mkdir(parents=True)
|
||||
(root / "docs" / "integrations" / "jetski-gemini-loader").mkdir(parents=True)
|
||||
(root / "apps" / "web-app" / "public").mkdir(parents=True)
|
||||
|
||||
(root / "apps" / "web-app" / "index.html").write_text(
|
||||
'<meta name="description" content="Explore 1,273+ installable agentic skills">\n'
|
||||
'<title>Agentic Awesome Skills GitHub | 1,273+ AI coding skills</title>\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "apps" / "web-app" / "public" / "llms.txt").write_text(
|
||||
"> Installable GitHub library of 1,273+ agentic SKILL.md playbooks.\n"
|
||||
"- Current release: V8.3.0.\n"
|
||||
"- Skill count: 1,273+.\n"
|
||||
"Agentic Awesome Skills is an installable library of 1,273+ reusable SKILL.md playbooks.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
(root / "docs" / "users" / "getting-started.md").write_text(
|
||||
"# Getting Started with Agentic Awesome Skills (V8.3.0)\n",
|
||||
@@ -107,7 +121,7 @@ class SyncRepoMetadataTests(unittest.TestCase):
|
||||
|
||||
updated_files = sync_repo_metadata.sync_curated_docs(str(root), metadata, dry_run=False)
|
||||
|
||||
self.assertGreaterEqual(updated_files, 10)
|
||||
self.assertGreaterEqual(updated_files, 12)
|
||||
readme = (root / "README.md").read_text(encoding="utf-8")
|
||||
self.assertIn("1,304+ agentic skills", readme)
|
||||
self.assertIn("[📚 Browse 1,304+ Skills](#browse-1304-skills)", readme)
|
||||
@@ -117,6 +131,13 @@ class SyncRepoMetadataTests(unittest.TestCase):
|
||||
self.assertIn("1,304+ files", (root / "docs" / "users" / "gemini-cli-skills.md").read_text(encoding="utf-8"))
|
||||
self.assertIn("1,304+ specialized areas", (root / "docs" / "users" / "kiro-integration.md").read_text(encoding="utf-8"))
|
||||
self.assertIn("Total Bundles: 2", (root / "docs" / "users" / "bundles.md").read_text(encoding="utf-8"))
|
||||
web_index = (root / "apps" / "web-app" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertIn("1,304+ installable agentic skills", web_index)
|
||||
self.assertIn("1,304+ AI coding skills", web_index)
|
||||
llms_text = (root / "apps" / "web-app" / "public" / "llms.txt").read_text(encoding="utf-8")
|
||||
self.assertIn("Current release: V8.4.0.", llms_text)
|
||||
self.assertIn("Skill count: 1,304+.", llms_text)
|
||||
self.assertIn("1,304+ reusable SKILL.md playbooks", llms_text)
|
||||
jetski_cortex = (root / "docs" / "integrations" / "jetski-cortex.md").read_text(encoding="utf-8")
|
||||
self.assertIn("1,304+ skill", jetski_cortex)
|
||||
self.assertNotIn("1,1", jetski_cortex)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { resolveExactSkillSelections } = require('../../bin/install');
|
||||
const { listSkillIdsRecursive } = require('../../lib/skill-utils');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
const repoSkills = path.join(repoRoot, 'skills');
|
||||
const catalog = JSON.parse(fs.readFileSync(path.join(repoRoot, 'skills_index.json'), 'utf8'));
|
||||
const installerEntries = listSkillIdsRecursive(repoSkills);
|
||||
|
||||
assert.strictEqual(
|
||||
installerEntries.length,
|
||||
catalog.length,
|
||||
'the Workbench catalog and installer must see the same number of canonical skills',
|
||||
);
|
||||
|
||||
const resolvedEntries = resolveExactSkillSelections(
|
||||
repoSkills,
|
||||
installerEntries,
|
||||
catalog.map((skill) => skill.id),
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
resolvedEntries.size,
|
||||
catalog.length,
|
||||
'every Workbench id must resolve to exactly one installer entry',
|
||||
);
|
||||
|
||||
for (const skill of catalog) {
|
||||
const expectedEntry = skill.path.replace(/^skills\//, '');
|
||||
assert.ok(
|
||||
resolvedEntries.has(expectedEntry),
|
||||
`Workbench id ${skill.id} must resolve to ${expectedEntry}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Workbench installer contract passed for ${catalog.length} canonical skills.`);
|
||||
Reference in New Issue
Block a user