
Contribute Skill
- 1 installs
- Updated May 17, 2026
- ducnguyenhuu/ducnguyen_learningspace
Contribute a local skill to an upstream skills repo by validating frontmatter, forking, branching, copying files, and opening a PR via a Node script.
About
Automates contributing a local skill upstream by forking the repo, creating a contrib branch, copying files, and opening a PR. A developer uses it to share a custom skill with their team or company.
- Validates SKILL.md frontmatter before contributing
- Creates fork, contrib branch, and PR with a template
Contribute Skill by the numbers
- 1 all-time installs (skills.sh)
- Ranked #644 of 782 Skill Development skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ducnguyenhuu/ducnguyen_learningspace --skill contribute-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | May 17, 2026 |
| Repository | ducnguyenhuu/ducnguyen_learningspace ↗ |
What it does
Contribute a local skill to an upstream skills repo by validating frontmatter, forking, branching, copying files, and opening a PR via a Node script.
Files
/contribute-skill - Contribute a Skill
Create a pull request to contribute a local skill to the upstream skills repository.
When to Use
Use this when:
- You've created a custom skill that would benefit others
- The skill is generic enough for company-wide use
- You want to share your workflow automation
Prerequisites
GITHUB_TOKENenvironment variable set (with repo access)- The skill must exist in
.claude/skills/<skill-name>/
Usage
node .claude/skills/contribute-skill/scripts/contribute.js <skill-name>What Happens
1. Validates the skill exists, has required files (SKILL.md), and frontmatter includes name, description, author, and author_email 2. Creates a fork of the upstream repo (if you don't have one) 3. Creates a branch named contrib/<skill-name> 4. Copies files to src/scaffolds/skills/<skill-name>/ 5. Creates a PR with a contribution template
Naming Convention
Use the <category>.<action> format for skill names (e.g., rally.get-item, sonar.get-issues, java.test.status). This enables automatic category detection and pack-based deployment in the wizard.
Architecture Notes
When your skill is merged:
Skills without scripts (prompt-only)
- Copied directly to
src/scaffolds/skills/, ready to use
Skills with scripts (script-backed)
- Scripts must be TypeScript for sidecar integration
- The esbuild bundler compiles TS → JS for deployment as self-contained bundles
- JavaScript contributions will need conversion before merge
What Maintainers Will Do
After your PR is submitted, maintainers will:
1. Bundler registration - Add entry to src/scripts/bundle-skills.ts (for script-backed skills) 2. Scaffold index - Register new category in src/scaffolds/index.ts (if introducing a new category) 3. Wizard pack - Add pack entry to src/commands/wizard.ts (if new category needs a pack) 4. TypeScript conversion - Convert JS scripts to TS if contributed as JavaScript 5. Deployment tests - Add test coverage in tests/skills-deployment.test.ts
Example
# Contribute your "api-helper" skill
node .claude/skills/contribute-skill/scripts/contribute.js api-helperOutput:
Contributing skill: api-helper
✓ Found skill at .claude/skills/api-helper/
✓ Validated SKILL.md
✓ Found 2 additional files
✓ Created fork your-username/skills-repo
✓ Created branch contrib/api-helper
✓ Added src/scaffolds/skills/api-helper/SKILL.md
✓ Added src/scaffolds/skills/api-helper/scripts/helper.js
Pull request created:
https://github.example.com/org/skills-repo/pull/42
Next steps:
- Review the PR description
- Add any additional context in comments
- Wait for maintainer reviewPR Template
The PR is created with:
- Title:
feat(skills): add <skill-name> skill - Description includes:
- What the skill does (from SKILL.md description)
- Files included
- Note about script conversion if applicable
Troubleshooting
"GITHUB_TOKEN not set"
Set your GitHub token:
export GITHUB_TOKEN=your_token_here
# Or add to .env file"Skill not found"
Ensure the skill exists at .claude/skills/<skill-name>/SKILL.md
"Permission denied"
Your GitHub token needs repo scope to create forks and PRs.
#!/usr/bin/env node
// src/skill-tools/cli/contribute-skill.ts
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, statSync } from "node:fs";
import { join as join2, relative } from "node:path";
// src/core/env.ts
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
function loadEnv() {
const loaded = {};
const envPath = join(process.cwd(), ".env");
if (!existsSync(envPath)) {
return loaded;
}
try {
const content = readFileSync(envPath, "utf-8");
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) {
continue;
}
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1).trim();
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1);
}
if (key && process.env[key] === void 0) {
process.env[key] = value;
loaded[key] = value;
}
}
} catch {
}
return loaded;
}
// src/skill-tools/cli/contribute-skill.ts
var UPSTREAM_OWNER = "NextGear";
var UPSTREAM_REPO = "sidecar";
var GHE_API_BASE = "https://ghe.coxautoinc.com/api/v3";
var TARGET_PATH = "src/scaffolds/skills";
var REQUIRED_FRONTMATTER = ["name", "description", "author", "author_email"];
function getGitHubToken() {
loadEnv();
const token = process.env["GITHUB_TOKEN"] || process.env["GH_TOKEN"];
if (!token) {
throw new Error(
'GITHUB_TOKEN not set. Set it in your shell config or .env file.\nToken needs "repo" scope for creating forks and PRs.'
);
}
return token;
}
function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match?.[1]) return {};
const fm = {};
for (const line of match[1].split("\n")) {
const eqIndex = line.indexOf(":");
if (eqIndex === -1) continue;
const key = line.slice(0, eqIndex).trim();
const value = line.slice(eqIndex + 1).trim();
if (key && value) {
fm[key] = value;
}
}
return fm;
}
function validateSkillFrontmatter(content) {
const fm = parseFrontmatter(content);
return REQUIRED_FRONTMATTER.filter((field) => !fm[field]);
}
function extractAuthor(content) {
const fm = parseFrontmatter(content);
if (!fm.author) return void 0;
return fm.author_email ? `${fm.author} <${fm.author_email}>` : fm.author;
}
async function githubRequest(endpoint, options = {}) {
const token = getGitHubToken();
const url = endpoint.startsWith("http") ? endpoint : `${GHE_API_BASE}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
...options.headers
}
});
if (!response.ok) {
const error = await response.text();
throw new Error(`GitHub API error (${response.status}): ${error}`);
}
return response.json();
}
async function getCurrentUser() {
return githubRequest("/user");
}
async function getRepo(owner, repo) {
try {
return await githubRequest(`/repos/${owner}/${repo}`);
} catch {
return null;
}
}
async function createFork() {
console.log(` Creating fork of ${UPSTREAM_OWNER}/${UPSTREAM_REPO}...`);
return githubRequest(
`/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/forks`,
{ method: "POST" }
);
}
async function getDefaultBranchSha(owner, repo) {
const repoInfo = await githubRequest(`/repos/${owner}/${repo}`);
const ref = await githubRequest(
`/repos/${owner}/${repo}/git/ref/heads/${repoInfo.default_branch}`
);
return ref.object.sha;
}
async function createBranch(owner, repo, branchName, sha) {
try {
await githubRequest(`/repos/${owner}/${repo}/git/refs`, {
method: "POST",
body: JSON.stringify({
ref: `refs/heads/${branchName}`,
sha
})
});
} catch (error) {
if (String(error).includes("Reference already exists")) {
console.log(` Branch ${branchName} already exists, will update it`);
} else {
throw error;
}
}
}
async function createOrUpdateFile(owner, repo, branch, path, content, message) {
const base64Content = Buffer.from(content).toString("base64");
let sha;
try {
const existing = await githubRequest(
`/repos/${owner}/${repo}/contents/${path}?ref=${branch}`
);
sha = existing.sha;
} catch {
}
await githubRequest(`/repos/${owner}/${repo}/contents/${path}`, {
method: "PUT",
body: JSON.stringify({
message,
content: base64Content,
branch,
sha
})
});
}
async function createPullRequest(owner, head, base, title, body) {
const headRef = owner.toLowerCase() === UPSTREAM_OWNER.toLowerCase() ? head : `${owner}:${head}`;
return githubRequest(`/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/pulls`, {
method: "POST",
body: JSON.stringify({
title,
body,
head: headRef,
base
})
});
}
function readSkillFiles(skillPath, skillName) {
const files = [];
function walkDir(dir) {
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join2(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
walkDir(fullPath);
} else {
const relativePath = relative(skillPath, fullPath);
const targetPath = `${TARGET_PATH}/${skillName}/${relativePath}`;
const content = readFileSync2(fullPath, "utf-8");
files.push({ path: targetPath, content });
}
}
}
walkDir(skillPath);
return files;
}
function extractDescription(skillMdContent) {
const match = skillMdContent.match(/description:\s*(.+?)(?:\n|$)/);
return match?.[1]?.trim() || "A contributed skill";
}
function printUsage() {
console.log(`
Usage: node contribute.js <skill-name>
Creates a pull request to contribute a local skill to Sidecar.
Arguments:
skill-name Name of the skill in .claude/skills/
Example:
node contribute.js my-custom-skill
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
printUsage();
process.exit(args.length === 0 ? 1 : 0);
}
const skillName = args[0];
const cwd = process.cwd();
const skillPath = join2(cwd, ".claude", "skills", skillName);
const skillMdPath = join2(skillPath, "SKILL.md");
console.log(`
Contributing skill: ${skillName}
`);
if (!existsSync2(skillPath)) {
console.error(`\u2717 Skill not found at ${skillPath}`);
process.exit(1);
}
console.log(`\u2713 Found skill at .claude/skills/${skillName}/`);
if (!existsSync2(skillMdPath)) {
console.error(`\u2717 SKILL.md not found in skill directory`);
process.exit(1);
}
const skillMdContent = readFileSync2(skillMdPath, "utf-8");
const missingFields = validateSkillFrontmatter(skillMdContent);
if (missingFields.length > 0) {
console.error(`\u2717 SKILL.md is missing required frontmatter fields: ${missingFields.join(", ")}`);
console.error(` Required fields: ${REQUIRED_FRONTMATTER.join(", ")}`);
console.error(` Add a YAML frontmatter block at the top of SKILL.md:`);
console.error(` ---`);
console.error(` name: ${skillName}`);
console.error(` description: What this skill does`);
console.error(` author: Your Name`);
console.error(` author_email: your.email@company.com`);
console.error(` ---`);
process.exit(1);
}
console.log(`\u2713 Validated SKILL.md frontmatter`);
const KNOWN_CATEGORIES = ["rally", "github", "sonar", "testrail", "speckit", "java"];
if (!skillName.includes(".")) {
console.log(`\u26A0 Skill name has no category prefix. Consider using <category>.<action> format.`);
console.log(` Known categories: ${KNOWN_CATEGORIES.join(", ")}`);
console.log(` Example: ${KNOWN_CATEGORIES[0]}.${skillName}`);
}
const files = readSkillFiles(skillPath, skillName);
console.log(`\u2713 Found ${files.length} file(s) to contribute`);
const stalePathFiles = [];
for (const file of files) {
if (file.path.endsWith(".md") && file.content.includes(".github/skills/")) {
stalePathFiles.push(file.path);
}
}
if (stalePathFiles.length > 0) {
console.log(`\u26A0 Found stale .github/skills/ references in:`);
for (const f of stalePathFiles) {
console.log(` ${f}`);
}
console.log(` Fix to .claude/skills/ before contributing.`);
}
const hasScripts = files.some((f) => f.path.includes("/scripts/"));
const hasJsScripts = files.some((f) => f.path.includes("/scripts/") && f.path.endsWith(".js"));
if (hasScripts && hasJsScripts) {
console.log(`\u26A0 WARNING: Scripts must be TypeScript for sidecar integration.`);
console.log(` JavaScript files will require conversion before merge.`);
}
const scriptsDir = join2(skillPath, "scripts");
const hasScriptsDir = existsSync2(scriptsDir) && readdirSync(scriptsDir).length > 0;
const skillType = hasScriptsDir ? "script-backed" : "prompt-only";
const detectedCategory = skillName.includes(".") ? skillName.split(".")[0] : "uncategorized";
const description = extractDescription(skillMdContent);
const author = extractAuthor(skillMdContent);
try {
const user = await getCurrentUser();
console.log(`\u2713 Authenticated as ${user.login}`);
const upstreamRepo = await getRepo(UPSTREAM_OWNER, UPSTREAM_REPO);
const canPushUpstream = upstreamRepo?.permissions?.push === true;
let targetOwner;
if (canPushUpstream) {
console.log(`\u2713 You have push access to ${UPSTREAM_OWNER}/${UPSTREAM_REPO} - pushing directly`);
targetOwner = UPSTREAM_OWNER;
} else {
let userRepo = await getRepo(user.login, UPSTREAM_REPO);
if (!userRepo) {
userRepo = await createFork();
console.log(`\u2713 Created fork ${user.login}/${UPSTREAM_REPO}`);
await new Promise((resolve) => setTimeout(resolve, 2e3));
} else if (userRepo.fork) {
console.log(`\u2713 Using existing fork ${user.login}/${UPSTREAM_REPO}`);
} else {
console.error(`\u2717 ${user.login}/${UPSTREAM_REPO} exists but is not a fork of ${UPSTREAM_OWNER}/${UPSTREAM_REPO}`);
console.error(` Rename or delete it, then re-run this command.`);
process.exit(1);
}
targetOwner = user.login;
}
const baseSha = await getDefaultBranchSha(UPSTREAM_OWNER, UPSTREAM_REPO);
const branchName = `contrib/${skillName}`;
await createBranch(targetOwner, UPSTREAM_REPO, branchName, baseSha);
console.log(`\u2713 Created branch ${branchName}`);
for (const file of files) {
await createOrUpdateFile(
targetOwner,
UPSTREAM_REPO,
branchName,
file.path,
file.content,
`feat(skills): add ${skillName} skill`
);
console.log(`\u2713 Added ${file.path}`);
}
const authorLine = author ? `
**Author:** ${author}` : "";
const notes = [];
if (hasJsScripts) {
notes.push("- Scripts are JavaScript and will need TypeScript conversion before merge");
}
if (stalePathFiles.length > 0) {
notes.push("- Contains stale `.github/skills/` path references that need updating to `.claude/skills/`");
}
const notesSection = notes.length > 0 ? `## Notes
${notes.join("\n")}
` : "";
const prBody = `## Summary
Contributes the \`${skillName}\` skill.
**Description:** ${description}${authorLine}
**Type:** ${skillType}
**Category:** ${detectedCategory}
## Files Added
${files.map((f) => `- \`${f.path}\``).join("\n")}
## Maintainer Integration Checklist
- [ ] **Naming**: Follows \`<category>.<action>\` convention
- [ ] **Bundler**: Entry added to \`src/scripts/bundle-skills.ts\` (if script-backed)
- [ ] **Scaffold index**: Category registered in \`src/scaffolds/index.ts\` (if new category)
- [ ] **Wizard pack**: Pack entry added to \`src/commands/wizard.ts\` (if new category)
- [ ] **TypeScript**: Scripts converted from JS to TS (if applicable)
- [ ] **Tests**: Deployment test added to \`tests/skills-deployment.test.ts\`
- [ ] **Paths**: No stale \`.github/skills/\` references in SKILL.md
${notesSection}---
*Contributed via \`/contribute-skill\`*
`;
const pr = await createPullRequest(
targetOwner,
branchName,
"master",
`feat(skills): add ${skillName} skill`,
prBody
);
console.log(`
\u2713 Pull request created!
`);
console.log(` ${pr.html_url}
`);
console.log(`Next steps:`);
console.log(` 1. Review the PR description`);
console.log(` 2. Add any additional context in comments`);
console.log(` 3. Wait for maintainer review`);
} catch (error) {
console.error(`
\u2717 Failed to create PR:`, error);
process.exit(1);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});