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
1 change: 0 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,6 @@ jobs:
- name: Sync skills
if: steps.check.outputs.ready == 'true'
env:
CLI_NAME: cli
SKILLS_TOKEN: ${{ steps.skills-token.outputs.token }}
RELEASE_TAG: ${{ github.ref_name }}
SOURCE_SHA: ${{ github.sha }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/sensitive-change-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jobs:
with:
extra-patterns: |
scripts/sync-skills.sh
seed/scripts/sync-skills.sh
permissions:
contents: read
pull-requests: write
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ jobs:
- name: Test
run: go test -v ./...

- name: Test the skills sync
run: seed/scripts/test-sync-skills.sh

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,25 @@ When authoring new seed templates:
- Keep generated code minimal — point to shared packages where possible
- Test by running the `prompts/seed-cli.md` prompt end-to-end

## Skills sync

Every CLI publishes its `skills/<name>/` trees into `skills/<name>/` at the root of
`basecamp/skills` (the layout `npx skills add basecamp/skills` reads). The one
implementation is `seed/scripts/sync-skills.sh`; `scripts/sync-skills.sh` here execs it
with `SYNC_SOURCE=cli`, and `actions/sync-skills` runs it from the action's checkout.
Change the seed script, never a copy.

Several CLIs share that target, so each one owns `.managed-skills.<source>` there
(`<source>` is the publishing repo: `hey-cli`, `basecamp-cli`, `cli`) and removes only
skill directories its own manifest lists, that its skill set no longer has, and that no
other manifest claims. The legacy shared `.managed-skills` is rewritten as a comment-only
tombstone so a sibling still on the pre-fix script deletes nothing (basecamp/skills#5).
The script always clones the target fresh (from `SKILLS_REPO_URL`, default
`https://github.com/basecamp/skills.git`) and pushes only the commit it made, so there is
no checkout to hand it. `seed/scripts/test-sync-skills.sh` runs the script as two CLIs
against a local bare repository — real clones, commits and pushes, no network — and is
part of `make check`.

## Rubric

[RUBRIC.md](RUBRIC.md) defines the quality standard for 37signals Go CLIs. Two profiles:
Expand Down
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
.DEFAULT_GOAL := check

.PHONY: check test test-race vet lint fmt fmt-check bench check-all \
.PHONY: check test test-sync-skills test-race vet lint fmt fmt-check bench check-all \
tidy tidy-check replace-check vuln secrets security release-check release

# Default target: fast checks for inner-loop dev.
check: fmt-check vet test
check: fmt-check vet test test-sync-skills

test:
go test ./...

# The skills sync (seed/scripts/sync-skills.sh, which scripts/sync-skills.sh runs)
# against a throwaway basecamp/skills, as two CLIs publishing in turn
test-sync-skills:
seed/scripts/test-sync-skills.sh

test-race:
go test -race ./...

Expand Down Expand Up @@ -79,7 +84,7 @@ lint-actions:
zizmor .

# Full suite: everything CI runs.
check-all: fmt-check vet lint lint-actions test-race bench tidy-check
check-all: fmt-check vet lint lint-actions test-race test-sync-skills bench tidy-check

# Full pre-flight for release
release-check: check-all replace-check vuln secrets
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Reusable composite actions in `actions/`:
|--------|-------------|
| `rubric-check` | Score a built CLI binary against the 37signals CLI rubric |
| `surface-compat` | Fail CI if CLI flags or subcommands were removed (breaking change) |
| `sync-skills` | Sync embedded SKILL.md files to the `basecamp/skills` distribution repo on release |
| `sync-skills` | Publish embedded skills to the `basecamp/skills` distribution repo on release (runs `seed/scripts/sync-skills.sh`) |

Usage in a workflow:

Expand Down Expand Up @@ -86,12 +86,18 @@ The `skills/` directory contains agent skills distributed via `basecamp/skills`:

- `rubric-audit` — Audit a Go CLI against the rubric

On release, `scripts/sync-skills.sh` publishes each one to `skills/<name>/` in
`basecamp/skills`, where every 37signals CLI publishes its own. Each publisher owns a
manifest there, `.managed-skills.<source>`, and only ever removes skills it listed —
the scheme, and the seed script every CLI runs, are described in
`seed/scripts/sync-skills.sh`.

## Development

Requires Go 1.24+.

```
make check # fmt-check + vet + test (inner-loop dev)
make check # fmt-check + vet + test + test-sync-skills (inner-loop dev)
make test # go test ./...
make test-race # go test -race ./...
make lint # golangci-lint run
Expand Down
124 changes: 23 additions & 101 deletions actions/sync-skills/action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
name: Sync Skills
description: Sync embedded SKILL.md files to basecamp/skills distribution repo
description: Publish embedded skills to the basecamp/skills distribution repo

# Runs seed/scripts/sync-skills.sh from this action's own checkout, so the sync
# logic lives in one place. The script's header documents the manifest scheme and
# every env var; the inputs here map onto those one to one.

inputs:
skills-token:
Expand All @@ -12,10 +16,18 @@ inputs:
description: The source commit SHA
required: true
cli-name:
description: The CLI name (used as directory prefix in skills repo)
description: The CLI name; the publishing source is <cli-name>-cli
required: true
source:
description: Override the publishing source name (default <cli-name>-cli)
required: false
default: ""
skills-source:
description: Directory holding the skills tree
required: false
default: skills
dry-run:
description: '"local" to skip push, "remote" to skip commit+push, empty for real run'
description: '"local" to skip the push, "remote" to skip commit and push, empty for a real run'
required: false
default: ""

Expand All @@ -29,105 +41,15 @@ runs:
RELEASE_TAG: ${{ inputs.release-tag }}
SOURCE_SHA: ${{ inputs.source-sha }}
CLI_NAME: ${{ inputs.cli-name }}
SYNC_SOURCE: ${{ inputs.source }}
SKILLS_SOURCE: ${{ inputs.skills-source }}
DRY_RUN: ${{ inputs.dry-run }}
ACTION_PATH: ${{ github.action_path }}
run: |
set -euo pipefail
SKILLS_REPO="basecamp/skills"
SKILLS_DIR="skills"
MANAGED_MANIFEST=".managed-skills"

# Clone the skills repo
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT

echo "::group::Clone skills repo"
if ! git clone "https://x-access-token:${SKILLS_TOKEN}@github.com/${SKILLS_REPO}.git" "$WORK_DIR/skills-repo" 2>&1 | grep -v 'x-access-token'; then
echo "::error::Failed to clone ${SKILLS_REPO}"
exit 1
fi
echo "::endgroup::"

TARGET_DIR="${WORK_DIR}/skills-repo"

# Collect skill directories
SKILL_DIRS=()
for skill_dir in ${SKILLS_DIR}/*/; do
if [[ -f "${skill_dir}/SKILL.md" ]]; then
SKILL_DIRS+=("$skill_dir")
fi
done

if [[ ${#SKILL_DIRS[@]} -eq 0 ]]; then
echo "No skills found in ${SKILLS_DIR}/"
# The release workflows check this before generating a token; the action
# has no step in front of it, so a CLI with no skills yet is a no-op here.
if ! compgen -G "${SKILLS_SOURCE}/*/SKILL.md" > /dev/null; then
echo "No skill files found under ${SKILLS_SOURCE}/ — skipping sync"
exit 0
fi

echo "Found ${#SKILL_DIRS[@]} skill(s) to sync"

# Copy skills
MANAGED_SKILLS=()
for skill_dir in "${SKILL_DIRS[@]}"; do
skill_name=$(basename "$skill_dir")
dest="${TARGET_DIR}/${CLI_NAME}/${skill_name}"
echo " Syncing ${skill_name}..."
mkdir -p "$dest"
# Preserve subdirectory structure; exclude Go sources and dotfiles
(cd "$skill_dir" && find . -type f ! -name '*.go' ! -name '.*' | while read -r f; do
mkdir -p "$dest/$(dirname "$f")"
cp "$f" "$dest/$f"
done)
MANAGED_SKILLS+=("${CLI_NAME}/${skill_name}")
done

# Update manifest
MANIFEST_PATH="${TARGET_DIR}/${MANAGED_MANIFEST}"
if [[ -f "$MANIFEST_PATH" ]]; then
grep -v "^${CLI_NAME}/" "$MANIFEST_PATH" > "${MANIFEST_PATH}.tmp" || true
mv "${MANIFEST_PATH}.tmp" "$MANIFEST_PATH"
fi
for skill in "${MANAGED_SKILLS[@]}"; do
echo "$skill" >> "$MANIFEST_PATH"
done
sort -u -o "$MANIFEST_PATH" "$MANIFEST_PATH"

# Remove stale skills
if [[ -d "${TARGET_DIR}/${CLI_NAME}" ]]; then
for existing in "${TARGET_DIR}/${CLI_NAME}"/*/; do
existing_name=$(basename "$existing")
found=false
for skill_dir in "${SKILL_DIRS[@]}"; do
[[ "$(basename "$skill_dir")" == "$existing_name" ]] && found=true && break
done
if [[ "$found" == "false" ]]; then
echo " Removing stale: ${existing_name}"
rm -rf "$existing"
fi
done
fi

[[ "$DRY_RUN" == "remote" ]] && echo "DRY_RUN=remote: done" && exit 0

# Commit
cd "$TARGET_DIR"
git add -A
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi

git config user.name "${CLI_NAME}-cli[bot]"
git config user.email "${CLI_NAME}-cli[bot]@users.noreply.github.com"
git commit -m "Sync ${CLI_NAME} skills from ${RELEASE_TAG}

Source: ${SOURCE_SHA}"

[[ "$DRY_RUN" == "local" ]] && echo "DRY_RUN=local: done" && exit 0

# Push
echo "::group::Push to ${SKILLS_REPO}"
if ! git push origin main; then
git pull --rebase origin main
git push origin main
fi
echo "::endgroup::"
echo "Skills synced successfully"
bash "${ACTION_PATH}/../../seed/scripts/sync-skills.sh"
3 changes: 2 additions & 1 deletion prompts/seed-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ You are creating a new Go CLI for a 37signals product using the seed templates.
- `seed/scripts/check-cli-surface-diff.sh` → `scripts/check-cli-surface-diff.sh` (copy; chmod +x)
- `seed/scripts/collect-profile.sh` → `scripts/collect-profile.sh` (copy; chmod +x)
- `seed/scripts/publish-aur.sh` → `scripts/publish-aur.sh` (copy; chmod +x)
- `seed/scripts/sync-skills.sh` → `scripts/sync-skills.sh` (copy; chmod +x)
- `seed/scripts/sync-skills.sh` → `scripts/sync-skills.sh` (copy; chmod +x; set `CLI_NAME`)
- `seed/scripts/test-sync-skills.sh` → `scripts/test-sync-skills.sh` (copy; chmod +x)

**GitHub infra (copy as-is unless .tmpl):**
- `seed/.github/workflows/test.yml` → `.github/workflows/test.yml` (update env vars, GOPRIVATE)
Expand Down
Loading
Loading