@acme-skunkworks/agent-skills 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rob Easthope
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # agent-skills
2
+
3
+ Shared agent skills for Claude Code and Cursor, distributed as [skills.sh](https://skills.sh)-compatible bundles. Each skill lives under `skills/<name>/` with a `SKILL.md` manifest at its root.
4
+
5
+ ## Installing a skill
6
+
7
+ From any consumer repo:
8
+
9
+ ```bash
10
+ npx skills add https://github.com/acme-skunkworks/agent-skills --skill <name> --agent claude-code --agent cursor --copy
11
+ ```
12
+
13
+ `--copy` writes real files (not symlinks) so the skill is portable across machines. Don't use `-g` / `--global` — installs should live in the consumer repo.
14
+
15
+ ## Repo layout
16
+
17
+ ```
18
+ .
19
+ ├── .changeset/ # pending changesets + config
20
+ ├── .claude/commands/
21
+ │ └── send-it.md # all-in-one finisher (stopgap until the send-it skill ships)
22
+ ├── .github/
23
+ │ ├── actions/
24
+ │ │ └── load-repo-config/ # infrastructure/repo-config.yaml → step outputs
25
+ │ └── workflows/
26
+ │ ├── release.yml # publish-only: build → npm (OIDC) → GitHub Packages (dormant)
27
+ │ └── validate.yml # PR gate: build & lint, changelog validation, infra tests
28
+ ├── .husky/ # git hooks (block main pushes; lint-staged; strip Claude trailer)
29
+ ├── architecture/ # ADRs (sequentially numbered, immutable)
30
+ ├── changelog/ # dated per-change release-note entries (companion to CHANGELOG.md)
31
+ ├── infrastructure/
32
+ │ ├── repo-config.yaml # non-secret CI/release knobs
33
+ │ ├── scripts/ # changelog .ts helpers + ensure-*.sh tool bootstraps
34
+ │ ├── send-it/ # deterministic helpers for /send-it
35
+ │ └── tests/ # bats (publish scripts) + vitest (changelog)
36
+ ├── scripts/ # publish wrappers (npm OIDC + GitHub Packages)
37
+ ├── skills/ # one folder per skill
38
+ ├── CLAUDE.md
39
+ ├── LICENSE
40
+ ├── README.md
41
+ └── package.json
42
+ ```
43
+
44
+ The `skills/<name>/` convention may be refined by ADR-0001 (tracked in [ASW-133](https://linear.app/goose-and-hobbes/issue/ASW-133)) once skills.sh's expected layout is double-checked.
45
+
46
+ ## Architecture decisions
47
+
48
+ ADRs land under `architecture/` as `NNNN-<slug>.md`. ADR-0001 — the foundational decision record for skill layout, distribution conventions, and semver discipline — is forthcoming.
49
+
50
+ ## Contributing
51
+
52
+ See [CLAUDE.md](./CLAUDE.md) for the conventions (Conventional Commits, draft PRs, Changesets per behavioural change).
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@acme-skunkworks/agent-skills",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "description": "Shared agent skills (Claude Code, Cursor) distributed as skills.sh-compatible bundles.",
6
+ "keywords": [
7
+ "agent-skills",
8
+ "claude-code",
9
+ "cursor",
10
+ "skills",
11
+ "skills.sh"
12
+ ],
13
+ "homepage": "https://github.com/acme-skunkworks/agent-skills#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/acme-skunkworks/agent-skills/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/acme-skunkworks/agent-skills.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": {
23
+ "name": "Rob Easthope",
24
+ "url": "https://github.com/RobEasthope"
25
+ },
26
+ "files": [
27
+ "skills/"
28
+ ],
29
+ "scripts": {
30
+ "changelog:finalise": "tsx infrastructure/scripts/finalise-changelog.ts",
31
+ "changeset": "changeset",
32
+ "changeset:version": "changeset version && pnpm changelog:finalise",
33
+ "lint:sh": "bash -c 'if command -v shellcheck >/dev/null 2>&1; then shellcheck scripts/*.sh infrastructure/scripts/*.sh .husky/pre-commit .husky/pre-push .husky/commit-msg; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ shellcheck not installed — skipping. Install: brew install shellcheck\"; else echo \"⚠️ shellcheck not installed — skipping. Install: apt-get install shellcheck\"; fi'",
34
+ "lint:workflows": "actionlint",
35
+ "lint:yaml": "yamllint .",
36
+ "prepare": "husky",
37
+ "release": "changeset publish",
38
+ "release:manual": "npm publish --access public --provenance=false",
39
+ "release:manual:dry": "npm publish --access public --provenance=false --dry-run",
40
+ "test": "vitest run",
41
+ "test:sh": "bash -c 'if command -v bats >/dev/null 2>&1; then bats infrastructure/tests/*.bats; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ bats not installed — skipping. Install: brew install bats-core\"; else echo \"⚠️ bats not installed — skipping. Install: apt-get install bats\"; fi'",
42
+ "test:watch": "vitest",
43
+ "validate:changelog": "tsx infrastructure/scripts/validate-changelog.ts",
44
+ "version": "changeset version"
45
+ },
46
+ "lint-staged": {
47
+ "**/*.sh": [
48
+ "bash -c 'if command -v shellcheck >/dev/null 2>&1; then shellcheck \"$@\"; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ shellcheck not installed — skipping. Install: brew install shellcheck\"; else echo \"⚠️ shellcheck not installed — skipping. Install: apt-get install shellcheck\"; fi' --"
49
+ ],
50
+ "**/*.{yml,yaml}": [
51
+ "bash -c 'if command -v yamllint >/dev/null 2>&1; then yamllint \"$@\"; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ yamllint not installed — skipping. Install: brew install yamllint\"; else echo \"⚠️ yamllint not installed — skipping. Install: pip install --user yamllint==1.37.1\"; fi' --"
52
+ ],
53
+ ".github/workflows/*.{yml,yaml}": [
54
+ "bash -c 'if command -v actionlint >/dev/null 2>&1; then actionlint \"$@\"; elif [ \"$(uname -s)\" = \"Darwin\" ]; then echo \"⚠️ actionlint not installed — skipping. Install: brew install actionlint\"; else echo \"⚠️ actionlint not installed — skipping. Install via the pinned bootstrap in infrastructure/scripts/ensure-actionlint.sh\"; fi' --"
55
+ ],
56
+ "changelog/*.md": [
57
+ "bash -c 'pnpm validate:changelog' --"
58
+ ]
59
+ },
60
+ "devDependencies": {
61
+ "@changesets/cli": "^2.31.0",
62
+ "@types/node": "^25.6.0",
63
+ "gray-matter": "^4.0.3",
64
+ "husky": "^9.1.7",
65
+ "lint-staged": "^16.3.2",
66
+ "tsx": "^4.21.1",
67
+ "typescript": "^6.0.3",
68
+ "vitest": "^4.1.6"
69
+ },
70
+ "packageManager": "pnpm@10.33.0",
71
+ "engines": {
72
+ "node": ">=22"
73
+ },
74
+ "publishConfig": {
75
+ "access": "public",
76
+ "provenance": true,
77
+ "registry": "https://registry.npmjs.org"
78
+ }
79
+ }
@@ -0,0 +1,11 @@
1
+ # Skills
2
+
3
+ Each skill lives in `skills/<name>/` as a [skills.sh](https://skills.sh)-compatible bundle with a `SKILL.md` manifest at its root.
4
+
5
+ Consumers install a skill from this repo with:
6
+
7
+ ```bash
8
+ npx skills add https://github.com/acme-skunkworks/agent-skills --skill <name> --agent claude-code --agent cursor --copy
9
+ ```
10
+
11
+ No skills here yet — the first one (`cleanup-repo`) is tracked under [ASW-134](https://linear.app/goose-and-hobbes/issue/ASW-134). The exact bundle layout may be refined by ADR-0001 (ASW-133) once skills.sh's expected structure is double-checked.
@@ -0,0 +1,73 @@
1
+ # cleanup-repo
2
+
3
+ Clean up a Git repository's merged branches and worktrees, then prune filesystem
4
+ cruft (recursively-empty directories and orphaned `node_modules/`) — behind a
5
+ single confirmation gate, with a `--dry-run` preview.
6
+
7
+ ## Install
8
+
9
+ From any consumer repo:
10
+
11
+ ```bash
12
+ npx skills add https://github.com/acme-skunkworks/agent-skills --skill cleanup-repo --agent claude-code --agent cursor --copy
13
+ ```
14
+
15
+ `--copy` writes real files so the bundle is portable. Don't use `-g` / `--global`
16
+ — the install should live in the consumer repo.
17
+
18
+ ## Configure
19
+
20
+ The shipped [`config.json`](config.json) carries **ACME Skunkworks defaults**
21
+ (`linearTeamName` and `issueKeys`) — update them for your organisation on install,
22
+ or the Linear lookups will target the wrong team and branch issue-IDs won't match.
23
+ A neutral [`config.example.json`](config.example.json) ships alongside it as a
24
+ template — copy it over `config.json` and fill in your values, or edit
25
+ `config.json` directly.
26
+
27
+ | Key | Meaning | Default |
28
+ | --- | --- | --- |
29
+ | `linearTeamName` | Linear team **name** used to resolve the live `Done` state. Stable across team-key renames. | `"ACME Skunkworks"` |
30
+ | `issueKeys` | Team-key prefixes that may appear in branch names; the issue-ID regex is built from these. Keep legacy keys so old branches still match. | `["ASW", "AKW", "SKW"]` |
31
+ | `protectedBranches` | Branches never deleted, locally or remotely. | `["main"]` |
32
+
33
+ > **Base branch.** v1 assumes the trunk is `origin/main` — merge detection
34
+ > (`git branch --merged origin/main`) is hard-coded to it. Repositories on
35
+ > `master` / `develop` aren't supported yet; a `mainBranch` config key is noted
36
+ > in [`references/design-notes.md`](references/design-notes.md) as a future
37
+ > extension.
38
+
39
+ ## Requirements
40
+
41
+ - `git` and `gh` CLIs (`gh` authenticated for the squash-merge detection pass).
42
+ - Node.js ≥22 (per the package's `engines`), for the bundled filesystem-hygiene script.
43
+ - The Linear MCP server is **optional**: the issue-status check and the `Done`
44
+ writeback are skipped silently when it is unavailable.
45
+
46
+ ## What it does
47
+
48
+ Two passes, one confirmation:
49
+
50
+ 1. **Branch/worktree pass** — fetches and prunes, removes merged worktrees
51
+ (guarding ones with uncommitted changes), deletes merged local and remote
52
+ branches using two-pass detection (`git branch --merged origin/main` plus
53
+ `gh pr list … --state merged` for squash-merges), optionally writes linked
54
+ Linear issues back to `Done` (default no).
55
+ 2. **Filesystem-hygiene pass** — removes top-most recursively-empty directories
56
+ (placeholder-only `.gitkeep` / `.gitignore` directories are left alone; `.git/`
57
+ is hard-protected) and orphaned `node_modules/` directories (those whose parent
58
+ has no `package.json`). The two filesystem groups are surfaced separately.
59
+
60
+ ## Behaviour parity
61
+
62
+ The **branch/worktree pass** is a faithful port of the `/cleanup-branches` slash
63
+ command (canonical reference: Octavo's `.claude/commands/cleanup-branches.md`):
64
+ the same two-pass merge detection, the same uncommitted-changes worktree guard,
65
+ the same protected-branch handling, and the same opt-in, default-no Linear `Done`
66
+ transition.
67
+
68
+ The **filesystem-hygiene pass is new** to this skill — it has no equivalent in
69
+ `/cleanup-branches`. A single bundled script computes the removal set once, so the
70
+ `--dry-run` preview lists exactly what a real run removes.
71
+
72
+ See [`references/design-notes.md`](references/design-notes.md) for the `cleanup-repo`
73
+ naming rationale and the future extensions the name deliberately leaves room for.
@@ -0,0 +1,289 @@
1
+ ---
2
+ name: cleanup-repo
3
+ description: >-
4
+ Clean up a Git repository's merged branches and worktrees, then prune
5
+ filesystem cruft (recursively-empty directories and orphaned node_modules).
6
+ Use when asked to clean up / tidy / prune merged branches, remove stale or
7
+ finished worktrees, delete branches whose PRs have already merged (including
8
+ squash-merges), or sweep empty directories and leftover node_modules. Two-pass
9
+ merge detection (git ancestry plus merged GitHub PRs), an uncommitted-changes
10
+ guard on worktrees, an optional Linear "Done" writeback, a single confirmation
11
+ gate, and a --dry-run preview. Protected branches are never touched.
12
+ license: MIT
13
+ compatibility: >-
14
+ Requires the `git` and `gh` CLIs. The optional Linear status check needs the
15
+ Linear MCP server; if it is unavailable, skip that step silently. The
16
+ filesystem pass needs Node.js ≥22.
17
+ metadata:
18
+ version: 0.1.0
19
+ allowed-tools: Bash(git:*), Bash(gh:*), Bash(node:*), mcp__linear-server__get_issue, mcp__linear-server__save_issue, mcp__linear-server__list_issue_statuses
20
+ ---
21
+
22
+ # cleanup-repo
23
+
24
+ Remove merged Git worktrees and branches, then run a filesystem-hygiene pass —
25
+ all behind a single confirmation gate, with a `--dry-run` preview.
26
+
27
+ The branch/worktree pass mirrors the behaviour of the `/cleanup-branches`
28
+ slash command it was extracted from. The filesystem-hygiene pass (recursively-empty
29
+ directories and orphan `node_modules/`) is new to this skill. See
30
+ [`references/design-notes.md`](references/design-notes.md) for the naming
31
+ rationale and the deliberately-deferred future extensions.
32
+
33
+ ## Configuration
34
+
35
+ Three knobs live in [`config.json`](config.json) beside this file. Read it at the
36
+ start of a run and use its values throughout. Edit your copied `config.json` to
37
+ match the consuming repo:
38
+
39
+ | Key | Meaning | Default |
40
+ | --- | --- | --- |
41
+ | `linearTeamName` | Linear team **name** used to resolve the live `Done` state. Use the name, not the key — the key is renamed over time but the name is stable. | `"ACME Skunkworks"` |
42
+ | `issueKeys` | Team-key prefixes that may appear in branch names. The issue-ID regex is built from these. Keep legacy keys so old branches still match. | `["ASW", "AKW", "SKW"]` |
43
+ | `protectedBranches` | Branches that are **never** deleted, locally or remotely. | `["main"]` |
44
+
45
+ Build the issue-ID regex by joining `issueKeys` with `|`:
46
+ `\b((?:ASW|AKW|SKW)-\d+)\b` for the defaults above. Match it against the
47
+ **upper-cased** branch name (branches like `asw-7-as-acquired` carry the key in
48
+ lower case).
49
+
50
+ If the Linear MCP server is not available, skip the Linear status check and the
51
+ optional `Done` writeback silently — they are not required for branch cleanup.
52
+
53
+ ## Usage modes
54
+
55
+ **Dry run** — preview everything, change nothing:
56
+
57
+ ```bash
58
+ cleanup-repo --dry-run
59
+ ```
60
+
61
+ **Normal** — preview, then delete after a single confirmation:
62
+
63
+ ```bash
64
+ cleanup-repo
65
+ ```
66
+
67
+ There is one bulk confirmation gate (Step 8) covering both the branch/worktree
68
+ pass and the filesystem pass. `--dry-run` short-circuits before that gate.
69
+
70
+ ## Process
71
+
72
+ ### Step 1 — Fetch latest from remote
73
+
74
+ ```bash
75
+ git fetch --prune origin
76
+ ```
77
+
78
+ ### Step 2 — Identify worktrees to remove
79
+
80
+ ```bash
81
+ git worktree list
82
+ ```
83
+
84
+ - List all worktrees except the main repository directory (the primary working
85
+ directory is never removed).
86
+ - Identify worktrees whose branch is fully merged into `main`.
87
+ - Identify worktrees in detached-HEAD state — treat as abandoned, safe to remove.
88
+ - Identify worktrees with uncommitted changes: `git -C <path> status --porcelain`
89
+ non-empty. These are surfaced separately in Step 6 and **never removed
90
+ automatically** — the user handles them manually (`git worktree remove --force
91
+ <path>` once they have moved or discarded the work).
92
+ - Worktree location is irrelevant to detection; `git worktree list` enumerates
93
+ them wherever they live (e.g. a gitignored `.claude/worktrees/<branch>/`).
94
+
95
+ ### Step 3 — Identify merged branches (two-pass)
96
+
97
+ **Pass 1 — Git-merged branches:**
98
+
99
+ - Find local branches merged into `main`: `git branch --merged origin/main`.
100
+ - Exclude every branch in `protectedBranches`.
101
+ - Determine which of those branches also still exist on the remote.
102
+
103
+ **Pass 2 — Squash-merged branches:**
104
+
105
+ A squash merge lands a single new commit on `main`, so the branch's own commits
106
+ are never ancestors of `main` and `git branch --merged` misses it. For each local
107
+ branch **not** caught in Pass 1 (and not protected):
108
+
109
+ ```bash
110
+ gh pr list --head <branch-name> --state merged --json number,title --limit 1
111
+ ```
112
+
113
+ `gh` auto-detects the repository from the current directory's remote, so no
114
+ `--repo` flag is needed.
115
+
116
+ - A non-empty result means the branch has a merged PR — treat it as merged and
117
+ add it to the cleanup list.
118
+ - An empty result means the branch is genuinely unmerged — leave it alone.
119
+ - Record the PR number and title for the summary so the user can verify.
120
+
121
+ ### Step 4 — Check Linear issue status for merged branches
122
+
123
+ For each merged branch whose name contains an issue ID (extract with the regex
124
+ built from `issueKeys`, matched against the upper-cased branch name):
125
+
126
+ - Fetch the issue via `mcp__linear-server__get_issue`.
127
+ - Track any issue that is **not** in `Done` status.
128
+
129
+ Skip this step silently if the Linear MCP server is unavailable.
130
+
131
+ ### Step 5 — Run the filesystem-hygiene detection
132
+
133
+ Run the bundled script against the repository root to get the candidate list.
134
+ It is read-only without `--apply`:
135
+
136
+ ```bash
137
+ node scripts/filesystem-hygiene.mjs <repo-root> --json
138
+ ```
139
+
140
+ It prints `{ "emptyDirs": [...], "orphanNodeModules": [...] }`:
141
+
142
+ - **`emptyDirs`** — top-most recursively-empty directories (no files anywhere in
143
+ the subtree). Directories holding any file — including a `.gitkeep` /
144
+ `.gitignore` placeholder — are left alone. `.git/` is never traversed.
145
+ - **`orphanNodeModules`** — `node_modules/` directories whose immediate parent has
146
+ no `package.json` (strict; no workspace inference). Removing one re-installs is
147
+ needed if the parent was not actually meant to be gone — which is why these are
148
+ surfaced **separately**.
149
+
150
+ This detection is read-only and feeds the Step 6 preview. One subtlety: Step 9
151
+ removes worktrees **before** re-running the detection with `--apply`, so the apply
152
+ pass can additionally sweep a parent that becomes empty only once its worktrees are
153
+ gone (e.g. `.claude/worktrees/`). Such a directory won't appear in this pre-removal
154
+ detect output — predict it from the worktree-removal list and label it as a
155
+ post-removal sweep in the preview, so the user isn't surprised when `--apply`
156
+ removes it.
157
+
158
+ ### Step 6 — Display everything to be deleted
159
+
160
+ Show clear, counted lists. Keep the filesystem groups separate so the user can
161
+ eyeball them:
162
+
163
+ ```text
164
+ ## Worktrees to Remove (3)
165
+ - /path/.claude/worktrees/ASW-7-as-acquired (merged)
166
+ - /path/.claude/worktrees/ASW-9-button-styling (squash-merged, PR #42)
167
+ - /path/.claude/worktrees/orphan-detached (detached HEAD)
168
+
169
+ ## Worktrees Skipped — Uncommitted Changes (1)
170
+ - /path/.claude/worktrees/ASW-12-wip (merged, but `git status` is non-empty;
171
+ remove manually with `git worktree remove --force <path>`)
172
+
173
+ ## Local Branches to Delete (3)
174
+ - ASW-7-as-acquired (merged)
175
+ - ASW-9-button-styling (squash-merged, PR #42 "Fix button styling")
176
+ - chore-update-deps (merged)
177
+
178
+ ## Remote Branches to Delete (2)
179
+ - ASW-7-as-acquired
180
+ - ASW-9-button-styling
181
+
182
+ ## Linear Issues Still Open (1)
183
+ - ASW-9 "Button styling" — currently In Review (branch: ASW-9-button-styling)
184
+
185
+ ## Empty Directories to Remove (1)
186
+ - /path/.claude/worktrees (predicted: empty once the worktrees above are removed)
187
+
188
+ ## Orphan node_modules to Remove (1)
189
+ - /path/old-package/node_modules (no sibling package.json)
190
+ ```
191
+
192
+ ### Step 7 — Dry-run handling
193
+
194
+ If `--dry-run` is set, STOP here. Print `DRY RUN MODE - No changes were made` and
195
+ exit without changing anything.
196
+
197
+ ### Step 8 — Confirmation (normal mode only)
198
+
199
+ Ask once: `Do you want to delete these worktrees, branches, and files? (yes/no)`.
200
+ Proceed only if the user answers `yes`; otherwise exit without deleting.
201
+
202
+ ### Step 9 — Execute, in order
203
+
204
+ Order matters. Worktrees must go before their branches, and the filesystem pass
205
+ runs **after** worktree removal so a just-emptied worktree parent (e.g.
206
+ `.claude/worktrees/`) is swept in the same run.
207
+
208
+ 1. **Remove worktrees** (skip the uncommitted-changes group from Step 6):
209
+
210
+ ```bash
211
+ git worktree remove <path>
212
+ ```
213
+
214
+ 2. **Prune stale worktree references:**
215
+
216
+ ```bash
217
+ git worktree prune
218
+ ```
219
+
220
+ 3. **Delete local branches:**
221
+
222
+ ```bash
223
+ git branch -d <branch-name> # Pass 1 (git-merged) — safe delete
224
+ git branch -D <branch-name> # Pass 2 (squash-merged) — force is safe: a
225
+ # merged PR was confirmed via the GitHub API
226
+ ```
227
+
228
+ 4. **Delete remote branches** that still exist:
229
+
230
+ ```bash
231
+ git push origin --delete <branch-name>
232
+ ```
233
+
234
+ 5. **Filesystem-hygiene removal** — re-run the bundled script with `--apply`. It
235
+ removes exactly the same set it detects and prints what it removed:
236
+
237
+ ```bash
238
+ node scripts/filesystem-hygiene.mjs <repo-root> --apply
239
+ ```
240
+
241
+ Removing an orphan `node_modules/` can leave its parent empty; that parent is
242
+ intentionally left for a follow-up run rather than swept in this snapshot.
243
+
244
+ ### Step 10 — Optional Linear `Done` writeback
245
+
246
+ If any Linear issues from Step 4 are not `Done`:
247
+
248
+ - Ask: `These Linear issues are linked to merged branches but aren't Done. Set
249
+ them to Done? (yes/no)`. **Default no** — Linear's GitHub integration normally
250
+ handles this on PR merge, so this exists only for the rare case where it didn't
251
+ fire (e.g. the issue ID was added after the merge).
252
+ - If yes:
253
+ - Resolve the live `Done` state ID **once** via
254
+ `mcp__linear-server__list_issue_statuses` with `team: <linearTeamName>` —
255
+ state IDs are per-team and the team key changes over time, so pass the team
256
+ *name*.
257
+ - For each open issue, call `mcp__linear-server__save_issue` with
258
+ `state: <Done state ID>`.
259
+ - If no, skip without changes.
260
+
261
+ ### Step 11 — Summary
262
+
263
+ Report counts: worktrees removed, local branches deleted, remote branches
264
+ deleted, empty directories removed, orphan `node_modules/` removed, Linear issues
265
+ set to `Done` (if any). List the names of deleted items.
266
+
267
+ ## Important rules
268
+
269
+ - **Dry-run** previews without deleting (`--dry-run`).
270
+ - **Confirmation required** before any deletion (single bulk gate).
271
+ - **Protected branches** (`protectedBranches`) are never touched.
272
+ - **Merged only**: a branch is deleted only if merged into `main` via git
273
+ ancestry **or** a merged GitHub PR (squash merges).
274
+ - **Worktrees first**, then branches; filesystem pass last.
275
+ - **Uncommitted worktrees** are never force-removed automatically.
276
+ - **`.git/` and the main worktree** are never touched.
277
+
278
+ ## Error handling
279
+
280
+ - Skip (and report) any worktree or branch that fails to remove; continue with
281
+ the rest.
282
+ - Skip remote branches that no longer exist (already deleted).
283
+ - If `gh pr list` fails for a branch (network, rate limit, auth), log a warning
284
+ and continue — do not treat the branch as merged.
285
+ - If the Linear MCP server is unavailable, skip the Linear steps silently.
286
+
287
+ ## Arguments
288
+
289
+ $ARGUMENTS
@@ -0,0 +1,5 @@
1
+ {
2
+ "linearTeamName": "Your Linear Team",
3
+ "issueKeys": ["ABC", "XYZ"],
4
+ "protectedBranches": ["main"]
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "linearTeamName": "ACME Skunkworks",
3
+ "issueKeys": ["ASW", "AKW", "SKW"],
4
+ "protectedBranches": ["main"]
5
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@acme-skunkworks/skill-cleanup-repo",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Agent skill: clean up merged Git branches and worktrees, plus a filesystem-hygiene pass (empty directories and orphan node_modules).",
6
+ "keywords": [
7
+ "agent-skill",
8
+ "claude-code",
9
+ "cursor",
10
+ "git",
11
+ "cleanup",
12
+ "worktree"
13
+ ],
14
+ "homepage": "https://github.com/acme-skunkworks/agent-skills/tree/main/skills/cleanup-repo#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/acme-skunkworks/agent-skills/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/acme-skunkworks/agent-skills.git",
21
+ "directory": "skills/cleanup-repo"
22
+ },
23
+ "license": "MIT",
24
+ "author": {
25
+ "name": "Rob Easthope",
26
+ "url": "https://github.com/RobEasthope"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ }
31
+ }
@@ -0,0 +1,56 @@
1
+ # cleanup-repo — design notes
2
+
3
+ ## Why `cleanup-repo`, not `cleanup-branches`
4
+
5
+ The skill lifts an existing slash command called `/cleanup-branches`, but it is
6
+ named `cleanup-repo` deliberately:
7
+
8
+ - It already does more than branches — it removes **worktrees** (with an
9
+ uncommitted-changes guard and detached-HEAD handling) and runs a
10
+ **filesystem-hygiene** pass.
11
+ - The broader name leaves room for further repo-hygiene extensions without
12
+ another rename.
13
+
14
+ Per-repo slash-command names are a consumer choice. A consumer can expose this as
15
+ `/cleanup-repo`, or keep `/cleanup-branches` as a friendlier alias.
16
+
17
+ ## Initial scope
18
+
19
+ - Merged-worktree and merged-branch cleanup (two-pass detection), an opt-in Linear
20
+ `Done` writeback, a single confirmation gate, and a `--dry-run` preview — at
21
+ parity with `/cleanup-branches`.
22
+ - **New:** a filesystem-hygiene pass — recursively-empty directory pruning and
23
+ orphan `node_modules/` pruning.
24
+
25
+ ## Deliberate non-goals (v1)
26
+
27
+ - **The filesystem pass is not parameterised.** The placeholder allowlist
28
+ (`.gitkeep`, `.gitignore`) is fixed, and the orphan-`node_modules` rule is fixed
29
+ at the strict "no sibling `package.json`" check. Either can become a config knob
30
+ in a future minor bump if a real consumer case demands it.
31
+ - **No workspace-membership inference** for orphan `node_modules/` — strict
32
+ parent-`package.json` check only.
33
+ - **Single-snapshot removal within `apply()`.** `apply()` detects once and removes
34
+ that snapshot; removing an orphan `node_modules/` can leave its parent empty, but
35
+ that parent is left for a follow-up run rather than swept in the same snapshot.
36
+ Note the skill runs the filesystem detection twice — a read-only pass for the
37
+ preview, then `apply()` **after** worktree removal — so the apply pass may sweep a
38
+ worktree parent (e.g. `.claude/worktrees/`) that the pre-removal preview could not
39
+ yet see. The skill predicts these in the preview from the worktree-removal list.
40
+ - **Merge-detection base branch is hardcoded to `origin/main`.** Both passes (git
41
+ ancestry and merged-PR lookup) assume `main`; a consumer defaulting to `master` or
42
+ `develop` must edit the skill. A future minor bump should add a `mainBranch` config
43
+ key alongside `protectedBranches`.
44
+
45
+ ## Future extensions (out of scope, enabled by the name)
46
+
47
+ Each would be a follow-up issue and a Changeset minor bump:
48
+
49
+ - Pruning stale local tags (`git tag --merged main` style).
50
+ - An optional reflog / garbage-collection trigger after large cleanups.
51
+ - Surfacing local branches with no upstream that have been stale for N days.
52
+ - Build-artifact cleanup (`dist/`, `build/`, `.next/`, `coverage/`, …) — only if a
53
+ generalisable heuristic emerges; tool-specific cleanup stays the user's job.
54
+
55
+ Explicitly **not** planned: lockfile / package-manager cache cleanup
56
+ (`pnpm store prune`, npm/yarn caches) — a different concern on a different cadence.
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ // Filesystem-hygiene pass for the cleanup-repo skill.
3
+ //
4
+ // Detects two kinds of cruft and (optionally) removes them:
5
+ // - emptyDirs : recursively-empty directories — a directory whose
6
+ // entire subtree contains no files at all. The top-most
7
+ // such directory is reported (removing it takes its
8
+ // empty descendants with it). A directory holding any
9
+ // file — including a `.gitkeep` / `.gitignore`
10
+ // placeholder — is NOT empty and is left alone.
11
+ // - orphanNodeModules : `node_modules/` directories whose immediate parent has
12
+ // no `package.json` (strict; no workspace inference).
13
+ //
14
+ // `.git/` is hard-protected: never traversed, and its presence marks the
15
+ // containing directory as non-empty (so a nested working tree is never swept).
16
+ // `node_modules/` is never traversed (huge, and handled by the orphan check).
17
+ //
18
+ // For a given filesystem state the SAME detection drives both the report and
19
+ // the removal, so `--apply` removes exactly what a plain run lists. (The skill
20
+ // runs detection twice — before and after worktree removal — so its end-to-end
21
+ // preview can still pick up parents emptied by that removal; see SKILL.md.)
22
+ //
23
+ // Usage:
24
+ // node filesystem-hygiene.mjs [root] # print detection JSON (default cwd)
25
+ // node filesystem-hygiene.mjs [root] --json # same, explicit
26
+ // node filesystem-hygiene.mjs [root] --apply # remove the detected set, print JSON
27
+ // node filesystem-hygiene.mjs --self-test # run built-in fixtures
28
+
29
+ import {
30
+ readdirSync,
31
+ existsSync,
32
+ statSync,
33
+ rmSync,
34
+ mkdtempSync,
35
+ mkdirSync,
36
+ writeFileSync,
37
+ } from "node:fs";
38
+ import { join, dirname, resolve, sep } from "node:path";
39
+ import { tmpdir } from "node:os";
40
+ import { fileURLToPath } from "node:url";
41
+
42
+ // Recurse a directory. Returns true when the directory is recursively empty
43
+ // (its subtree contains no files). Side effects: pushes top-most empty
44
+ // directories into `emptyDirs` and every node_modules path into `nodeModulesDirs`.
45
+ function scan(dir, emptyDirs, nodeModulesDirs) {
46
+ let entries;
47
+ try {
48
+ entries = readdirSync(dir, { withFileTypes: true });
49
+ } catch {
50
+ // Unreadable directory — treat as content so we never remove it.
51
+ return false;
52
+ }
53
+
54
+ let hasContent = false;
55
+ const emptyChildDirs = [];
56
+
57
+ for (const entry of entries) {
58
+ const full = join(dir, entry.name);
59
+
60
+ if (entry.isSymbolicLink()) {
61
+ // Never follow or remove through symlinks.
62
+ hasContent = true;
63
+ continue;
64
+ }
65
+ if (entry.isFile()) {
66
+ // Any file — placeholders included — counts as content.
67
+ hasContent = true;
68
+ continue;
69
+ }
70
+ if (entry.isDirectory()) {
71
+ if (entry.name === ".git") {
72
+ hasContent = true; // protect working trees / nested repos
73
+ continue;
74
+ }
75
+ if (entry.name === "node_modules") {
76
+ nodeModulesDirs.push(full);
77
+ hasContent = true; // not traversed; handled by the orphan check
78
+ continue;
79
+ }
80
+ const childEmpty = scan(full, emptyDirs, nodeModulesDirs);
81
+ if (childEmpty) {
82
+ emptyChildDirs.push(full);
83
+ } else {
84
+ hasContent = true;
85
+ }
86
+ continue;
87
+ }
88
+ // Sockets, FIFOs, devices — treat as content.
89
+ hasContent = true;
90
+ }
91
+
92
+ if (hasContent) {
93
+ // This directory stays; its recursively-empty children are the top-most
94
+ // empties (their parent has content), so they are the ones to remove.
95
+ for (const child of emptyChildDirs) emptyDirs.push(child);
96
+ return false;
97
+ }
98
+ // No content anywhere in this subtree: this whole directory is removable.
99
+ // Don't record its children — the parent records this directory instead.
100
+ return true;
101
+ }
102
+
103
+ export function detect(root) {
104
+ if (!existsSync(root)) {
105
+ throw new Error(`Root path does not exist: ${root}`);
106
+ }
107
+ if (!statSync(root).isDirectory()) {
108
+ throw new Error(`Root path is not a directory: ${root}`);
109
+ }
110
+ const emptyDirs = [];
111
+ const nodeModulesDirs = [];
112
+ // The root itself is never a removal candidate.
113
+ scan(root, emptyDirs, nodeModulesDirs);
114
+
115
+ const orphanNodeModules = nodeModulesDirs.filter(
116
+ (nm) => !existsSync(join(dirname(nm), "package.json")),
117
+ );
118
+
119
+ return {
120
+ emptyDirs: emptyDirs.sort(),
121
+ orphanNodeModules: orphanNodeModules.sort(),
122
+ };
123
+ }
124
+
125
+ export function apply(root) {
126
+ const result = detect(root);
127
+ const removed = [];
128
+ const failed = [];
129
+ // Isolate per-path failures: one un-removable entry (permissions, a race)
130
+ // must not abort the rest. Report what was removed and what wasn't.
131
+ for (const path of [...result.orphanNodeModules, ...result.emptyDirs]) {
132
+ try {
133
+ rmSync(path, { recursive: true, force: true });
134
+ removed.push(path);
135
+ } catch (err) {
136
+ failed.push({ path, error: err.message });
137
+ }
138
+ }
139
+ return { ...result, removed, failed };
140
+ }
141
+
142
+ function parseArgs(argv) {
143
+ const flags = new Set(argv.filter((a) => a.startsWith("--")));
144
+ const positional = argv.filter((a) => !a.startsWith("--"));
145
+ return { flags, root: positional[0] ?? process.cwd() };
146
+ }
147
+
148
+ // ---- self-test ----------------------------------------------------------
149
+
150
+ function buildFixture() {
151
+ const root = mkdtempSync(join(tmpdir(), "cleanup-repo-fs-"));
152
+ const d = (...p) => {
153
+ const full = join(root, ...p);
154
+ mkdirSync(full, { recursive: true });
155
+ return full;
156
+ };
157
+ const f = (rel, body = "") => writeFileSync(join(root, rel), body);
158
+
159
+ // A repo-like root marker so the root always has content.
160
+ d(".git");
161
+ f(".git/HEAD", "ref: refs/heads/main\n");
162
+
163
+ // 1. A recursively-empty directory (nested, no files) → top-most reported.
164
+ d("empty-top", "a", "b");
165
+
166
+ // 2. A directory whose subtree holds only a placeholder → left alone.
167
+ d("placeholder-only");
168
+ f("placeholder-only/.gitkeep");
169
+
170
+ // 3. A directory with a real file → left alone.
171
+ d("has-file");
172
+ f("has-file/index.ts", "export {};\n");
173
+
174
+ // 4. Orphan node_modules (parent has no package.json).
175
+ d("orphan-pkg", "node_modules", "left-pad");
176
+
177
+ // 5. Legitimate node_modules (parent has package.json) → not orphan.
178
+ d("real-pkg", "node_modules", "left-pad");
179
+ f("real-pkg/package.json", "{}\n");
180
+
181
+ // 6. A nested .git (sub working tree) inside an otherwise file-free dir →
182
+ // must NOT be reported as empty.
183
+ d("nested-repo", ".git");
184
+
185
+ return root;
186
+ }
187
+
188
+ function selfTest() {
189
+ const cases = [];
190
+ const root = buildFixture();
191
+ let result;
192
+ try {
193
+ result = detect(root);
194
+ } catch (e) {
195
+ console.log(` FAIL detect threw (${e.message})`);
196
+ process.exit(1);
197
+ }
198
+
199
+ const rel = (p) => p.slice(root.length).split(sep).filter(Boolean).join("/");
200
+ const empties = result.emptyDirs.map(rel);
201
+ const orphans = result.orphanNodeModules.map(rel);
202
+
203
+ cases.push({
204
+ name: "top-most empty directory is reported",
205
+ ok: empties.includes("empty-top"),
206
+ });
207
+ cases.push({
208
+ name: "empty descendants are not reported individually",
209
+ ok: !empties.includes("empty-top/a") && !empties.includes("empty-top/a/b"),
210
+ });
211
+ cases.push({
212
+ name: "placeholder-only directory is left alone",
213
+ ok: !empties.includes("placeholder-only"),
214
+ });
215
+ cases.push({
216
+ name: "directory with a real file is left alone",
217
+ ok: !empties.includes("has-file"),
218
+ });
219
+ cases.push({
220
+ name: "orphan node_modules (no sibling package.json) is reported",
221
+ ok: orphans.includes("orphan-pkg/node_modules"),
222
+ });
223
+ cases.push({
224
+ name: "node_modules with a sibling package.json is NOT an orphan",
225
+ ok: !orphans.includes("real-pkg/node_modules"),
226
+ });
227
+ cases.push({
228
+ name: "node_modules never appears in emptyDirs",
229
+ ok: !empties.some((p) => p.split("/").includes("node_modules")),
230
+ });
231
+ cases.push({
232
+ name: "directory holding a nested .git is not reported empty",
233
+ ok: !empties.includes("nested-repo"),
234
+ });
235
+ cases.push({
236
+ name: ".git is never reported or traversed",
237
+ ok:
238
+ !empties.some((p) => p.split("/").includes(".git")) &&
239
+ !orphans.some((p) => p.split("/").includes(".git")),
240
+ });
241
+
242
+ // apply() removes exactly the detected snapshot and nothing else.
243
+ const before = detect(root);
244
+ const applied = apply(root);
245
+ const after = detect(root);
246
+ cases.push({
247
+ name: "apply removed the originally detected paths",
248
+ ok:
249
+ before.emptyDirs.every((p) => !existsSync(p)) &&
250
+ before.orphanNodeModules.every((p) => !existsSync(p)),
251
+ });
252
+ cases.push({
253
+ name: "apply reports every removed path and no failures on a clean run",
254
+ ok:
255
+ applied.failed.length === 0 &&
256
+ applied.removed.length ===
257
+ before.emptyDirs.length + before.orphanNodeModules.length,
258
+ });
259
+ cases.push({
260
+ name: "apply preserves placeholder-only and file-bearing directories",
261
+ ok: existsSync(join(root, "placeholder-only")) && existsSync(join(root, "has-file")),
262
+ });
263
+ // Removing orphan-pkg/node_modules empties its parent — a deliberate cascade
264
+ // that a *subsequent* run sweeps, never the same snapshot.
265
+ cases.push({
266
+ name: "removing an orphan node_modules leaves its parent for the next run",
267
+ ok:
268
+ after.emptyDirs.map(rel).join(",") === "orphan-pkg" &&
269
+ after.orphanNodeModules.length === 0,
270
+ });
271
+
272
+ // detect() fails fast on a root that exists but is not a directory, rather
273
+ // than silently reporting nothing (readdirSync would throw ENOTDIR in scan).
274
+ const fileRoot = join(tmpdir(), `cleanup-repo-fs-not-a-dir-${process.pid}`);
275
+ writeFileSync(fileRoot, "");
276
+ let threwOnFileRoot = false;
277
+ try {
278
+ detect(fileRoot);
279
+ } catch {
280
+ threwOnFileRoot = true;
281
+ }
282
+ rmSync(fileRoot, { force: true });
283
+ cases.push({
284
+ name: "detect throws on a non-directory root",
285
+ ok: threwOnFileRoot,
286
+ });
287
+
288
+ rmSync(root, { recursive: true, force: true });
289
+
290
+ let failed = 0;
291
+ for (const { name, ok } of cases) {
292
+ if (ok) {
293
+ console.log(` ok ${name}`);
294
+ } else {
295
+ failed += 1;
296
+ console.log(` FAIL ${name}`);
297
+ }
298
+ }
299
+ console.log(`\n${cases.length - failed}/${cases.length} passed`);
300
+ process.exit(failed === 0 ? 0 : 1);
301
+ }
302
+
303
+ function main() {
304
+ const argv = process.argv.slice(2);
305
+ if (argv.includes("--self-test")) {
306
+ selfTest();
307
+ return;
308
+ }
309
+ const { flags, root } = parseArgs(argv);
310
+ const result = flags.has("--apply") ? apply(root) : detect(root);
311
+ console.log(JSON.stringify(result, null, 2));
312
+ }
313
+
314
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
315
+ main();
316
+ }