@ecology91/skills 0.1.5 → 0.1.7

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.
@@ -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
@@ -17,6 +17,8 @@ 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
+ The install menu is grouped by bucket (`engineering`, `productivity`, `misc`). Toggle a whole category or pick individual skills in one screen.
21
+
20
22
  1. Or install from a git checkout or the published npm package:
21
23
 
22
24
  ```bash
@@ -0,0 +1,74 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ const home = os.homedir();
5
+
6
+ /** @param {string} targetPath */
7
+ export function shortenPath(targetPath) {
8
+ const normalized = path.resolve(targetPath);
9
+ if (normalized.startsWith(`${home}${path.sep}`)) {
10
+ return `~${path.sep}${path.relative(home, normalized)}`;
11
+ }
12
+ return normalized;
13
+ }
14
+
15
+ /** @param {string} bucketId */
16
+ export function bucketTitle(bucketId) {
17
+ return bucketId
18
+ .split("-")
19
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
20
+ .join(" ");
21
+ }
22
+
23
+ /** @param {string[]} paths */
24
+ export function dedupePaths(paths) {
25
+ return [...new Set(paths)];
26
+ }
27
+
28
+ /**
29
+ * @param {Map<string, { name: string, dest: string, src?: string }[]>} grouped
30
+ * @param {{ mode: "copy" | "link", dryRun: boolean }} options
31
+ */
32
+ export function formatInstallSummary(grouped, { mode, dryRun }) {
33
+ const lines = [];
34
+ const actionLabel = dryRun ? (mode === "link" ? "would link" : "would install") : mode;
35
+
36
+ for (const [bucket, bucketSkills] of grouped) {
37
+ lines.push("");
38
+ lines.push(bucketTitle(bucket));
39
+
40
+ for (const skill of bucketSkills) {
41
+ const shortDest = shortenPath(skill.dest);
42
+
43
+ if (mode === "copy") {
44
+ const marker = dryRun ? actionLabel : "copied";
45
+ lines.push(`✓ ${skill.name} (${marker})`);
46
+ lines.push(` → ${shortDest}`);
47
+ continue;
48
+ }
49
+
50
+ if (dryRun) {
51
+ lines.push(`✓ ${skill.name} (${actionLabel})`);
52
+ lines.push(` → ${shortDest}`);
53
+ if (skill.src) {
54
+ lines.push(` → ${shortenPath(skill.src)}`);
55
+ }
56
+ continue;
57
+ }
58
+
59
+ lines.push(`✓ ${skill.name}`);
60
+ lines.push(` → ${shortDest}`);
61
+ if (skill.src) {
62
+ lines.push(` → ${shortenPath(skill.src)}`);
63
+ }
64
+ }
65
+ }
66
+
67
+ const skillCount = [...grouped.values()].reduce((total, skills) => total + skills.length, 0);
68
+ const verb = dryRun ? "Checked" : mode === "link" ? "Linked" : "Installed";
69
+
70
+ return {
71
+ title: `${verb} ${skillCount} skill${skillCount === 1 ? "" : "s"}`,
72
+ lines: lines.join("\n").trimStart(),
73
+ };
74
+ }
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { test } from "node:test";
5
+ import { dedupePaths, formatInstallSummary, shortenPath } from "./install-summary.mjs";
6
+
7
+ test("dedupePaths removes duplicate install destinations", () => {
8
+ assert.deepEqual(
9
+ dedupePaths(["~/.agents/skills/tdd", "~/.agents/skills/tdd", "~/.cursor/skills/tdd"]),
10
+ ["~/.agents/skills/tdd", "~/.cursor/skills/tdd"],
11
+ );
12
+ });
13
+
14
+ test("formatInstallSummary shows one destination line per skill", () => {
15
+ const dest = path.join(os.homedir(), ".agents", "skills", "diagnose");
16
+ const grouped = new Map([
17
+ [
18
+ "engineering",
19
+ [{ name: "diagnose", dest }],
20
+ ],
21
+ ]);
22
+
23
+ const summary = formatInstallSummary(grouped, { mode: "copy", dryRun: false });
24
+ assert.equal(summary.title, "Installed 1 skill");
25
+ assert.equal(summary.lines.split("\n").filter((line) => line.startsWith(" →")).length, 1);
26
+ });
27
+
28
+ test("shortenPath replaces home prefix with tilde", () => {
29
+ const dest = path.join(os.homedir(), ".agents", "skills", "tdd");
30
+ assert.equal(shortenPath(dest), "~/.agents/skills/tdd");
31
+ });
package/bin/install.mjs CHANGED
@@ -3,12 +3,14 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { note, outro, spinner } from "@clack/prompts";
6
7
  import {
7
8
  collectSkillsByBucket,
8
9
  countByBucket,
9
10
  flattenCatalog,
10
11
  formatBucketCounts,
11
12
  } from "./collect-skills.mjs";
13
+ import { formatInstallSummary } from "./install-summary.mjs";
12
14
  import { promptSkillSelection } from "./prompt-skills.mjs";
13
15
  import { parseArgs, resolveSelection, skillsFromNames } from "./resolve-selection.mjs";
14
16
 
@@ -54,6 +56,7 @@ const isGitCheckout = fs.existsSync(path.join(root, ".git"));
54
56
  const link = flags.has("--link") || (isGitCheckout && !flags.has("--copy"));
55
57
  const isInteractive =
56
58
  Boolean(process.stdin.isTTY && process.stdout.isTTY) && !flags.has("--all");
59
+ const useClackSummary = isInteractive;
57
60
 
58
61
  const catalog = collectSkillsByBucket(root, destRoot);
59
62
  const allSkills = flattenCatalog(catalog);
@@ -97,39 +100,54 @@ function installCopy(skills) {
97
100
 
98
101
  const grouped = groupSkillsByBucket(skills);
99
102
  for (const [bucket, bucketSkills] of grouped) {
100
- console.log(`${bucket} (${bucketSkills.length})`);
103
+ if (!useClackSummary) {
104
+ console.log(`${bucket} (${bucketSkills.length})`);
105
+ }
106
+
101
107
  for (const skill of bucketSkills) {
102
108
  if (dryRun) {
103
- console.log(` would install ${skill.name} -> ${skill.dest}`);
109
+ if (!useClackSummary) {
110
+ console.log(` would install ${skill.name} -> ${skill.dest}`);
111
+ }
104
112
  continue;
105
113
  }
106
114
 
107
115
  fs.rmSync(skill.dest, { recursive: true, force: true });
108
116
  fs.cpSync(skill.src, skill.dest, { recursive: true, force: true });
109
- console.log(` installed ${skill.name} -> ${skill.dest}`);
117
+ if (!useClackSummary) {
118
+ console.log(` installed ${skill.name} -> ${skill.dest}`);
119
+ }
110
120
  }
111
121
  }
122
+
123
+ return grouped;
112
124
  }
113
125
 
114
126
  /** @param {{ bucket: string, name: string, src: string, dest: string }[]} skills */
115
127
  function installLink(skills) {
128
+ const grouped = groupSkillsByBucket(skills);
129
+
116
130
  if (dryRun) {
117
- const grouped = groupSkillsByBucket(skills);
118
131
  for (const [bucket, bucketSkills] of grouped) {
119
- console.log(`${bucket} (${bucketSkills.length})`);
132
+ if (!useClackSummary) {
133
+ console.log(`${bucket} (${bucketSkills.length})`);
134
+ }
120
135
  for (const skill of bucketSkills) {
121
- console.log(` would link ${skill.name} -> ${skill.dest} (${skill.src})`);
136
+ if (!useClackSummary) {
137
+ console.log(` would link ${skill.name} -> ${skill.dest} (${skill.src})`);
138
+ }
122
139
  }
123
140
  }
124
- return;
141
+ return grouped;
125
142
  }
126
143
 
127
144
  assertSafeDest();
128
145
  fs.mkdirSync(destRoot, { recursive: true });
129
146
 
130
- const grouped = groupSkillsByBucket(skills);
131
147
  for (const [bucket, bucketSkills] of grouped) {
132
- console.log(`${bucket} (${bucketSkills.length})`);
148
+ if (!useClackSummary) {
149
+ console.log(`${bucket} (${bucketSkills.length})`);
150
+ }
133
151
  for (const skill of bucketSkills) {
134
152
  const stat = fs.lstatSync(skill.dest, { throwIfNoEntry: false });
135
153
  if (stat) {
@@ -137,9 +155,43 @@ function installLink(skills) {
137
155
  }
138
156
 
139
157
  fs.symlinkSync(skill.src, skill.dest);
140
- console.log(` linked ${skill.name} -> ${skill.src}`);
158
+ if (!useClackSummary) {
159
+ console.log(` linked ${skill.name} -> ${skill.src}`);
160
+ }
141
161
  }
142
162
  }
163
+
164
+ return grouped;
165
+ }
166
+
167
+ function printPlainSummary(selectedSkills, mode) {
168
+ const verb = dryRun ? "Checked" : mode === "link" ? "Linked" : "Installed";
169
+ console.log(
170
+ `${verb} ${selectedSkills.length} skills to ~/.agents/skills (${formatBucketCounts(countByBucket(selectedSkills))}).`,
171
+ );
172
+ console.log("Restart your agent (opencode, Cursor, etc.) to reload the skill list.");
173
+ }
174
+
175
+ function printClackSummary(grouped, mode) {
176
+ const summarySkills = new Map();
177
+ for (const [bucket, bucketSkills] of grouped) {
178
+ summarySkills.set(
179
+ bucket,
180
+ bucketSkills.map((skill) => ({
181
+ name: skill.name,
182
+ dest: skill.dest,
183
+ src: mode === "link" ? skill.src : undefined,
184
+ })),
185
+ );
186
+ }
187
+
188
+ const { title, lines } = formatInstallSummary(summarySkills, {
189
+ mode: mode === "link" ? "link" : "copy",
190
+ dryRun,
191
+ });
192
+
193
+ note(lines, title);
194
+ outro("Restart your agent (opencode, Cursor, etc.) to reload the skill list.");
143
195
  }
144
196
 
145
197
  let selectedSkills;
@@ -163,18 +215,23 @@ if (selectedSkills.length === 0) {
163
215
  process.exit(0);
164
216
  }
165
217
 
166
- if (link) {
167
- installLink(selectedSkills);
168
- const verb = dryRun ? "Checked" : "Linked";
169
- console.log(
170
- `${verb} ${selectedSkills.length} skills to ~/.agents/skills (${formatBucketCounts(countByBucket(selectedSkills))}).`,
171
- );
218
+ const installMode = link ? "link" : "copy";
219
+ const s = spinner();
220
+
221
+ if (useClackSummary) {
222
+ s.start("Installing skills...");
223
+ }
224
+
225
+ let grouped;
226
+ if (installMode === "link") {
227
+ grouped = installLink(selectedSkills);
172
228
  } else {
173
- installCopy(selectedSkills);
174
- const verb = dryRun ? "Checked" : "Installed";
175
- console.log(
176
- `${verb} ${selectedSkills.length} skills to ~/.agents/skills (${formatBucketCounts(countByBucket(selectedSkills))}).`,
177
- );
229
+ grouped = installCopy(selectedSkills);
178
230
  }
179
231
 
180
- console.log("Restart your agent (opencode, Cursor, etc.) to reload the skill list.");
232
+ if (useClackSummary) {
233
+ s.stop("Installation complete");
234
+ printClackSummary(grouped, installMode);
235
+ } else {
236
+ printPlainSummary(selectedSkills, installMode);
237
+ }
@@ -1,4 +1,4 @@
1
- import { cancel, groupMultiselect, intro, isCancel, outro } from "@clack/prompts";
1
+ import { cancel, groupMultiselect, intro, isCancel } from "@clack/prompts";
2
2
 
3
3
  /**
4
4
  * @param {{ id: string, label: string, skills: { name: string, description: string }[] }[]} catalog
@@ -34,6 +34,5 @@ export async function promptSkillSelection(catalog) {
34
34
  return null;
35
35
  }
36
36
 
37
- outro(`Selected ${selected.length} skill${selected.length === 1 ? "" : "s"}.`);
38
37
  return selected;
39
38
  }
@@ -103,5 +103,16 @@ function filterByFlags(allSkills, values, allNames) {
103
103
  export function skillsFromNames(selectedNames, allSkills) {
104
104
  if (!selectedNames || selectedNames.length === 0) return [];
105
105
  const byName = new Map(allSkills.map((skill) => [skill.name, skill]));
106
- return selectedNames.map((name) => byName.get(name)).filter(Boolean);
106
+ const seen = new Set();
107
+ /** @type {typeof allSkills} */
108
+ const selected = [];
109
+
110
+ for (const name of selectedNames) {
111
+ if (seen.has(name)) continue;
112
+ seen.add(name);
113
+ const skill = byName.get(name);
114
+ if (skill) selected.push(skill);
115
+ }
116
+
117
+ return selected;
107
118
  }
@@ -58,3 +58,9 @@ test("skillsFromNames preserves prompt order", () => {
58
58
  const selected = skillsFromNames(["caveman", "tdd"], allSkills);
59
59
  assert.deepEqual(selected.map((skill) => skill.name), ["caveman", "tdd"]);
60
60
  });
61
+
62
+ test("skillsFromNames deduplicates repeated selections", () => {
63
+ const allSkills = catalog.flatMap((bucket) => bucket.skills);
64
+ const selected = skillsFromNames(["tdd", "tdd", "tdd"], allSkills);
65
+ assert.deepEqual(selected.map((skill) => skill.name), ["tdd"]);
66
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecology91/skills",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "opencode agent skills for real engineering workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "bin/",
15
15
  "scripts/",
16
+ ".claude-plugin/",
16
17
  "skills/engineering/",
17
18
  "skills/productivity/",
18
19
  "skills/misc/",
@@ -40,11 +41,12 @@
40
41
  "access": "public"
41
42
  },
42
43
  "scripts": {
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",
44
+ "typecheck": "node --check bin/install.mjs && node --check bin/collect-skills.mjs && node --check bin/install-summary.mjs && node --check bin/prompt-skills.mjs && node --check bin/resolve-selection.mjs && node --check bin/resolve-selection.test.mjs && node --check bin/install-summary.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 --test bin/install-summary.test.mjs && node bin/install.mjs --dry-run --copy --all",
46
+ "test:unit": "node --test bin/resolve-selection.test.mjs && node --test bin/install-summary.test.mjs",
46
47
  "link-skills": "./scripts/link-skills.sh",
47
- "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",
48
50
  "validate:fast": "npm run typecheck && npm run test",
49
51
  "validate": "npm run validate:fast && npm run build"
50
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).`);
@@ -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.");