@ecology91/skills 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Skills For Real Engineers
2
2
 
3
- [![skills.sh](https://skills.sh/b/ecology9191/skills)](https://skills.sh/ecology9191/skills)
4
- [![npm](https://img.shields.io/npm/v/@ecology91/skills)](https://www.npmjs.com/package/@ecology91/skills)
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,31 @@ 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
- 2. Or install from a git checkout or the published npm package:
20
+ 1. Or install from a git checkout or the published npm package:
21
21
 
22
22
  ```bash
23
23
  npx --package @ecology91/skills ecology91-skills
24
24
  ```
25
25
 
26
- From a git checkout this symlinks into `~/.agents/skills` so edits in the repo are live. From the published package it copies files instead.
26
+ 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
27
 
28
- 3. Force a copy install from a checkout with `--copy`.
28
+ 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
29
 
30
- OpenCode auto-loads `~/.agents/skills` alongside its own config tree, so one global install works across harnesses.
30
+ ```bash
31
+ npx --package @ecology91/skills ecology91-skills --all
32
+ npx --package @ecology91/skills ecology91-skills --bucket engineering --skill tdd
33
+ ```
31
34
 
32
- 4. Quit and restart your agent so it reloads the skill list.
35
+ 1. Force a copy install from a checkout with `--copy`.
33
36
 
34
- 5. Run `/setup-agent-skills` in your coding agent. It will:
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
37
+ OpenCode auto-loads `~/.agents/skills` alongside its own config tree, so one global install works across harnesses.
38
38
 
39
- 6. Bam - you're ready to go.
39
+ 1. Quit and restart your agent so it reloads the skill list.
40
+ 2. Run `/setup-agent-skills` in your coding agent. It will:
41
+ - Ask you which issue tracker you want to use (GitHub, GitLab, Beads, `.scratch`, or another workflow)
42
+ - Ask you what labels you apply to issues when you triage them (`/triage` uses labels)
43
+ - Ask you where you want to save any docs we create
44
+ 3. Bam - you're ready to go.
40
45
 
41
46
  This repo also includes `opencode.json`, so opencode loads the promoted skill buckets automatically when you open this repo directly.
42
47
 
@@ -56,10 +61,10 @@ This is just the same in the AI age. There is a communication gap between you an
56
61
 
57
62
  **The Fix** is to use:
58
63
 
59
- - [`/grill-me`](./skills/productivity/grill-me/SKILL.md) - for non-code uses
60
- - [`/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)
64
+ - `[/grill-me](./skills/productivity/grill-me/SKILL.md)` - for non-code uses
65
+ - `[/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
66
 
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 _every_ time you want to make a change.
67
+ 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
68
 
64
69
  ### #2: The Agent Is Way Too Verbose
65
70
 
@@ -73,10 +78,7 @@ I felt the same tension with my agents. Agents are usually dropped into a projec
73
78
 
74
79
  **The Fix** for this is a shared language. It's a document that helps agents decode the jargon used in the project.
75
80
 
76
- <details>
77
- <summary>
78
81
  Example
79
- </summary>
80
82
 
81
83
  Here's a before-and-after example. Which one is easier to read?
82
84
 
@@ -85,9 +87,9 @@ Here's a before-and-after example. Which one is easier to read?
85
87
 
86
88
  This concision pays off session after session.
87
89
 
88
- </details>
89
90
 
90
- 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
+
92
+ 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
93
 
92
94
  It's hard to explain how powerful this is. It might be the single coolest technique in this repo. Try it, and see.
93
95
 
@@ -104,7 +106,7 @@ It's hard to explain how powerful this is. It might be the single coolest techni
104
106
  >
105
107
  > David Thomas & Andrew Hunt, [The Pragmatic Programmer](https://www.amazon.co.uk/Pragmatic-Programmer-Anniversary-Journey-Mastery/dp/B0833F1T3V)
106
108
 
107
- **The Problem**: Let's say that you and the agent are aligned on what to build. What happens when the agent _still_ produces crap?
109
+ **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
110
 
109
111
  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
112
 
@@ -112,13 +114,13 @@ It's time to look at your feedback loops. Without feedback on how the code it pr
112
114
 
113
115
  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
116
 
115
- 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.
117
+ 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
118
 
117
- For debugging, I've also built a **[`/diagnose`](./skills/engineering/diagnose/SKILL.md)** skill that wraps best debugging practices into a simple loop.
119
+ For debugging, I've also built a `**[/diagnose](./skills/engineering/diagnose/SKILL.md)`** skill that wraps best debugging practices into a simple loop.
118
120
 
119
121
  ### #4: We Built A Ball Of Mud
120
122
 
121
- > "Invest in the design of the system _every day_."
123
+ > "Invest in the design of the system *every day*."
122
124
  >
123
125
  > Kent Beck, [Extreme Programming Explained](https://www.amazon.co.uk/Extreme-Programming-Explained-Embrace-Change/dp/0321278658)
124
126
 
@@ -132,10 +134,10 @@ For debugging, I've also built a **[`/diagnose`](./skills/engineering/diagnose/S
132
134
 
133
135
  This is built in to every layer of these skills:
134
136
 
135
- - [`/to-prd`](./skills/engineering/to-prd/SKILL.md) quizzes you about which modules you're touching before creating a PRD
136
- - [`/zoom-out`](./skills/engineering/zoom-out/SKILL.md) tells the agent to explain code in the context of the whole system
137
+ - `[/to-prd](./skills/engineering/to-prd/SKILL.md)` quizzes you about which modules you're touching before creating a PRD
138
+ - `[/zoom-out](./skills/engineering/zoom-out/SKILL.md)` tells the agent to explain code in the context of the whole system
137
139
 
138
- 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.
140
+ 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
141
 
140
142
  ### Summary
141
143
 
@@ -177,3 +179,4 @@ Tools I keep around but rarely use.
177
179
  - **[migrate-to-shoehorn](./skills/misc/migrate-to-shoehorn/SKILL.md)** — Migrate test files from `as` type assertions to @total-typescript/shoehorn.
178
180
  - **[scaffold-exercises](./skills/misc/scaffold-exercises/SKILL.md)** — Create exercise directory structures with sections, problems, solutions, and explainers.
179
181
  - **[setup-pre-commit](./skills/misc/setup-pre-commit/SKILL.md)** — Set up Husky pre-commit hooks with lint-staged, Prettier, type checking, and tests.
182
+
@@ -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
- if (args.has("--help") || args.has("-h")) {
17
- console.log(`Usage: npx @ecology91/skills [--dry-run] [--copy] [--link]
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
- --link Force symlinks (runs scripts/link-skills.sh)
25
- --copy Force copies instead of symlinks`);
26
- process.exit(0);
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
- const dryRun = args.has("--dry-run");
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
- function collectSkills() {
32
- const skills = [];
45
+ const { flags, values } = parsed;
33
46
 
34
- for (const bucket of buckets) {
35
- const bucketDir = path.join(root, "skills", bucket);
36
- if (!fs.existsSync(bucketDir)) continue;
47
+ if (flags.has("--help") || flags.has("-h")) {
48
+ printHelp();
49
+ process.exit(0);
50
+ }
37
51
 
38
- for (const entry of fs.readdirSync(bucketDir, { withFileTypes: true })) {
39
- if (!entry.isDirectory()) continue;
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
- const src = path.join(bucketDir, entry.name);
42
- if (!fs.existsSync(path.join(src, "SKILL.md"))) continue;
58
+ const catalog = collectSkillsByBucket(root, destRoot);
59
+ const allSkills = flattenCatalog(catalog);
43
60
 
44
- skills.push([src, path.join(destRoot, entry.name)]);
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
- return skills;
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) fs.mkdirSync(destRoot, { recursive: true });
93
+ if (!dryRun) {
94
+ assertSafeDest();
95
+ fs.mkdirSync(destRoot, { recursive: true });
96
+ }
53
97
 
54
- for (const [src, dest] of skills) {
55
- if (dryRun) {
56
- console.log(`would install ${path.basename(src)} -> ${dest}`);
57
- continue;
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
- for (const [src, dest] of skills) {
70
- console.log(`would link ${path.basename(src)} -> ${dest} (${src})`);
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
- execFileSync("bash", [path.join(root, "scripts/link-skills.sh")], {
76
- stdio: "inherit",
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
- const skills = collectSkills();
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(skills);
167
+ installLink(selectedSkills);
168
+ const verb = dryRun ? "Checked" : "Linked";
84
169
  console.log(
85
- `${dryRun ? "Checked" : "Linked"} ${skills.length} skills to ~/.agents/skills.`,
170
+ `${verb} ${selectedSkills.length} skills to ~/.agents/skills (${formatBucketCounts(countByBucket(selectedSkills))}).`,
86
171
  );
87
172
  } else {
88
- installCopy(skills);
173
+ installCopy(selectedSkills);
174
+ const verb = dryRun ? "Checked" : "Installed";
89
175
  console.log(
90
- `${dryRun ? "Checked" : "Installed"} ${skills.length} skills to ~/.agents/skills.`,
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,9 +1,12 @@
1
1
  {
2
2
  "name": "@ecology91/skills",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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
  },
@@ -37,8 +40,9 @@
37
40
  "access": "public"
38
41
  },
39
42
  "scripts": {
40
- "typecheck": "node --check bin/install.mjs && node --check scripts/verify-package-files.mjs",
41
- "test": "node bin/install.mjs --dry-run --copy",
43
+ "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/verify-package-files.mjs",
44
+ "test": "node --test bin/resolve-selection.test.mjs && node bin/install.mjs --dry-run --copy --all",
45
+ "test:unit": "node --test bin/resolve-selection.test.mjs",
42
46
  "link-skills": "./scripts/link-skills.sh",
43
47
  "build": "node scripts/verify-package-files.mjs",
44
48
  "validate:fast": "npm run typecheck && npm run test",
@@ -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
- DEST="$HOME/.agents/skills"
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 "$@"