Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 8 additions & 82 deletions .github/workflows/mattpocock-skills-reviewer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

122 changes: 122 additions & 0 deletions actions/setup/js/install_frontmatter_skills.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// @ts-check
/// <reference types="@actions/github-script" />

const fs = require("fs");
const path = require("path");

/**
* @param {string} rawSkills
* @returns {string[]}
*/
function parseSkillSpecs(rawSkills) {
return (rawSkills || "")
.split(/\r?\n/)
.map(skill => skill.trim())
.filter(Boolean);
}

/**
* @typedef {{args: string[]; displaySpec: string}} SkillInstallCommand
*/

/**
* @param {string} skillSpec
* @param {string} skillsDst
* @returns {SkillInstallCommand}
*/
function buildSkillInstallCommand(skillSpec, skillsDst) {
const atIndex = skillSpec.lastIndexOf("@");
const hasPin = atIndex >= 0;
const skillBase = hasPin ? skillSpec.slice(0, atIndex) : skillSpec;
const skillRef = hasPin ? skillSpec.slice(atIndex + 1) : "";
const parts = skillBase.split("/");
const pinArgs = skillRef ? ["--pin", skillRef] : [];

if (parts.length >= 3) {
return {
displaySpec: skillSpec,
args: ["skill", "install", `${parts[0]}/${parts[1]}`, parts.slice(2).join("/"), ...pinArgs, "--dir", skillsDst, "--force"],
};
}

if (parts.length === 2) {
return {
displaySpec: skillSpec,
args: ["skill", "install", skillBase, "--all", ...pinArgs, "--dir", skillsDst, "--force"],
};
}

return {
displaySpec: skillSpec,
args: ["skill", "install", skillSpec, "--dir", skillsDst, "--force"],
};
}

/**
* @param {string} skillsDst
* @returns {number}
*/
function countInstalledSkillFiles(skillsDst) {
if (!fs.existsSync(skillsDst)) {
return 0;
}

let count = 0;
const stack = [skillsDst];
while (stack.length > 0) {
const currentDir = stack.pop();
if (!currentDir) {
continue;
}
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
const entryPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
continue;
}
if (entry.isFile() && entry.name === "SKILL.md") {
count++;
}
}
}

return count;
}

/**
* @param {string} skillDir
* @param {string[]} skills
* @param {number} installedSkillCount
* @returns {Promise<void>}
*/
async function writeSkillSummary(skillDir, skills, installedSkillCount) {
core.summary
.addRaw("### Frontmatter skills installed\n\n")
.addRaw(`- Engine skill directory: \`${skillDir}\`\n`)
.addRaw(`- Requested references: \`${JSON.stringify(skills)}\`\n`)
.addRaw(`- Installed SKILL.md files: ${installedSkillCount}\n`);
await core.summary.write();
}

async function main() {
const skillDir = process.env.GH_AW_SKILL_DIR || "";
const skills = parseSkillSpecs(process.env.GH_AW_FRONTMATTER_SKILLS || "");
const skillsDst = path.join("/tmp/gh-aw", skillDir);

fs.mkdirSync(skillsDst, { recursive: true });

core.info(`Installing frontmatter skills to ${skillsDst}`);
core.info("Existing skills at destination may be replaced (--force) to ensure pinned refs are up to date");

for (const skillSpec of skills) {
core.info(`Installing skill reference: ${skillSpec}`);
const command = buildSkillInstallCommand(skillSpec, skillsDst);
await exec.exec("gh", command.args);
}

const installedSkillCount = countInstalledSkillFiles(skillsDst);
core.info(`Installed ${installedSkillCount} skill file(s)`);
await writeSkillSummary(skillDir, skills, installedSkillCount);
}

module.exports = { main, parseSkillSpecs, buildSkillInstallCommand, countInstalledSkillFiles };
87 changes: 87 additions & 0 deletions actions/setup/js/install_frontmatter_skills.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { createRequire } from "module";

const require = createRequire(import.meta.url);
const script = require("./install_frontmatter_skills.cjs");

