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
3 changes: 2 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

## Checklist

- [ ] `skillshare-hub.json` is valid JSON
- [ ] Added entry to the correct `skills/*.json` category file (see [CONTRIBUTING.md](../CONTRIBUTING.md#categories))
- [ ] Ran `make build` and committed the updated `skillshare-hub.json`
- [ ] `name` is lowercase with hyphens only
- [ ] `description` is a clear one-liner
- [ ] `source` repo is publicly accessible and contains a valid `SKILL.md`
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/validate-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: Validate PR
on:
pull_request:
paths:
- 'skills/**'
- 'skillshare-hub.json'
- 'scripts/**'
- '.github/workflows/validate-pr.yml'
Expand All @@ -16,6 +17,17 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Build hub.json from skills/
run: ./scripts/build.sh

- name: Check hub.json is up to date
run: |
if ! git diff --quiet skillshare-hub.json; then
echo "::error::skillshare-hub.json is out of date. Run 'make build' and commit the result."
git diff skillshare-hub.json
exit 1
fi

- name: Validate hub.json
run: ./scripts/validate.sh

Expand Down
51 changes: 45 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,29 @@ Thanks for sharing your skill with the community!
## How to Add a Skill

1. Fork this repo
2. Edit `skillshare-hub.json` — add your skill entry to the `skills` array
3. Open a Pull Request
4. CI will validate your entry automatically
5. A maintainer will review and merge
2. Find the right category file in `skills/` (see [Categories](#categories) below)
3. Add your skill entry to the JSON array in that file
4. Run `make build` to regenerate `skillshare-hub.json`
5. Commit **both** the `skills/*.json` change and `skillshare-hub.json`
6. Open a Pull Request
7. CI will validate and audit your entry automatically
8. A maintainer will review and merge

> **Important**: Do not edit `skillshare-hub.json` directly — it is generated from `skills/*.json` by `make build`. CI will reject PRs where the hub file is out of sync.

## Categories

| File | Description |
|------|-------------|
| `skills/skill.json` | Hub meta-skills (skillshare, skill-creator, find-skills, template-skill) |
| `skills/anthropic.json` | Skills from `anthropics/skills` |
| `skills/marketing.json` | Skills from `coreyhaines31/marketingskills` |
| `skills/workflow.json` | Skills from `obra/superpowers` and `obra/episodic-memory` |
| `skills/expo.json` | Skills from `expo/skills` |
| `skills/vercel.json` | Skills from `vercel-labs/*` and `vercel/*` |
| `skills/community.json` | Everything else — independent authors and orgs |

> **Not sure where to put your skill?** Just add it to `skills/community.json`. Maintainers will move it if needed.

## Skill Entry Format

Expand All @@ -23,7 +42,18 @@ Single-skill repo:
}
```

Multi-skill repo (use `skill` to specify which one):
Multi-skill repo (use subpath in `source`):

```json
{
"name": "my-skill",
"description": "One-line description of what the skill does",
"source": "your-username/your-repo/my-skill",
"tags": ["relevant", "tags"]
}
```

Multi-skill repo (use `skill` field to specify which one):

```json
{
Expand Down Expand Up @@ -52,7 +82,7 @@ Non-GitHub platforms (GitLab, Bitbucket, self-hosted, etc.):
|-------|----------|-------------|
| `name` | Yes | Unique skill name — lowercase, hyphens only (e.g. `commit-helper`) |
| `description` | Yes | One-line description of what the skill does |
| `source` | Yes | GitHub `owner/repo`, full git URL, or any platform URL |
| `source` | Yes | GitHub `owner/repo[/subpath]`, full git URL, or any platform URL |
| `skill` | No | Specific skill name within a multi-skill repo (installs via `-s`) |
| `tags` | No | 1-3 classification tags |

Expand All @@ -71,6 +101,15 @@ Feel free to introduce new tags if none fit — maintainers may suggest changes
- **Clear description** — explain what the skill does, not how it works
- **No duplicates** — search existing entries before adding

## Local Development

```bash
make build # Build skillshare-hub.json from skills/*.json
make validate # Validate format and rules
make audit # Audit new/changed skills against main
make ci # Run all three (build → validate → audit)
```

## Using Skills from the Hub

```bash
Expand Down
10 changes: 6 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
.PHONY: format validate audit ci
.PHONY: build format validate audit ci

format: ## Format skillshare-hub.json (sort by name, 2-space indent)
@./scripts/format.sh
build: ## Build skillshare-hub.json from skills/*.json
@./scripts/build.sh

format: build ## Alias for build (build already sorts and formats)

validate: ## Validate skillshare-hub.json format and rules
@./scripts/validate.sh

audit: ## Audit new/changed skills against main
@./scripts/audit.sh main

ci: validate audit ## Run full CI locally
ci: build validate audit ## Run full CI locally
176 changes: 109 additions & 67 deletions scripts/audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,110 +11,60 @@ log_group() { is_ci && echo "::group::$1" || echo "=== $1 ==="; }
log_endgroup() { is_ci && echo "::endgroup::" || true; }
log_error() { is_ci && echo "::error::$1" || echo "ERROR: $1"; }

# --- Find new/changed sources ---
base_sources=$(git show "${BASE_REF}:${HUB_FILE}" 2>/dev/null \
| jq -r '.skills[] | "\(.name)|\(.source)"' | sort) || base_sources=""

pr_sources=$(jq -r '.skills[] | "\(.name)|\(.source)"' "$HUB_FILE" | sort)

new_entries=$(comm -13 <(echo "$base_sources") <(echo "$pr_sources"))
new_sources=$(echo "$new_entries" | cut -d'|' -f2 | sort -u)

if [ -z "$new_entries" ]; then
echo "No new or changed skill sources to audit"
[ -n "$REPORT_FILE" ] && echo "No new or changed skill sources to audit." > "$REPORT_FILE"
exit 0
fi

echo "New/changed sources to audit:"
echo "$new_sources"
echo ""

# --- Check skillshare is available ---
if ! command -v skillshare &>/dev/null; then
echo "ERROR: skillshare CLI not found. Install from https://github.com/runkids/skillshare"
exit 1
fi
# --- Parse source into clone_url and subpath ---
# Output: clone_url<TAB>subpath
parse_source() {
local source="$1"
local clone_url="" subpath=""

# --- Audit each source ---
failed=0
results=()
details=()

while IFS= read -r source; do
[ -z "$source" ] && continue

# Parse source into clone_url + optional subpath
subpath=""
if [[ "$source" =~ ^https?://github\.com/([^/]+/[^/]+)/tree/[^/]+/(.+)$ ]]; then
# GitHub browser URL: https://github.com/owner/repo/tree/branch/path
clone_url="https://github.com/${BASH_REMATCH[1]}.git"
subpath="${BASH_REMATCH[2]}"
elif [[ "$source" =~ ^https?://github\.com/([^/]+/[^/]+)/?$ ]]; then
# GitHub repo URL (no subpath): https://github.com/owner/repo
clone_url="https://github.com/${BASH_REMATCH[1]}.git"
elif [[ "$source" =~ ^github\.com/([^/]+/[^/]+)(/(.+))?$ ]]; then
# github.com shorthand: github.com/owner/repo[/path]
clone_url="https://github.com/${BASH_REMATCH[1]}.git"
subpath="${BASH_REMATCH[3]}"
elif [[ "$source" =~ ^(https?://[^/]+)/([^/]+/[^/]+)/src/branch/[^/]+/(.+)$ ]]; then
# Gitea/Forgejo browser URL: https://host/owner/repo/src/branch/main/path
clone_url="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}.git"
subpath="${BASH_REMATCH[3]}"
elif [[ "$source" =~ ^(https?://[^/]+)/([^/]+/[^/]+)/src/branch/ ]]; then
# Gitea/Forgejo repo URL (branch root, no subpath)
clone_url="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}.git"
elif [[ "$source" == http* ]]; then
# Other HTTP URLs — use as-is
clone_url="$source"
else
# GitHub shorthand: owner/repo[/path]
local owner_repo
owner_repo=$(echo "$source" | cut -d'/' -f1-2)
subpath=$(echo "$source" | cut -d'/' -f3-)
clone_url="https://github.com/${owner_repo}.git"
fi

safe_name=$(echo "$source" | tr '/' '-')
clone_dir="/tmp/audit-${safe_name}"

log_group "Auditing ${source}"
printf '%s\t%s' "$clone_url" "$subpath"
}

if ! clone_output=$(git clone --depth 1 "$clone_url" "$clone_dir" 2>&1); then
log_error "Failed to clone ${source}"
results+=("| \`${source}\` | :x: Clone failed | - |")
details+=("DETAIL_SEP### \`${source}\`"$'\n'"Clone failed:"$'\n'"\`\`\`"$'\n'"${clone_output}"$'\n'"\`\`\`")
failed=1
log_endgroup
continue
fi

# Audit subpath if specified, otherwise audit entire clone
audit_target="$clone_dir"
if [ -n "$subpath" ] && [ -d "$clone_dir/$subpath" ]; then
audit_target="$clone_dir/$subpath"
fi
# --- Audit a single target and record results ---
# Sets: results[], details[], failed
audit_target() {
local source="$1" audit_target="$2"

local audit_json
audit_json=$(skillshare audit "$audit_target" --threshold high --json 2>/dev/null) || true

# Check if output is valid audit JSON
if ! echo "$audit_json" | jq -e '.summary' >/dev/null 2>&1; then
# Not valid JSON — command errored (e.g. path not found)
local audit_err
audit_err=$(skillshare audit "$audit_target" --threshold high 2>&1) || true
echo "$audit_err"
results+=("| \`${source}\` | :x: Error | - |")
details+=("DETAIL_SEP### \`${source}\`"$'\n'"\`\`\`"$'\n'"${audit_err}"$'\n'"\`\`\`")
failed=1
rm -rf "$clone_dir"
log_endgroup
continue
return
fi

# Extract risk info with jq
local risk_score risk_label risk findings_text audit_display
risk_score=$(echo "$audit_json" | jq -r '.summary.riskScore')
risk_label=$(echo "$audit_json" | jq -r '.summary.riskLabel' | tr '[:lower:]' '[:upper:]')
risk="${risk_label} (${risk_score}/100)"

# Format findings for display
findings_text=$(echo "$audit_json" | jq -r '
[.results[] | (.findings // [])[] | " \(.severity): \(.message) (\(.file):\(.line))\n \"\(.snippet)\""]
| join("\n\n")')
Expand All @@ -131,16 +81,108 @@ while IFS= read -r source; do
results+=("| \`${source}\` | :white_check_mark: Passed | ${risk} |")
details+=("DETAIL_SEP### \`${source}\`"$'\n'"\`\`\`"$'\n'"${audit_display}"$'\n'"\`\`\`")
fi
}

# --- Find new/changed sources ---
base_sources=$(git show "${BASE_REF}:${HUB_FILE}" 2>/dev/null \
| jq -r '.skills[] | "\(.name)|\(.source)"' | sort) || base_sources=""

pr_sources=$(jq -r '.skills[] | "\(.name)|\(.source)"' "$HUB_FILE" | sort)

new_entries=$(comm -13 <(echo "$base_sources") <(echo "$pr_sources"))
new_sources=$(echo "$new_entries" | cut -d'|' -f2 | sort -u)

if [ -z "$new_entries" ]; then
echo "No new or changed skill sources to audit"
[ -n "$REPORT_FILE" ] && echo "No new or changed skill sources to audit." > "$REPORT_FILE"
exit 0
fi

# --- Check skillshare is available ---
if ! command -v skillshare &>/dev/null; then
echo "ERROR: skillshare CLI not found. Install from https://github.com/runkids/skillshare"
exit 1
fi

# --- Group sources by clone_url (clone once per repo) ---
# Use temp dir instead of associative arrays (Bash 3.x compat)
group_dir=$(mktemp -d)
trap 'rm -rf "$group_dir"' EXIT
Comment on lines +109 to +110

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This script creates temporary directories in multiple places (group_dir and clone_dir). While group_dir is cleaned up by a trap, the clone_dirs created in /tmp are removed manually. If the script is interrupted (e.g., with Ctrl-C), these clone directories might be left behind.

For more robust cleanup, consider creating a single parent temporary directory for all transient data. A single trap on this parent directory would ensure all temporary files and directories are cleaned up, regardless of how the script terminates.

Example structure:

# Create a single root temp dir at the start
audit_tmp_dir=$(mktemp -d)
trap 'rm -rf "$audit_tmp_dir"' EXIT

# Use subdirectories within it
group_dir="${audit_tmp_dir}/groups"
mkdir "$group_dir"
# ...
clone_dir="${audit_tmp_dir}/clones/${safe_name}"
# ...

This would make the script's cleanup mechanism more resilient.

clone_order_file="$group_dir/_order"
touch "$clone_order_file"

while IFS= read -r source; do
[ -z "$source" ] && continue

parsed=$(parse_source "$source")
clone_url=$(echo "$parsed" | cut -f1)
subpath=$(echo "$parsed" | cut -f2)

safe_name=$(echo "$clone_url" | sed 's|https://||;s|\.git$||' | tr '/:' '-')
if ! grep -qxF "$safe_name" "$clone_order_file" 2>/dev/null; then
echo "$safe_name" >> "$clone_order_file"
echo "$clone_url" > "$group_dir/${safe_name}.url"
fi
printf '%s\t%s\n' "$source" "$subpath" >> "$group_dir/${safe_name}.sources"
done <<< "$new_sources"

unique_repos=$(wc -l < "$clone_order_file" | tr -d ' ')
echo "New/changed sources to audit: $(echo "$new_sources" | wc -l | tr -d ' ') sources across ${unique_repos} repos"
echo ""

# --- Audit each repo ---
failed=0
results=()
details=()

while IFS= read -r safe_name; do
[ -z "$safe_name" ] && continue
clone_url=$(cat "$group_dir/${safe_name}.url")
clone_dir="/tmp/audit-${safe_name}"
rm -rf "$clone_dir"

log_group "Cloning ${clone_url}"

if ! clone_output=$(git clone --depth 1 "$clone_url" "$clone_dir" 2>&1); then
# Clone failed — mark all sources in this repo as failed
while IFS=$'\t' read -r source subpath; do
[ -z "$source" ] && continue
log_error "Failed to clone ${source}"
results+=("| \`${source}\` | :x: Clone failed | - |")
details+=("DETAIL_SEP### \`${source}\`"$'\n'"Clone failed:"$'\n'"\`\`\`"$'\n'"${clone_output}"$'\n'"\`\`\`")
failed=1
done < "$group_dir/${safe_name}.sources"
rm -rf "$clone_dir"
log_endgroup
continue
fi

echo "Cloned ${clone_url} — auditing subpaths..."

# Audit each source/subpath in this repo
while IFS=$'\t' read -r source subpath; do
[ -z "$source" ] && continue

local_target="$clone_dir"
if [ -n "$subpath" ] && [ -d "$clone_dir/$subpath" ]; then
local_target="$clone_dir/$subpath"
fi

echo " Auditing: ${source}"
audit_target "$source" "$local_target"
done < "$group_dir/${safe_name}.sources"

rm -rf "$clone_dir"
log_endgroup
done <<< "$new_sources"
done < "$clone_order_file"

# --- Generate report ---
if [ -n "$REPORT_FILE" ]; then
{
echo "## Skill Audit Results"
echo ""
echo "Audited $(echo "$new_sources" | wc -l | tr -d ' ') sources across ${unique_repos} repos."
echo ""
echo "| Source | Status | Risk |"
echo "|--------|--------|------|"
for row in "${results[@]}"; do
Expand Down
26 changes: 26 additions & 0 deletions scripts/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail

SKILLS_DIR="${1:-skills}"
HUB_FILE="skillshare-hub.json"

if [ ! -d "$SKILLS_DIR" ]; then
echo "ERROR: $SKILLS_DIR directory not found"
exit 1
fi

# Count source files
files=("$SKILLS_DIR"/*.json)
if [ ${#files[@]} -eq 0 ]; then
echo "ERROR: No .json files found in $SKILLS_DIR"
exit 1
fi
Comment on lines +14 to +17

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This check for missing .json files is not quite correct for standard bash behavior. Without shopt -s nullglob, if no files match a glob pattern, the pattern itself is returned as a single-element array. This means ${#files[@]} will be 1, not 0, and this check will be bypassed, leading to a less clear error from jq later.

A more reliable way to check is to see if the first element of the array actually exists as a file.

Suggested change
if [ ${#files[@]} -eq 0 ]; then
echo "ERROR: No .json files found in $SKILLS_DIR"
exit 1
fi
if [ ! -e "${files[0]}" ]; then
echo "ERROR: No .json files found in $SKILLS_DIR"
exit 1
fi


# Merge all arrays, sort by name, wrap in hub schema
jq -s '{
schemaVersion: 1,
skills: ([.[][]] | sort_by(.name))
}' "${files[@]}" > "$HUB_FILE"

count=$(jq '.skills | length' "$HUB_FILE")
echo "Built: $HUB_FILE ($count skills from ${#files[@]} files)"
Loading