@ecology91/skills 0.1.4 → 0.1.6
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/.claude-plugin/marketplace.json +45 -0
- package/README.md +32 -27
- package/bin/collect-skills.mjs +97 -0
- package/bin/install.mjs +130 -44
- package/bin/prompt-skills.mjs +39 -0
- package/bin/resolve-selection.mjs +107 -0
- package/bin/resolve-selection.test.mjs +60 -0
- package/package.json +10 -4
- package/scripts/generate-marketplace.mjs +24 -0
- package/scripts/link-skills.sh +1 -36
- package/scripts/verify-marketplace.mjs +33 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"metadata": {
|
|
3
|
+
"pluginRoot": "./skills"
|
|
4
|
+
},
|
|
5
|
+
"plugins": [
|
|
6
|
+
{
|
|
7
|
+
"name": "engineering",
|
|
8
|
+
"source": "./engineering",
|
|
9
|
+
"skills": [
|
|
10
|
+
"./diagnose",
|
|
11
|
+
"./grill-with-docs",
|
|
12
|
+
"./improve-codebase-architecture",
|
|
13
|
+
"./prototype",
|
|
14
|
+
"./setup-agent-skills",
|
|
15
|
+
"./setup-coding-quality-checks",
|
|
16
|
+
"./tdd",
|
|
17
|
+
"./to-issues",
|
|
18
|
+
"./to-prd",
|
|
19
|
+
"./to-qa",
|
|
20
|
+
"./triage",
|
|
21
|
+
"./zoom-out"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "productivity",
|
|
26
|
+
"source": "./productivity",
|
|
27
|
+
"skills": [
|
|
28
|
+
"./caveman",
|
|
29
|
+
"./grill-me",
|
|
30
|
+
"./handoff",
|
|
31
|
+
"./write-a-skill"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "misc",
|
|
36
|
+
"source": "./misc",
|
|
37
|
+
"skills": [
|
|
38
|
+
"./git-guardrails-opencode",
|
|
39
|
+
"./migrate-to-shoehorn",
|
|
40
|
+
"./scaffold-exercises",
|
|
41
|
+
"./setup-pre-commit"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Skills For Real Engineers
|
|
2
2
|
|
|
3
|
-
[
|
|
4
|
-
[
|
|
3
|
+
[skills.sh](https://skills.sh/ecology9191/skills)
|
|
4
|
+
[npm](https://www.npmjs.com/package/@ecology91/skills)
|
|
5
5
|
|
|
6
6
|
Agent skills for doing real engineering with opencode - not vibe coding.
|
|
7
7
|
|
|
@@ -17,26 +17,33 @@ These skills are designed to be small, easy to adapt, and composable. They work
|
|
|
17
17
|
npx skills@latest add ecology9191/skills -g
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
The install menu is grouped by bucket (`engineering`, `productivity`, `misc`). Toggle a whole category or pick individual skills in one screen.
|
|
21
|
+
|
|
22
|
+
1. Or install from a git checkout or the published npm package:
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
25
|
npx --package @ecology91/skills ecology91-skills
|
|
24
26
|
```
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
This opens an interactive menu grouped by bucket (`engineering`, `productivity`, `misc`). Toggle a whole category or pick individual skills in one screen. Use `--all` to skip the menu, or `--bucket` / `--skill` for non-interactive partial installs.
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
From a git checkout this symlinks into `~/.agents/skills` so edits in the repo are live. From the published package it copies files instead.
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
```bash
|
|
33
|
+
npx --package @ecology91/skills ecology91-skills --all
|
|
34
|
+
npx --package @ecology91/skills ecology91-skills --bucket engineering --skill tdd
|
|
35
|
+
```
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
1. Force a copy install from a checkout with `--copy`.
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
- Ask you which issue tracker you want to use (GitHub, GitLab, Beads, `.scratch`, or another workflow)
|
|
36
|
-
- Ask you what labels you apply to issues when you triage them (`/triage` uses labels)
|
|
37
|
-
- Ask you where you want to save any docs we create
|
|
39
|
+
OpenCode auto-loads `~/.agents/skills` alongside its own config tree, so one global install works across harnesses.
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
1. Quit and restart your agent so it reloads the skill list.
|
|
42
|
+
2. Run `/setup-agent-skills` in your coding agent. It will:
|
|
43
|
+
- Ask you which issue tracker you want to use (GitHub, GitLab, Beads, `.scratch`, or another workflow)
|
|
44
|
+
- Ask you what labels you apply to issues when you triage them (`/triage` uses labels)
|
|
45
|
+
- Ask you where you want to save any docs we create
|
|
46
|
+
3. Bam - you're ready to go.
|
|
40
47
|
|
|
41
48
|
This repo also includes `opencode.json`, so opencode loads the promoted skill buckets automatically when you open this repo directly.
|
|
42
49
|
|
|
@@ -56,10 +63,10 @@ This is just the same in the AI age. There is a communication gap between you an
|
|
|
56
63
|
|
|
57
64
|
**The Fix** is to use:
|
|
58
65
|
|
|
59
|
-
- [
|
|
60
|
-
- [
|
|
66
|
+
- `[/grill-me](./skills/productivity/grill-me/SKILL.md)` - for non-code uses
|
|
67
|
+
- `[/grill-with-docs](./skills/engineering/grill-with-docs/SKILL.md)` - same as `[/grill-me](./skills/productivity/grill-me/SKILL.md)`, but adds more goodies (see below)
|
|
61
68
|
|
|
62
|
-
These are my most popular skills. They help you align with the agent before you get started, and think deeply about the change you're making. Use them
|
|
69
|
+
These are my most popular skills. They help you align with the agent before you get started, and think deeply about the change you're making. Use them *every* time you want to make a change.
|
|
63
70
|
|
|
64
71
|
### #2: The Agent Is Way Too Verbose
|
|
65
72
|
|
|
@@ -73,10 +80,7 @@ I felt the same tension with my agents. Agents are usually dropped into a projec
|
|
|
73
80
|
|
|
74
81
|
**The Fix** for this is a shared language. It's a document that helps agents decode the jargon used in the project.
|
|
75
82
|
|
|
76
|
-
<details>
|
|
77
|
-
<summary>
|
|
78
83
|
Example
|
|
79
|
-
</summary>
|
|
80
84
|
|
|
81
85
|
Here's a before-and-after example. Which one is easier to read?
|
|
82
86
|
|
|
@@ -85,9 +89,9 @@ Here's a before-and-after example. Which one is easier to read?
|
|
|
85
89
|
|
|
86
90
|
This concision pays off session after session.
|
|
87
91
|
|
|
88
|
-
</details>
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
|
|
94
|
+
This is built into `[/grill-with-docs](./skills/engineering/grill-with-docs/SKILL.md)`. It's a grilling session, but that helps you build a shared language with the AI, and document hard-to-explain decisions in ADR's.
|
|
91
95
|
|
|
92
96
|
It's hard to explain how powerful this is. It might be the single coolest technique in this repo. Try it, and see.
|
|
93
97
|
|
|
@@ -104,7 +108,7 @@ It's hard to explain how powerful this is. It might be the single coolest techni
|
|
|
104
108
|
>
|
|
105
109
|
> David Thomas & Andrew Hunt, [The Pragmatic Programmer](https://www.amazon.co.uk/Pragmatic-Programmer-Anniversary-Journey-Mastery/dp/B0833F1T3V)
|
|
106
110
|
|
|
107
|
-
**The Problem**: Let's say that you and the agent are aligned on what to build. What happens when the agent
|
|
111
|
+
**The Problem**: Let's say that you and the agent are aligned on what to build. What happens when the agent *still* produces crap?
|
|
108
112
|
|
|
109
113
|
It's time to look at your feedback loops. Without feedback on how the code it produces actually runs, the agent will be flying blind.
|
|
110
114
|
|
|
@@ -112,13 +116,13 @@ It's time to look at your feedback loops. Without feedback on how the code it pr
|
|
|
112
116
|
|
|
113
117
|
For automated tests, a red-green-refactor loop is critical. This is where the agent writes a failing test first, then fixes the test. This helps give the agent a consistent level of feedback that results in far better code.
|
|
114
118
|
|
|
115
|
-
I've built a
|
|
119
|
+
I've built a `**[/tdd](./skills/engineering/tdd/SKILL.md)` skill** you can slot into any project. It encourages red-green-refactor and gives the agent plenty of guidance on what makes good and bad tests.
|
|
116
120
|
|
|
117
|
-
For debugging, I've also built a
|
|
121
|
+
For debugging, I've also built a `**[/diagnose](./skills/engineering/diagnose/SKILL.md)`** skill that wraps best debugging practices into a simple loop.
|
|
118
122
|
|
|
119
123
|
### #4: We Built A Ball Of Mud
|
|
120
124
|
|
|
121
|
-
> "Invest in the design of the system
|
|
125
|
+
> "Invest in the design of the system *every day*."
|
|
122
126
|
>
|
|
123
127
|
> Kent Beck, [Extreme Programming Explained](https://www.amazon.co.uk/Extreme-Programming-Explained-Embrace-Change/dp/0321278658)
|
|
124
128
|
|
|
@@ -132,10 +136,10 @@ For debugging, I've also built a **[`/diagnose`](./skills/engineering/diagnose/S
|
|
|
132
136
|
|
|
133
137
|
This is built in to every layer of these skills:
|
|
134
138
|
|
|
135
|
-
- [
|
|
136
|
-
- [
|
|
139
|
+
- `[/to-prd](./skills/engineering/to-prd/SKILL.md)` quizzes you about which modules you're touching before creating a PRD
|
|
140
|
+
- `[/zoom-out](./skills/engineering/zoom-out/SKILL.md)` tells the agent to explain code in the context of the whole system
|
|
137
141
|
|
|
138
|
-
And crucially, [
|
|
142
|
+
And crucially, `[/improve-codebase-architecture](./skills/engineering/improve-codebase-architecture/SKILL.md)` helps you rescue a codebase that has become a ball of mud. I recommend running it on your codebase once every few days.
|
|
139
143
|
|
|
140
144
|
### Summary
|
|
141
145
|
|
|
@@ -177,3 +181,4 @@ Tools I keep around but rarely use.
|
|
|
177
181
|
- **[migrate-to-shoehorn](./skills/misc/migrate-to-shoehorn/SKILL.md)** — Migrate test files from `as` type assertions to @total-typescript/shoehorn.
|
|
178
182
|
- **[scaffold-exercises](./skills/misc/scaffold-exercises/SKILL.md)** — Create exercise directory structures with sections, problems, solutions, and explainers.
|
|
179
183
|
- **[setup-pre-commit](./skills/misc/setup-pre-commit/SKILL.md)** — Set up Husky pre-commit hooks with lint-staged, Prettier, type checking, and tests.
|
|
184
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const buckets = [
|
|
5
|
+
{ id: "engineering", label: "daily code work" },
|
|
6
|
+
{ id: "productivity", label: "daily non-code workflow tools" },
|
|
7
|
+
{ id: "misc", label: "kept around but rarely used" },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const DESCRIPTION_MAX = 80;
|
|
11
|
+
|
|
12
|
+
function readDescription(skillMdPath) {
|
|
13
|
+
const content = fs.readFileSync(skillMdPath, "utf8");
|
|
14
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
15
|
+
if (!match) return "";
|
|
16
|
+
|
|
17
|
+
for (const line of match[1].split("\n")) {
|
|
18
|
+
const desc = line.match(/^description:\s*(.+)$/);
|
|
19
|
+
if (!desc) continue;
|
|
20
|
+
|
|
21
|
+
let value = desc[1].trim();
|
|
22
|
+
if (
|
|
23
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
24
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
25
|
+
) {
|
|
26
|
+
value = value.slice(1, -1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const firstLine = value.split("\n")[0].trim();
|
|
30
|
+
if (firstLine.length <= DESCRIPTION_MAX) return firstLine;
|
|
31
|
+
return `${firstLine.slice(0, DESCRIPTION_MAX - 1)}…`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} root
|
|
39
|
+
* @param {string} destRoot
|
|
40
|
+
*/
|
|
41
|
+
export function collectSkillsByBucket(root, destRoot) {
|
|
42
|
+
/** @type {{ id: string, label: string, skills: { name: string, src: string, dest: string, description: string, bucket: string }[] }[]} */
|
|
43
|
+
const catalog = [];
|
|
44
|
+
|
|
45
|
+
for (const bucket of buckets) {
|
|
46
|
+
const bucketDir = path.join(root, "skills", bucket.id);
|
|
47
|
+
if (!fs.existsSync(bucketDir)) continue;
|
|
48
|
+
|
|
49
|
+
/** @type {{ name: string, src: string, dest: string, description: string, bucket: string }[]} */
|
|
50
|
+
const skills = [];
|
|
51
|
+
|
|
52
|
+
for (const entry of fs.readdirSync(bucketDir, { withFileTypes: true })) {
|
|
53
|
+
if (!entry.isDirectory()) continue;
|
|
54
|
+
|
|
55
|
+
const src = path.join(bucketDir, entry.name);
|
|
56
|
+
const skillMd = path.join(src, "SKILL.md");
|
|
57
|
+
if (!fs.existsSync(skillMd)) continue;
|
|
58
|
+
|
|
59
|
+
skills.push({
|
|
60
|
+
name: entry.name,
|
|
61
|
+
src,
|
|
62
|
+
dest: path.join(destRoot, entry.name),
|
|
63
|
+
description: readDescription(skillMd),
|
|
64
|
+
bucket: bucket.id,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
69
|
+
if (skills.length > 0) {
|
|
70
|
+
catalog.push({ id: bucket.id, label: bucket.label, skills });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return catalog;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {{ id: string, label: string, skills: { name: string }[] }[]} catalog */
|
|
78
|
+
export function flattenCatalog(catalog) {
|
|
79
|
+
return catalog.flatMap((bucket) => bucket.skills);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {{ name: string, bucket: string }[]} skills */
|
|
83
|
+
export function countByBucket(skills) {
|
|
84
|
+
/** @type {Record<string, number>} */
|
|
85
|
+
const counts = {};
|
|
86
|
+
for (const skill of skills) {
|
|
87
|
+
counts[skill.bucket] = (counts[skill.bucket] ?? 0) + 1;
|
|
88
|
+
}
|
|
89
|
+
return counts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {Record<string, number>} counts */
|
|
93
|
+
export function formatBucketCounts(counts) {
|
|
94
|
+
return Object.entries(counts)
|
|
95
|
+
.map(([bucket, count]) => `${bucket}: ${count}`)
|
|
96
|
+
.join(", ");
|
|
97
|
+
}
|
package/bin/install.mjs
CHANGED
|
@@ -1,93 +1,179 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
3
2
|
import fs from "node:fs";
|
|
4
3
|
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
collectSkillsByBucket,
|
|
8
|
+
countByBucket,
|
|
9
|
+
flattenCatalog,
|
|
10
|
+
formatBucketCounts,
|
|
11
|
+
} from "./collect-skills.mjs";
|
|
12
|
+
import { promptSkillSelection } from "./prompt-skills.mjs";
|
|
13
|
+
import { parseArgs, resolveSelection, skillsFromNames } from "./resolve-selection.mjs";
|
|
7
14
|
|
|
8
15
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
-
const buckets = ["engineering", "productivity", "misc"];
|
|
10
|
-
const args = new Set(process.argv.slice(2));
|
|
11
16
|
const destRoot = path.join(os.homedir(), ".agents", "skills");
|
|
12
|
-
const isGitCheckout = fs.existsSync(path.join(root, ".git"));
|
|
13
|
-
const link =
|
|
14
|
-
args.has("--link") || (isGitCheckout && !args.has("--copy"));
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
console.log(`Usage: npx @ecology91/skills [
|
|
18
|
+
function printHelp() {
|
|
19
|
+
console.log(`Usage: npx @ecology91/skills [options]
|
|
18
20
|
|
|
19
21
|
Install promoted skills into ~/.agents/skills.
|
|
20
22
|
|
|
21
23
|
From a git checkout, symlinks by default so local edits are picked up live.
|
|
22
24
|
From the published npm package, copies by default.
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
Interactive mode (TTY): pick buckets and individual skills in one menu.
|
|
27
|
+
Non-interactive (CI, pipes): installs all promoted skills.
|
|
28
|
+
|
|
29
|
+
--dry-run Print actions without installing
|
|
30
|
+
--copy Force copies instead of symlinks
|
|
31
|
+
--link Force symlinks
|
|
32
|
+
--all Skip menu; install all promoted skills
|
|
33
|
+
--bucket <ids> Comma-separated buckets (engineering, productivity, misc)
|
|
34
|
+
--skill <names> Comma-separated skill names`);
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = parseArgs(process.argv.slice(2));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`error: ${error.message}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
const skills = [];
|
|
45
|
+
const { flags, values } = parsed;
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
if (flags.has("--help") || flags.has("-h")) {
|
|
48
|
+
printHelp();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
37
51
|
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
const dryRun = flags.has("--dry-run");
|
|
53
|
+
const isGitCheckout = fs.existsSync(path.join(root, ".git"));
|
|
54
|
+
const link = flags.has("--link") || (isGitCheckout && !flags.has("--copy"));
|
|
55
|
+
const isInteractive =
|
|
56
|
+
Boolean(process.stdin.isTTY && process.stdout.isTTY) && !flags.has("--all");
|
|
40
57
|
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
const catalog = collectSkillsByBucket(root, destRoot);
|
|
59
|
+
const allSkills = flattenCatalog(catalog);
|
|
43
60
|
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
function assertSafeDest() {
|
|
62
|
+
if (!fs.lstatSync(destRoot, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resolved = fs.realpathSync(destRoot);
|
|
67
|
+
const repoReal = fs.realpathSync(root);
|
|
68
|
+
if (resolved === repoReal || resolved.startsWith(`${repoReal}${path.sep}`)) {
|
|
69
|
+
console.error(
|
|
70
|
+
`error: ${destRoot} is a symlink into this repo (${resolved}).`,
|
|
71
|
+
);
|
|
72
|
+
console.error(
|
|
73
|
+
`Remove it (rm "${destRoot}") and re-run; the installer will recreate it as a real dir.`,
|
|
74
|
+
);
|
|
75
|
+
process.exit(1);
|
|
46
76
|
}
|
|
77
|
+
}
|
|
47
78
|
|
|
48
|
-
|
|
79
|
+
/** @param {{ bucket: string, name: string }[]} skills */
|
|
80
|
+
function groupSkillsByBucket(skills) {
|
|
81
|
+
/** @type {Map<string, typeof skills>} */
|
|
82
|
+
const grouped = new Map();
|
|
83
|
+
for (const skill of skills) {
|
|
84
|
+
const list = grouped.get(skill.bucket) ?? [];
|
|
85
|
+
list.push(skill);
|
|
86
|
+
grouped.set(skill.bucket, list);
|
|
87
|
+
}
|
|
88
|
+
return grouped;
|
|
49
89
|
}
|
|
50
90
|
|
|
91
|
+
/** @param {{ bucket: string, name: string, src: string, dest: string }[]} skills */
|
|
51
92
|
function installCopy(skills) {
|
|
52
|
-
if (!dryRun)
|
|
93
|
+
if (!dryRun) {
|
|
94
|
+
assertSafeDest();
|
|
95
|
+
fs.mkdirSync(destRoot, { recursive: true });
|
|
96
|
+
}
|
|
53
97
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
98
|
+
const grouped = groupSkillsByBucket(skills);
|
|
99
|
+
for (const [bucket, bucketSkills] of grouped) {
|
|
100
|
+
console.log(`${bucket} (${bucketSkills.length})`);
|
|
101
|
+
for (const skill of bucketSkills) {
|
|
102
|
+
if (dryRun) {
|
|
103
|
+
console.log(` would install ${skill.name} -> ${skill.dest}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fs.rmSync(skill.dest, { recursive: true, force: true });
|
|
108
|
+
fs.cpSync(skill.src, skill.dest, { recursive: true, force: true });
|
|
109
|
+
console.log(` installed ${skill.name} -> ${skill.dest}`);
|
|
58
110
|
}
|
|
59
|
-
|
|
60
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
61
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
62
|
-
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
63
|
-
console.log(`installed ${path.basename(src)} -> ${dest}`);
|
|
64
111
|
}
|
|
65
112
|
}
|
|
66
113
|
|
|
114
|
+
/** @param {{ bucket: string, name: string, src: string, dest: string }[]} skills */
|
|
67
115
|
function installLink(skills) {
|
|
68
116
|
if (dryRun) {
|
|
69
|
-
|
|
70
|
-
|
|
117
|
+
const grouped = groupSkillsByBucket(skills);
|
|
118
|
+
for (const [bucket, bucketSkills] of grouped) {
|
|
119
|
+
console.log(`${bucket} (${bucketSkills.length})`);
|
|
120
|
+
for (const skill of bucketSkills) {
|
|
121
|
+
console.log(` would link ${skill.name} -> ${skill.dest} (${skill.src})`);
|
|
122
|
+
}
|
|
71
123
|
}
|
|
72
124
|
return;
|
|
73
125
|
}
|
|
74
126
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
127
|
+
assertSafeDest();
|
|
128
|
+
fs.mkdirSync(destRoot, { recursive: true });
|
|
129
|
+
|
|
130
|
+
const grouped = groupSkillsByBucket(skills);
|
|
131
|
+
for (const [bucket, bucketSkills] of grouped) {
|
|
132
|
+
console.log(`${bucket} (${bucketSkills.length})`);
|
|
133
|
+
for (const skill of bucketSkills) {
|
|
134
|
+
const stat = fs.lstatSync(skill.dest, { throwIfNoEntry: false });
|
|
135
|
+
if (stat) {
|
|
136
|
+
fs.rmSync(skill.dest, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fs.symlinkSync(skill.src, skill.dest);
|
|
140
|
+
console.log(` linked ${skill.name} -> ${skill.src}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
78
143
|
}
|
|
79
144
|
|
|
80
|
-
|
|
145
|
+
let selectedSkills;
|
|
146
|
+
try {
|
|
147
|
+
selectedSkills = resolveSelection(catalog, parsed, isInteractive);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error(`error: ${error.message}`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (selectedSkills === null) {
|
|
154
|
+
const selectedNames = await promptSkillSelection(catalog);
|
|
155
|
+
if (selectedNames === null) {
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
selectedSkills = skillsFromNames(selectedNames, allSkills);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (selectedSkills.length === 0) {
|
|
162
|
+
console.log("No skills selected.");
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
81
165
|
|
|
82
166
|
if (link) {
|
|
83
|
-
installLink(
|
|
167
|
+
installLink(selectedSkills);
|
|
168
|
+
const verb = dryRun ? "Checked" : "Linked";
|
|
84
169
|
console.log(
|
|
85
|
-
`${
|
|
170
|
+
`${verb} ${selectedSkills.length} skills to ~/.agents/skills (${formatBucketCounts(countByBucket(selectedSkills))}).`,
|
|
86
171
|
);
|
|
87
172
|
} else {
|
|
88
|
-
installCopy(
|
|
173
|
+
installCopy(selectedSkills);
|
|
174
|
+
const verb = dryRun ? "Checked" : "Installed";
|
|
89
175
|
console.log(
|
|
90
|
-
`${
|
|
176
|
+
`${verb} ${selectedSkills.length} skills to ~/.agents/skills (${formatBucketCounts(countByBucket(selectedSkills))}).`,
|
|
91
177
|
);
|
|
92
178
|
}
|
|
93
179
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cancel, groupMultiselect, intro, isCancel, outro } from "@clack/prompts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{ id: string, label: string, skills: { name: string, description: string }[] }[]} catalog
|
|
5
|
+
* @returns {Promise<string[] | null>}
|
|
6
|
+
*/
|
|
7
|
+
export async function promptSkillSelection(catalog) {
|
|
8
|
+
/** @type {Record<string, { value: string, label: string }[]>} */
|
|
9
|
+
const options = {};
|
|
10
|
+
const allNames = [];
|
|
11
|
+
|
|
12
|
+
for (const bucket of catalog) {
|
|
13
|
+
options[`${bucket.id} — ${bucket.label}`] = bucket.skills.map((skill) => ({
|
|
14
|
+
value: skill.name,
|
|
15
|
+
label: skill.description
|
|
16
|
+
? `${skill.name} — ${skill.description}`
|
|
17
|
+
: skill.name,
|
|
18
|
+
}));
|
|
19
|
+
allNames.push(...bucket.skills.map((skill) => skill.name));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
intro("ecology91-skills");
|
|
23
|
+
|
|
24
|
+
const selected = await groupMultiselect({
|
|
25
|
+
message: "Select skills to install",
|
|
26
|
+
selectableGroups: true,
|
|
27
|
+
options,
|
|
28
|
+
initialValues: allNames,
|
|
29
|
+
required: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (isCancel(selected)) {
|
|
33
|
+
cancel("Installation cancelled.");
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
outro(`Selected ${selected.length} skill${selected.length === 1 ? "" : "s"}.`);
|
|
38
|
+
return selected;
|
|
39
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { buckets } from "./collect-skills.mjs";
|
|
2
|
+
|
|
3
|
+
const bucketIds = new Set(buckets.map((bucket) => bucket.id));
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string[]} argv
|
|
7
|
+
*/
|
|
8
|
+
export function parseArgs(argv) {
|
|
9
|
+
const flags = new Set();
|
|
10
|
+
/** @type {Record<string, string>} */
|
|
11
|
+
const values = {};
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const arg = argv[i];
|
|
15
|
+
|
|
16
|
+
if (arg === "--bucket" || arg === "--skill") {
|
|
17
|
+
const value = argv[i + 1];
|
|
18
|
+
if (!value || value.startsWith("-")) {
|
|
19
|
+
throw new Error(`Missing value for ${arg}`);
|
|
20
|
+
}
|
|
21
|
+
values[arg.slice(2)] = value;
|
|
22
|
+
i++;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (arg.startsWith("-")) {
|
|
27
|
+
flags.add(arg);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { flags, values };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string | undefined} raw
|
|
36
|
+
*/
|
|
37
|
+
function splitList(raw) {
|
|
38
|
+
if (!raw) return [];
|
|
39
|
+
return raw
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((part) => part.trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {{ id: string, skills: { name: string, bucket: string }[] }[]} catalog
|
|
47
|
+
* @param {{ flags: Set<string>, values: Record<string, string> }} parsed
|
|
48
|
+
* @param {boolean} isInteractive
|
|
49
|
+
*/
|
|
50
|
+
export function resolveSelection(catalog, parsed, isInteractive) {
|
|
51
|
+
const allSkills = catalog.flatMap((bucket) => bucket.skills);
|
|
52
|
+
const allNames = new Set(allSkills.map((skill) => skill.name));
|
|
53
|
+
const { flags, values } = parsed;
|
|
54
|
+
|
|
55
|
+
const hasExplicitSelection =
|
|
56
|
+
flags.has("--all") || values.bucket || values.skill || !isInteractive;
|
|
57
|
+
|
|
58
|
+
if (hasExplicitSelection) {
|
|
59
|
+
return filterByFlags(allSkills, values, allNames);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{ name: string, bucket: string }[]} allSkills
|
|
67
|
+
* @param {Record<string, string>} values
|
|
68
|
+
* @param {Set<string>} allNames
|
|
69
|
+
*/
|
|
70
|
+
function filterByFlags(allSkills, values, allNames) {
|
|
71
|
+
const bucketFilter = new Set(splitList(values.bucket));
|
|
72
|
+
const skillFilter = new Set(splitList(values.skill));
|
|
73
|
+
|
|
74
|
+
for (const bucket of bucketFilter) {
|
|
75
|
+
if (!bucketIds.has(bucket)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Unknown bucket "${bucket}". Expected one of: ${[...bucketIds].join(", ")}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const skill of skillFilter) {
|
|
83
|
+
if (!allNames.has(skill)) {
|
|
84
|
+
throw new Error(`Unknown skill "${skill}".`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return allSkills.filter((skill) => {
|
|
89
|
+
if (bucketFilter.size > 0 && !bucketFilter.has(skill.bucket)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (skillFilter.size > 0 && !skillFilter.has(skill.name)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string[] | null} selectedNames
|
|
101
|
+
* @param {{ name: string }[]} allSkills
|
|
102
|
+
*/
|
|
103
|
+
export function skillsFromNames(selectedNames, allSkills) {
|
|
104
|
+
if (!selectedNames || selectedNames.length === 0) return [];
|
|
105
|
+
const byName = new Map(allSkills.map((skill) => [skill.name, skill]));
|
|
106
|
+
return selectedNames.map((name) => byName.get(name)).filter(Boolean);
|
|
107
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { parseArgs, resolveSelection, skillsFromNames } from "./resolve-selection.mjs";
|
|
4
|
+
|
|
5
|
+
const catalog = [
|
|
6
|
+
{
|
|
7
|
+
id: "engineering",
|
|
8
|
+
label: "daily code work",
|
|
9
|
+
skills: [
|
|
10
|
+
{ name: "tdd", bucket: "engineering" },
|
|
11
|
+
{ name: "diagnose", bucket: "engineering" },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "productivity",
|
|
16
|
+
label: "daily non-code workflow tools",
|
|
17
|
+
skills: [{ name: "caveman", bucket: "productivity" }],
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
test("parseArgs splits bucket and skill values", () => {
|
|
22
|
+
const parsed = parseArgs(["--bucket", "engineering", "--skill", "tdd,diagnose"]);
|
|
23
|
+
assert.equal(parsed.values.bucket, "engineering");
|
|
24
|
+
assert.equal(parsed.values.skill, "tdd,diagnose");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("resolveSelection returns all skills when non-interactive", () => {
|
|
28
|
+
const parsed = parseArgs([]);
|
|
29
|
+
const selected = resolveSelection(catalog, parsed, false);
|
|
30
|
+
assert.equal(selected?.length, 3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("resolveSelection filters by bucket", () => {
|
|
34
|
+
const parsed = parseArgs(["--bucket", "engineering"]);
|
|
35
|
+
const selected = resolveSelection(catalog, parsed, true);
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
selected?.map((skill) => skill.name).sort(),
|
|
38
|
+
["diagnose", "tdd"],
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("resolveSelection intersects bucket and skill filters", () => {
|
|
43
|
+
const parsed = parseArgs(["--bucket", "engineering", "--skill", "tdd"]);
|
|
44
|
+
const selected = resolveSelection(catalog, parsed, true);
|
|
45
|
+
assert.deepEqual(selected?.map((skill) => skill.name), ["tdd"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("resolveSelection rejects unknown bucket", () => {
|
|
49
|
+
const parsed = parseArgs(["--bucket", "personal"]);
|
|
50
|
+
assert.throws(
|
|
51
|
+
() => resolveSelection(catalog, parsed, true),
|
|
52
|
+
/Unknown bucket "personal"/,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("skillsFromNames preserves prompt order", () => {
|
|
57
|
+
const allSkills = catalog.flatMap((bucket) => bucket.skills);
|
|
58
|
+
const selected = skillsFromNames(["caveman", "tdd"], allSkills);
|
|
59
|
+
assert.deepEqual(selected.map((skill) => skill.name), ["caveman", "tdd"]);
|
|
60
|
+
});
|
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecology91/skills",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "opencode agent skills for real engineering workflows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@clack/prompts": "^0.11.0"
|
|
9
|
+
},
|
|
7
10
|
"bin": {
|
|
8
11
|
"ecology91-skills": "bin/install.mjs"
|
|
9
12
|
},
|
|
10
13
|
"files": [
|
|
11
14
|
"bin/",
|
|
12
15
|
"scripts/",
|
|
16
|
+
".claude-plugin/",
|
|
13
17
|
"skills/engineering/",
|
|
14
18
|
"skills/productivity/",
|
|
15
19
|
"skills/misc/",
|
|
@@ -37,10 +41,12 @@
|
|
|
37
41
|
"access": "public"
|
|
38
42
|
},
|
|
39
43
|
"scripts": {
|
|
40
|
-
"typecheck": "node --check bin/install.mjs && node --check scripts/verify-package-files.mjs",
|
|
41
|
-
"test": "node bin/install.mjs --dry-run --copy",
|
|
44
|
+
"typecheck": "node --check bin/install.mjs && node --check bin/collect-skills.mjs && node --check bin/prompt-skills.mjs && node --check bin/resolve-selection.mjs && node --check bin/resolve-selection.test.mjs && node --check scripts/generate-marketplace.mjs && node --check scripts/verify-marketplace.mjs && node --check scripts/verify-package-files.mjs",
|
|
45
|
+
"test": "node --test bin/resolve-selection.test.mjs && node bin/install.mjs --dry-run --copy --all",
|
|
46
|
+
"test:unit": "node --test bin/resolve-selection.test.mjs",
|
|
42
47
|
"link-skills": "./scripts/link-skills.sh",
|
|
43
|
-
"build": "node scripts/verify-package-files.mjs",
|
|
48
|
+
"build": "node scripts/verify-package-files.mjs && node scripts/verify-marketplace.mjs",
|
|
49
|
+
"generate:marketplace": "node scripts/generate-marketplace.mjs",
|
|
44
50
|
"validate:fast": "npm run typecheck && npm run test",
|
|
45
51
|
"validate": "npm run validate:fast && npm run build"
|
|
46
52
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { collectSkillsByBucket } from "../bin/collect-skills.mjs";
|
|
6
|
+
|
|
7
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const outPath = path.join(root, ".claude-plugin", "marketplace.json");
|
|
9
|
+
|
|
10
|
+
const catalog = collectSkillsByBucket(root, "/tmp/unused");
|
|
11
|
+
|
|
12
|
+
const manifest = {
|
|
13
|
+
metadata: { pluginRoot: "./skills" },
|
|
14
|
+
plugins: catalog.map((bucket) => ({
|
|
15
|
+
name: bucket.id,
|
|
16
|
+
source: `./${bucket.id}`,
|
|
17
|
+
skills: bucket.skills.map((skill) => `./${skill.name}`),
|
|
18
|
+
})),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
22
|
+
fs.writeFileSync(outPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
23
|
+
|
|
24
|
+
console.log(`Wrote ${path.relative(root, outPath)} (${catalog.flatMap((b) => b.skills).length} skills).`);
|
package/scripts/link-skills.sh
CHANGED
|
@@ -1,40 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
|
-
# Links promoted skills in the repository to ~/.agents/skills so they are
|
|
5
|
-
# available globally across agent harnesses (opencode, Cursor, etc.).
|
|
6
|
-
|
|
7
4
|
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
# If the destination is a symlink that resolves into this repo, we'd end up
|
|
11
|
-
# writing the per-skill symlinks back into the repo's own skills/ tree. Detect
|
|
12
|
-
# and bail out instead of polluting the working copy.
|
|
13
|
-
if [ -L "$DEST" ]; then
|
|
14
|
-
resolved="$(readlink -f "$DEST")"
|
|
15
|
-
case "$resolved" in
|
|
16
|
-
"$REPO"|"$REPO"/*)
|
|
17
|
-
echo "error: $DEST is a symlink into this repo ($resolved)." >&2
|
|
18
|
-
echo "Remove it (rm \"$DEST\") and re-run; the script will recreate it as a real dir." >&2
|
|
19
|
-
exit 1
|
|
20
|
-
;;
|
|
21
|
-
esac
|
|
22
|
-
fi
|
|
23
|
-
|
|
24
|
-
mkdir -p "$DEST"
|
|
25
|
-
|
|
26
|
-
for bucket in engineering productivity misc; do
|
|
27
|
-
find "$REPO/skills/$bucket" -name SKILL.md -not -path '*/node_modules/*' -print0
|
|
28
|
-
done |
|
|
29
|
-
while IFS= read -r -d '' skill_md; do
|
|
30
|
-
src="$(dirname "$skill_md")"
|
|
31
|
-
name="$(basename "$src")"
|
|
32
|
-
target="$DEST/$name"
|
|
33
|
-
|
|
34
|
-
if [ -e "$target" ] && [ ! -L "$target" ]; then
|
|
35
|
-
rm -rf "$target"
|
|
36
|
-
fi
|
|
37
|
-
|
|
38
|
-
ln -sfn "$src" "$target"
|
|
39
|
-
echo "linked $name -> $src"
|
|
40
|
-
done
|
|
5
|
+
exec node "$REPO/bin/install.mjs" --link "$@"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { collectSkillsByBucket } from "../bin/collect-skills.mjs";
|
|
6
|
+
|
|
7
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const marketplacePath = path.join(root, ".claude-plugin", "marketplace.json");
|
|
9
|
+
|
|
10
|
+
const catalog = collectSkillsByBucket(root, "/tmp/unused");
|
|
11
|
+
const expected = {
|
|
12
|
+
metadata: { pluginRoot: "./skills" },
|
|
13
|
+
plugins: catalog.map((bucket) => ({
|
|
14
|
+
name: bucket.id,
|
|
15
|
+
source: `./${bucket.id}`,
|
|
16
|
+
skills: bucket.skills.map((skill) => `./${skill.name}`),
|
|
17
|
+
})),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (!fs.existsSync(marketplacePath)) {
|
|
21
|
+
console.error("Missing .claude-plugin/marketplace.json — run: npm run generate:marketplace");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const actual = JSON.parse(fs.readFileSync(marketplacePath, "utf8"));
|
|
26
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
27
|
+
console.error(
|
|
28
|
+
".claude-plugin/marketplace.json is out of date — run: npm run generate:marketplace",
|
|
29
|
+
);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log("Verified .claude-plugin/marketplace.json matches promoted skills.");
|