describe("install_frontmatter_skills", () => {
let originalEnv;
let originalCore;
let originalExec;
let tempRoot;

beforeEach(() => {
originalEnv = {
GH_AW_SKILL_DIR: process.env.GH_AW_SKILL_DIR,
GH_AW_FRONTMATTER_SKILLS: process.env.GH_AW_FRONTMATTER_SKILLS,
};
originalCore = global.core;
originalExec = global.exec;
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-frontmatter-skills-"));

global.core = {
info: vi.fn(),
summary: {
addRaw: vi.fn().mockReturnThis(),
write: vi.fn().mockResolvedValue(undefined),
},
};
global.exec = {
exec: vi.fn().mockResolvedValue(0),
};
});

afterEach(() => {
if (originalEnv.GH_AW_SKILL_DIR === undefined) {
delete process.env.GH_AW_SKILL_DIR;
} else {
process.env.GH_AW_SKILL_DIR = originalEnv.GH_AW_SKILL_DIR;
}
if (originalEnv.GH_AW_FRONTMATTER_SKILLS === undefined) {
delete process.env.GH_AW_FRONTMATTER_SKILLS;
} else {
process.env.GH_AW_FRONTMATTER_SKILLS = originalEnv.GH_AW_FRONTMATTER_SKILLS;
}
global.core = originalCore;
global.exec = originalExec;
fs.rmSync(tempRoot, { recursive: true, force: true });
fs.rmSync("/tmp/gh-aw/.claude", { recursive: true, force: true });
});

it("splits repo-level and path-level skill specs into gh skill install arguments", () => {
expect(script.buildSkillInstallCommand("githubnext/skills@abc123", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "githubnext/skills", "--all", "--pin", "abc123", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
expect(script.buildSkillInstallCommand("githubnext/skills/review/security@abc123", "/tmp/gh-aw/.claude/skills").args).toEqual([
"skill",
"install",
"githubnext/skills",
"review/security",
"--pin",
"abc123",
"--dir",
"/tmp/gh-aw/.claude/skills",
"--force",
]);
});

it("omits --pin when the resolved skill spec is unpinned", () => {
expect(script.buildSkillInstallCommand("githubnext/skills/review/security", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "githubnext/skills", "review/security", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
});

it("reads skill specs from the env var and installs them at runtime", async () => {
process.env.GH_AW_SKILL_DIR = ".claude/skills";
process.env.GH_AW_FRONTMATTER_SKILLS = ["githubnext/skills@abc123", "githubnext/skills/review/security@def456", "${{ inputs.skill_ref }}"].join("\n");
fs.mkdirSync("/tmp/gh-aw/.claude/skills/example", { recursive: true });
fs.writeFileSync("/tmp/gh-aw/.claude/skills/example/SKILL.md", "# test\n", "utf8");

await script.main();

expect(global.exec.exec).toHaveBeenNthCalledWith(1, "gh", ["skill", "install", "githubnext/skills", "--all", "--pin", "abc123", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
expect(global.exec.exec).toHaveBeenNthCalledWith(2, "gh", ["skill", "install", "githubnext/skills", "review/security", "--pin", "def456", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
expect(global.exec.exec).toHaveBeenNthCalledWith(3, "gh", ["skill", "install", "${{ inputs.skill_ref }}", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
expect(global.core.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("### Frontmatter skills installed"));
expect(global.core.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining('["githubnext/skills@abc123","githubnext/skills/review/security@def456","${{ inputs.skill_ref }}"]'));
});
});
15 changes: 5 additions & 10 deletions pkg/workflow/activation_skills_step_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,9 @@ func TestBuildActivationJob_AddsFrontmatterSkillsInstallSteps(t *testing.T) {
assert.Contains(t, steps, "Upgrade gh CLI for frontmatter skills", "expected gh upgrade step in activation job")
assert.Contains(t, steps, "Install frontmatter skills", "expected frontmatter skills install step in activation job")
assert.Contains(t, steps, "GH_AW_SKILL_DIR: \".claude/skills\"", "expected engine skill directory env var")
assert.Contains(t, steps, "GH_AW_SKILLS_SUMMARY: '[\"githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6\",\"githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6\"]'", "expected summary env var for requested skills")
assert.Contains(t, steps, "GH_AW_SKILL_SPEC_0: \"githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6\"", "expected first skill env var")
assert.Contains(t, steps, "GH_AW_SKILL_SPEC_1: \"githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6\"", "expected second skill env var")
assert.Contains(t, steps, "skill_spec=\"${GH_AW_SKILL_SPEC_0}\"", "expected runtime install loop to read first skill from env")
assert.Contains(t, steps, "install_args+=(--all)", "expected runtime repository-scope detection")
assert.Contains(t, steps, "gh skill install \"${skill_spec}\" \"${install_args[@]}\" --dir \"${SKILLS_DST}\" --force", "expected runtime install command to use quoted env values")
assert.Contains(t, steps, "### Frontmatter skills installed", "expected step summary output")
assert.Contains(t, steps, "GH_AW_FRONTMATTER_SKILLS: \"githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6\\ngithubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6\"", "expected skills env var")
assert.Contains(t, steps, "const { main } = require('${{ runner.temp }}/gh-aw/actions/install_frontmatter_skills.cjs');", "expected github-script runtime loader for skill install")
assert.NotContains(t, steps, "GH_AW_SKILL_SPEC_0", "expected per-skill env vars to be removed")
}

func TestBuildActivationJob_AddsExpressionSkillInstallSteps(t *testing.T) {
Expand All @@ -65,9 +61,8 @@ func TestBuildActivationJob_AddsExpressionSkillInstallSteps(t *testing.T) {
require.NotNil(t, job)

steps := strings.Join(job.Steps, "")
assert.Contains(t, steps, "GH_AW_SKILL_SPEC_0: \"${{ inputs.skill_ref }}\"", "expected whole-expression skill env var")
assert.Contains(t, steps, "GH_AW_SKILL_SPEC_1: \"githubnext/skills@${{ github.sha }}\"", "expected expression-ref skill env var")
assert.NotContains(t, steps, "echo \"Installing skill reference: ${{ inputs.skill_ref }}\"", "expression should not be interpolated directly into the run script")
assert.Contains(t, steps, "GH_AW_FRONTMATTER_SKILLS: \"${{ inputs.skill_ref }}\\ngithubnext/skills@${{ github.sha }}\"", "expected skills env var to preserve expressions for runtime resolution")
assert.NotContains(t, steps, "GH_AW_SKILL_SPEC_0", "expected per-skill env vars to be removed")
}

func TestBuildActivationJob_NoSkillsStepsWhenSkillsAbsent(t *testing.T) {
Expand Down
Loading
Loading