Skip to content
Open
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
31 changes: 30 additions & 1 deletion docs/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,42 @@ Outputs:
- `dist/vscode/.github/skills/*` (VS Code / Copilot repo layout)
- `dist/claude/.claude/skills/*` (Claude Code repo layout)
- `dist/cursor/.cursor/skills/*` (Cursor repo layout)
- `dist/gemini/.gemini/*` (Gemini CLI extension layout)

## Install into another repo

1. Build dist (above).
2. Install into a destination repo:

- `node shared/scripts/skillpack-install.mjs --dest=../some-repo --targets=codex,vscode,claude,cursor`
- `node shared/scripts/skillpack-install.mjs --dest=../some-repo --targets=codex,vscode,claude,cursor,gemini`

By default, install mode is `replace` (it replaces only the skill directories it installs).

## Gemini CLI Extension

To use these skills with [Gemini CLI](https://github.com/google/gemini-cli), the build process generates a modular extension with individual skills located in the `skills/` directory. This allows Gemini CLI to load only the necessary skills on demand, optimizing context usage.

### Build and Install

1. **Build the package**:
```bash
node shared/scripts/skillpack-build.mjs --targets=gemini
```

2. **Install via Gemini CLI (Recommended)**:
The native CLI command is the safest method. It handles security trust prompts, registers the extension properly, and enables on-demand skill activation.
```bash
gemini extension install ./dist/gemini/.gemini
```

3. **Install via script (Alternative)**:
For headless environments or automated setups, you can install the extension globally to `~/.gemini/extensions/wordpress-agent-skills/`:
```bash
node shared/scripts/skillpack-install.mjs --targets=gemini-global
```

### Managing the Extension
To update the extension, you must uninstall it first. Always use the registered extension name (found in `gemini-extension.json`), not the file path:
```bash
gemini extension uninstall wordpress-agent-skills
```
6 changes: 6 additions & 0 deletions gemini-extension.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "wordpress-agent-skills",
"version": "1.0.0",
"description": "Official WordPress Core engineering standards and agent skills",
"contextFileName": "GEMINI.md"
}
104 changes: 95 additions & 9 deletions shared/scripts/skillpack-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ function usage() {
process.stderr.write(
[
"Usage:",
" node shared/scripts/skillpack-build.mjs [--out=dist] [--targets=codex,vscode,claude,cursor] [--skills=skill1,skill2] [--clean]",
" node shared/scripts/skillpack-build.mjs [--out=dist] [--targets=codex,vscode,claude,cursor,gemini] [--skills=skill1,skill2] [--clean]",
"",
"Outputs:",
" - <out>/codex/.codex/skills/<skill>/SKILL.md",
" - <out>/vscode/.github/skills/<skill>/SKILL.md",
" - <out>/claude/.claude/skills/<skill>/SKILL.md",
" - <out>/cursor/.cursor/skills/<skill>/SKILL.md",
" - <out>/gemini/.gemini/GEMINI.md",
"",
"Options:",
" --targets Comma-separated list of targets (codex, vscode, claude, cursor). Default: codex,vscode,claude,cursor",
" --targets Comma-separated list of targets (codex, vscode, claude, cursor, gemini). Default: codex,vscode,claude,cursor,gemini",
" --skills Comma-separated list of skill names to build. Default: all skills",
" --clean Remove target directories before building",
"",
Expand All @@ -26,7 +27,7 @@ function usage() {
}

function parseArgs(argv) {
const args = { out: "dist", targets: ["codex", "vscode", "claude", "cursor"], skills: [], clean: false };
const args = { out: "dist", targets: ["codex", "vscode", "claude", "cursor", "gemini"], skills: [], clean: false };
for (const a of argv) {
if (a === "--help" || a === "-h") args.help = true;
else if (a === "--clean") args.clean = true;
Expand Down Expand Up @@ -101,23 +102,109 @@ function buildTarget({ repoRoot, outDir, target, skillDirs }) {
vscode: path.join(outDir, "vscode", ".github", "skills"),
claude: path.join(outDir, "claude", ".claude", "skills"),
cursor: path.join(outDir, "cursor", ".cursor", "skills"),
gemini: path.join(outDir, "gemini", ".gemini"),
};
const destSkillsRoot = rootByTarget[target];
assert(destSkillsRoot, `Unknown target: ${target}`);

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

for (const srcSkillDir of skillDirs) {
const name = path.basename(srcSkillDir);
const destSkillDir = path.join(destSkillsRoot, name);
copyDir({ srcDir: srcSkillDir, destDir: destSkillDir });

if (target === "gemini") {
// 1. CREATE ROOT GEMINI.md
let rootContent = "# WordPress Core Standards & Agent Skills\n\n";
rootContent += "## 🛠️ Script Execution Paths\n";
rootContent += "Executable scripts for these skills are located in either:\n";
rootContent += "- `./.gemini/skills/` (if installed locally in a project)\n";
rootContent += "- `~/.gemini/extensions/wordpress-agent-skills/skills/` (if installed globally)\n\n";
rootContent += "Please use these paths when instructed to run `node` commands.\n";
fs.writeFileSync(path.join(destSkillsRoot, "GEMINI.md"), rootContent);

// 2. COPY COMMANDS
const commandsSrcDir = path.join(repoRoot, "commands");
if (fs.existsSync(commandsSrcDir)) {
const commandsDestDir = path.join(destSkillsRoot, "commands");
fs.mkdirSync(commandsDestDir, { recursive: true });
const commandFiles = fs.readdirSync(commandsSrcDir).filter((f) => f.endsWith(".md"));
for (const cmdFile of commandFiles) {
fs.copyFileSync(path.join(commandsSrcDir, cmdFile), path.join(commandsDestDir, cmdFile));
}
}

// 3. PROCESS INDIVIDUAL SKILLS
const destSkillsDir = path.join(destSkillsRoot, "skills");
fs.mkdirSync(destSkillsDir, { recursive: true });

for (const srcSkillDir of skillDirs) {
const skillName = path.basename(srcSkillDir);
const destSkillDir = path.join(destSkillsDir, skillName);
fs.mkdirSync(destSkillDir, { recursive: true });

// Build the SKILL.md for this specific skill
const srcSkillFile = path.join(srcSkillDir, "SKILL.md");
if (fs.existsSync(srcSkillFile)) {
let content = fs.readFileSync(srcSkillFile, "utf8");

// Extract frontmatter to keep it at the top
let frontmatter = "";
const frontmatterMatch = content.match(/^---\n[\s\S]*?\n---\n*/);
if (frontmatterMatch) {
frontmatter = frontmatterMatch[0];
content = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
}

// Add references if they exist
const refsDir = path.join(srcSkillDir, "references");
if (fs.existsSync(refsDir)) {
const refs = fs.readdirSync(refsDir).filter((f) => f.endsWith(".md") || f.endsWith(".json")).sort();
for (const ref of refs) {
const refPath = path.join(refsDir, ref);
let refContent = fs.readFileSync(refPath, "utf8");
const relPath = path.relative(repoRoot, refPath);

if (ref.endsWith(".json")) {
refContent = `\n\`\`\`json\n${refContent}\n\`\`\`\n`;
}

content += `\n\n***\n### 📄 Source: ${relPath}\n\n${refContent}`;
}
}

// Apply transformations
content = content.replace(/node skills\//g, 'node .gemini/skills/');
content = content.replace(/@([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/g, '&#64;$1');

// Write the combined SKILL.md
fs.writeFileSync(path.join(destSkillDir, "SKILL.md"), frontmatter + content);
}

// Copy scripts for this skill
const scriptsSrc = path.join(srcSkillDir, "scripts");
if (fs.existsSync(scriptsSrc)) {
const scriptsDest = path.join(destSkillDir, "scripts");
copyDir({ srcDir: scriptsSrc, destDir: scriptsDest });
}
}

// 4. COPY MANIFEST
const manifestSrc = path.join(repoRoot, "gemini-extension.json");
if (fs.existsSync(manifestSrc)) {
fs.copyFileSync(manifestSrc, path.join(destSkillsRoot, "gemini-extension.json"));
}
} else {
// Standard logic for Codex, VS Code, Claude, and Cursor
for (const srcSkillDir of skillDirs) {
const name = path.basename(srcSkillDir);
const destSkillDir = path.join(destSkillsRoot, name);
copyDir({ srcDir: srcSkillDir, destDir: destSkillDir });
}
}

const rel = path.relative(repoRoot, destSkillsRoot);
process.stdout.write(`OK: built ${target} skillpack at ${rel}\n`);
}

const VALID_TARGETS = ["codex", "vscode", "claude", "cursor"];
const VALID_TARGETS = ["codex", "vscode", "claude", "cursor", "gemini"];

function main() {
const args = parseArgs(process.argv.slice(2));
Expand Down Expand Up @@ -164,4 +251,3 @@ function main() {
}

main();

78 changes: 51 additions & 27 deletions shared/scripts/skillpack-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function usage() {
"Options:",
" --dest=<path> Destination repo root (required, unless using --global)",
" --from=<path> Source directory (default: dist)",
" --targets=<list> Comma-separated targets: codex, vscode, claude, claude-global, cursor, cursor-global (default: codex,vscode)",
" --targets=<list> Comma-separated targets: codex, vscode, claude, claude-global, cursor, cursor-global, gemini, gemini-global (default: codex,vscode)",
" --skills=<list> Comma-separated skill names to install (default: all)",
" --mode=<mode> 'replace' (default) or 'merge'",
" --global Shorthand for --targets=claude-global (installs to ~/.claude/skills)",
Expand All @@ -25,23 +25,28 @@ function usage() {
" claude-global Install to ~/.claude/skills/ (user-level, ignores --dest)",
" cursor Install to <dest>/.cursor/skills/",
" cursor-global Install to ~/.cursor/skills/ (user-level, ignores --dest)",
" gemini Install to <dest>/.gemini/",
" gemini-global Install to ~/.gemini/ (user-level, ignores --dest)",
"",
"Examples:",
" # Build and install to a WordPress project",
" node shared/scripts/skillpack-build.mjs --clean",
" node shared/scripts/skillpack-install.mjs --dest=../my-wp-repo --targets=codex,vscode,claude,cursor",
" node shared/scripts/skillpack-install.mjs --dest=../my-wp-repo --targets=codex,vscode,claude,cursor,gemini",
"",
" # Install globally for Claude Code (all skills)",
" node shared/scripts/skillpack-install.mjs --global",
"",
" # Install globally for Cursor (all skills)",
" node shared/scripts/skillpack-install.mjs --targets=cursor-global",
"",
" # Install globally for Gemini CLI (all skills)",
" node shared/scripts/skillpack-install.mjs --targets=gemini-global",
"",
" # Install specific skills globally",
" node shared/scripts/skillpack-install.mjs --global --skills=wp-playground,wp-block-development",
"",
" # Install to project with specific skills",
" node shared/scripts/skillpack-install.mjs --dest=../my-repo --targets=claude,cursor --skills=wp-wpcli-and-ops",
" node shared/scripts/skillpack-install.mjs --dest=../my-repo --targets=claude,cursor,gemini --skills=wp-wpcli-and-ops",
"",
].join("\n")
);
Expand Down Expand Up @@ -126,45 +131,59 @@ function copyDir({ srcDir, destDir }) {

function listSkillDirs(skillsRoot) {
if (!fs.existsSync(skillsRoot)) return [];

// For Gemini extensions, the skills are usually in a 'skills' subdirectory
const geminiSkillsDir = path.join(skillsRoot, "skills");
if (fs.existsSync(geminiSkillsDir) && fs.statSync(geminiSkillsDir).isDirectory()) {
return fs
.readdirSync(geminiSkillsDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => path.join(geminiSkillsDir, d.name))
.filter((d) => fs.existsSync(path.join(d, "SKILL.md")));
}

// Legacy/Aggregated Gemini check
if (fs.existsSync(path.join(skillsRoot, "GEMINI.md")) && !fs.existsSync(geminiSkillsDir)) {
return [skillsRoot];
}

return fs
.readdirSync(skillsRoot, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => path.join(skillsRoot, d.name))
.filter((d) => fs.existsSync(path.join(d, "SKILL.md")));
}

const VALID_TARGETS = ["codex", "vscode", "claude", "claude-global", "cursor", "cursor-global"];
const VALID_TARGETS = ["codex", "vscode", "claude", "claude-global", "cursor", "cursor-global", "gemini", "gemini-global"];

// Map target to source subdirectory in dist
function getSourceDir(fromDir, target) {
// claude-global uses the same source as claude; cursor-global uses the same as cursor
const sourceTarget =
target === "claude-global" ? "claude" : target === "cursor-global" ? "cursor" : target;
// Map global targets to their respective sources
const sourceTarget = target.endsWith("-global") ? target.replace("-global", "") : target;
const targetDirMap = {
codex: path.join(fromDir, "codex", ".codex", "skills"),
vscode: path.join(fromDir, "vscode", ".github", "skills"),
claude: path.join(fromDir, "claude", ".claude", "skills"),
cursor: path.join(fromDir, "cursor", ".cursor", "skills"),
gemini: path.join(fromDir, "gemini", ".gemini"),
};
return targetDirMap[sourceTarget];
}

// Map target to destination directory
function getDestDir(destRepoRoot, target) {
// claude-global and cursor-global don't need destRepoRoot
if (target === "claude-global") {
return path.join(os.homedir(), ".claude", "skills");
}
if (target === "cursor-global") {
return path.join(os.homedir(), ".cursor", "skills");
}
// Global targets don't need destRepoRoot
if (target === "claude-global") return path.join(os.homedir(), ".claude", "skills");
if (target === "cursor-global") return path.join(os.homedir(), ".cursor", "skills");
if (target === "gemini-global") return path.join(os.homedir(), ".gemini", "extensions", "wordpress-agent-skills");

// Other targets require destRepoRoot
const destDirMap = {
codex: path.join(destRepoRoot, ".codex", "skills"),
vscode: path.join(destRepoRoot, ".github", "skills"),
claude: path.join(destRepoRoot, ".claude", "skills"),
cursor: path.join(destRepoRoot, ".cursor", "skills"),
gemini: path.join(destRepoRoot, ".gemini"),
};
return destDirMap[target];
}
Expand All @@ -179,8 +198,8 @@ function installTarget({ fromDir, destRepoRoot, target, skillsFilter, mode, dryR
let skillDirs = listSkillDirs(srcSkillsRoot);
assert(skillDirs.length > 0, `No skills found in: ${srcSkillsRoot}`);

// Filter skills if requested
if (skillsFilter.length > 0) {
// Filter skills if requested (only applies to non-aggregated targets)
if (skillsFilter.length > 0 && !target.includes("gemini")) {
const requested = new Set(skillsFilter);
const available = skillDirs.map((d) => path.basename(d));

Expand All @@ -202,25 +221,30 @@ function installTarget({ fromDir, destRepoRoot, target, skillsFilter, mode, dryR

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

for (const srcSkillDir of skillDirs) {
const name = path.basename(srcSkillDir);
const destSkillDir = path.join(destSkillsRoot, name);
if (target.includes("gemini")) {
// For Gemini, we copy the entire .gemini source directory contents
copyDir({ srcDir: srcSkillsRoot, destDir: destSkillsRoot });
} else {
for (const srcSkillDir of skillDirs) {
const name = path.basename(srcSkillDir);
const destSkillDir = path.join(destSkillsRoot, name);

if (mode === "replace") {
fs.rmSync(destSkillDir, { recursive: true, force: true });
}
if (mode === "replace") {
fs.rmSync(destSkillDir, { recursive: true, force: true });
}

copyDir({ srcDir: srcSkillDir, destDir: destSkillDir });
copyDir({ srcDir: srcSkillDir, destDir: destSkillDir });
}
}

const isGlobal = target === "claude-global" || target === "cursor-global";
const isGlobal = target.endsWith("-global");
const location = isGlobal ? destSkillsRoot : path.relative(destRepoRoot, destSkillsRoot) || ".";
process.stdout.write(`OK: installed ${skillDirs.length} skill(s) to ${location}\n`);
}

function listAvailableSkills(fromDir) {
// Check all possible target sources
const sources = ["codex", "vscode", "claude", "cursor"]
const sources = ["codex", "vscode", "claude", "cursor", "gemini"]
.map((t) => getSourceDir(fromDir, t))
.filter((p) => fs.existsSync(p));

Expand Down Expand Up @@ -258,8 +282,8 @@ function main() {
assert(VALID_TARGETS.includes(t), `Invalid target: ${t}. Valid targets: ${VALID_TARGETS.join(", ")}`);
}

// --dest is required unless only using global targets (claude-global, cursor-global)
const needsDest = targets.some((t) => t !== "claude-global" && t !== "cursor-global");
// --dest is required unless only using global targets
const needsDest = targets.some((t) => !t.endsWith("-global"));
if (needsDest && !args.dest) {
process.stderr.write("Error: --dest is required for non-global targets.\n\n");
usage();
Expand Down
Loading