@skilly-hand/skilly-hand 0.17.0 → 0.19.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -3
  3. package/catalog/skills/accessibility-audit/SKILL.md +21 -0
  4. package/catalog/skills/agents-root-orchestrator/SKILL.md +19 -0
  5. package/catalog/skills/angular-guidelines/SKILL.md +21 -0
  6. package/catalog/skills/figma-mcp-0to1/SKILL.md +21 -0
  7. package/catalog/skills/frontend-design/SKILL.md +17 -0
  8. package/catalog/skills/output-optimizer/SKILL.md +18 -0
  9. package/catalog/skills/project-security/SKILL.md +19 -0
  10. package/catalog/skills/project-security/assets/generic-ci-security-gate.sh +1 -28
  11. package/catalog/skills/project-security/assets/github-actions-security-gate.yml +38 -0
  12. package/catalog/skills/project-security/assets/pre-commit.sample.sh +1 -1
  13. package/catalog/skills/project-security/assets/pre-push.sample.sh +1 -30
  14. package/catalog/skills/project-security/assets/run-security-check.shared.sh +33 -0
  15. package/catalog/skills/project-teacher/SKILL.md +17 -0
  16. package/catalog/skills/react-guidelines/SKILL.md +21 -0
  17. package/catalog/skills/review-rangers/SKILL.md +17 -0
  18. package/catalog/skills/skill-creator/SKILL.md +34 -0
  19. package/catalog/skills/skill-creator/assets/SKILL-TEMPLATE.md +6 -0
  20. package/catalog/skills/spec-driven-development/SKILL.md +19 -0
  21. package/catalog/skills/test-driven-development/SKILL.md +17 -0
  22. package/catalog/skills/token-optimizer/SKILL.md +18 -0
  23. package/package.json +6 -4
  24. package/packages/catalog/package.json +1 -1
  25. package/packages/catalog/src/index.js +400 -4
  26. package/packages/cli/package.json +1 -1
  27. package/packages/cli/src/bin.js +126 -161
  28. package/packages/cli/src/ink-ui.js +692 -0
  29. package/packages/core/package.json +1 -1
  30. package/packages/core/src/terminal.js +16 -5
  31. package/packages/core/src/ui/layout.js +193 -42
  32. package/packages/detectors/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilly-hand/catalog",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,4 +1,4 @@
1
- import { cp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
1
+ import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
@@ -21,6 +21,16 @@ const REQUIRED_FIELDS = [
21
21
  "dependencies"
22
22
  ];
23
23
 
24
+ const MIRRORED_SKILL_METADATA_KEYS = [
25
+ "author",
26
+ "last-edit",
27
+ "license",
28
+ "version",
29
+ "changelog",
30
+ "auto-invoke",
31
+ "allowed-tools"
32
+ ];
33
+
24
34
  export function getCatalogRoot() {
25
35
  return catalogDir;
26
36
  }
@@ -70,9 +80,372 @@ export function validateSkillManifest(manifest) {
70
80
  throw new Error(`Skill "${manifest.id}" must declare files`);
71
81
  }
72
82
 
83
+ const hasSkillInstruction = manifest.files.some((file) => file.path === "SKILL.md" && file.kind === "instruction");
84
+ if (!hasSkillInstruction) {
85
+ throw new Error(`Skill "${manifest.id}" must include files entry for SKILL.md as instruction`);
86
+ }
87
+
88
+ assertFrontmatterFields(manifest);
89
+
73
90
  return true;
74
91
  }
75
92
 
93
+ function toLf(text) {
94
+ return text.replaceAll("\r\n", "\n");
95
+ }
96
+
97
+ function splitLinesWithOffsets(text) {
98
+ const lines = [];
99
+ let start = 0;
100
+ for (let index = 0; index < text.length; index += 1) {
101
+ if (text[index] === "\n") {
102
+ lines.push({
103
+ text: text.slice(start, index),
104
+ start,
105
+ end: index + 1
106
+ });
107
+ start = index + 1;
108
+ }
109
+ }
110
+ if (start < text.length) {
111
+ lines.push({
112
+ text: text.slice(start),
113
+ start,
114
+ end: text.length
115
+ });
116
+ } else if (text.length === 0) {
117
+ lines.push({ text: "", start: 0, end: 0 });
118
+ }
119
+ return lines;
120
+ }
121
+
122
+ function yamlQuote(value) {
123
+ return JSON.stringify(String(value));
124
+ }
125
+
126
+ function assertFrontmatterFields(manifest) {
127
+ if (!manifest || typeof manifest !== "object") {
128
+ throw new Error("Invalid manifest while building SKILL.md frontmatter");
129
+ }
130
+
131
+ if (typeof manifest.description !== "string" || manifest.description.length === 0) {
132
+ throw new Error(`Skill "${manifest.id}" is missing required manifest.description for frontmatter mirroring`);
133
+ }
134
+
135
+ if (!manifest.skillMetadata || typeof manifest.skillMetadata !== "object") {
136
+ throw new Error(`Skill "${manifest.id}" is missing required manifest.skillMetadata for frontmatter mirroring`);
137
+ }
138
+
139
+ for (const key of MIRRORED_SKILL_METADATA_KEYS) {
140
+ if (!(key in manifest.skillMetadata)) {
141
+ throw new Error(`Skill "${manifest.id}" is missing required skillMetadata.${key} for frontmatter mirroring`);
142
+ }
143
+ }
144
+
145
+ const scalarKeys = ["author", "last-edit", "license", "version", "changelog", "auto-invoke"];
146
+ for (const key of scalarKeys) {
147
+ if (typeof manifest.skillMetadata[key] !== "string" || manifest.skillMetadata[key].trim().length === 0) {
148
+ throw new Error(`Skill "${manifest.id}" has invalid skillMetadata.${key}; expected a non-empty string`);
149
+ }
150
+ }
151
+
152
+ if (!Array.isArray(manifest.skillMetadata["allowed-tools"])) {
153
+ throw new Error(`Skill "${manifest.id}" must declare skillMetadata.allowed-tools as an array`);
154
+ }
155
+
156
+ for (const tool of manifest.skillMetadata["allowed-tools"]) {
157
+ if (typeof tool !== "string" || tool.trim().length === 0) {
158
+ throw new Error(`Skill "${manifest.id}" has invalid skillMetadata.allowed-tools; expected non-empty strings`);
159
+ }
160
+ }
161
+ }
162
+
163
+ export function buildSkillFrontmatterPayload(manifest) {
164
+ assertFrontmatterFields(manifest);
165
+ return {
166
+ description: manifest.description,
167
+ skillMetadata: {
168
+ author: manifest.skillMetadata.author,
169
+ "last-edit": manifest.skillMetadata["last-edit"],
170
+ license: manifest.skillMetadata.license,
171
+ version: manifest.skillMetadata.version,
172
+ changelog: manifest.skillMetadata.changelog,
173
+ "auto-invoke": manifest.skillMetadata["auto-invoke"],
174
+ "allowed-tools": [...manifest.skillMetadata["allowed-tools"]]
175
+ }
176
+ };
177
+ }
178
+
179
+ function renderSkillFrontmatterInner(payload) {
180
+ const lines = [
181
+ `description: ${yamlQuote(payload.description)}`,
182
+ "skillMetadata:",
183
+ ` author: ${yamlQuote(payload.skillMetadata.author)}`,
184
+ ` last-edit: ${yamlQuote(payload.skillMetadata["last-edit"])}`,
185
+ ` license: ${yamlQuote(payload.skillMetadata.license)}`,
186
+ ` version: ${yamlQuote(payload.skillMetadata.version)}`,
187
+ ` changelog: ${yamlQuote(payload.skillMetadata.changelog)}`,
188
+ ` auto-invoke: ${yamlQuote(payload.skillMetadata["auto-invoke"])}`,
189
+ " allowed-tools:"
190
+ ];
191
+
192
+ for (const tool of payload.skillMetadata["allowed-tools"]) {
193
+ lines.push(` - ${yamlQuote(tool)}`);
194
+ }
195
+
196
+ return lines.join("\n");
197
+ }
198
+
199
+ export function renderSkillFrontmatter(manifest) {
200
+ const payload = buildSkillFrontmatterPayload(manifest);
201
+ return `---\n${renderSkillFrontmatterInner(payload)}\n---\n`;
202
+ }
203
+
204
+ export function splitSkillMarkdown(content) {
205
+ const normalized = toLf(content);
206
+ const source = normalized.startsWith("\uFEFF") ? normalized.slice(1) : normalized;
207
+ const lines = splitLinesWithOffsets(source);
208
+ const mirroredKeys = new Set([
209
+ "description",
210
+ "skillMetadata",
211
+ "author",
212
+ "last-edit",
213
+ "license",
214
+ "version",
215
+ "changelog",
216
+ "auto-invoke",
217
+ "allowed-tools"
218
+ ]);
219
+ const isYamlLike = (line) => (
220
+ line.trim().length === 0 ||
221
+ /^\s*[A-Za-z0-9_-]+:(?:\s.*)?$/.test(line) ||
222
+ /^\s*-\s+.*$/.test(line)
223
+ );
224
+
225
+ let firstNonBlankIndex = 0;
226
+ while (firstNonBlankIndex < lines.length && lines[firstNonBlankIndex].text.trim().length === 0) {
227
+ firstNonBlankIndex += 1;
228
+ }
229
+
230
+ if (firstNonBlankIndex >= lines.length || lines[firstNonBlankIndex].text !== "---") {
231
+ return {
232
+ hasFrontmatter: false,
233
+ malformedFrontmatter: false,
234
+ frontmatter: null,
235
+ body: source
236
+ };
237
+ }
238
+
239
+ const openLine = lines[firstNonBlankIndex];
240
+ let sawKeyValue = false;
241
+ let sawMirroredKey = false;
242
+ const detectedKeys = new Set();
243
+
244
+ for (let index = firstNonBlankIndex + 1; index < lines.length; index += 1) {
245
+ const line = lines[index].text;
246
+ const nextLine = index + 1 < lines.length ? lines[index + 1].text : null;
247
+
248
+ if (
249
+ sawMirroredKey &&
250
+ line.trim().length === 0 &&
251
+ nextLine &&
252
+ /^(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+|>\s+|```)/.test(nextLine)
253
+ ) {
254
+ return {
255
+ hasFrontmatter: true,
256
+ malformedFrontmatter: true,
257
+ frontmatter: null,
258
+ body: source.slice(lines[index + 1].start)
259
+ };
260
+ }
261
+
262
+ if (line === "---") {
263
+ if (!sawKeyValue || !sawMirroredKey) {
264
+ return {
265
+ hasFrontmatter: false,
266
+ malformedFrontmatter: false,
267
+ frontmatter: null,
268
+ body: source
269
+ };
270
+ }
271
+ const end = lines[index].end;
272
+ const frontmatter = source.slice(openLine.start, end);
273
+ return {
274
+ hasFrontmatter: true,
275
+ malformedFrontmatter: false,
276
+ frontmatter: frontmatter.endsWith("\n") ? frontmatter : `${frontmatter}\n`,
277
+ body: source.slice(end),
278
+ detectedKeys: Array.from(detectedKeys)
279
+ };
280
+ }
281
+
282
+ if (/^\s*[A-Za-z0-9_-]+:(?:\s.*)?$/.test(line)) {
283
+ sawKeyValue = true;
284
+ const key = line.split(":", 1)[0].trim();
285
+ detectedKeys.add(key);
286
+ if (mirroredKeys.has(key)) {
287
+ sawMirroredKey = true;
288
+ }
289
+ }
290
+
291
+ if (!isYamlLike(line)) {
292
+ if (!sawKeyValue || !sawMirroredKey) {
293
+ return {
294
+ hasFrontmatter: false,
295
+ malformedFrontmatter: false,
296
+ frontmatter: null,
297
+ body: source
298
+ };
299
+ }
300
+ return {
301
+ hasFrontmatter: true,
302
+ malformedFrontmatter: true,
303
+ frontmatter: null,
304
+ body: source.slice(lines[index].start),
305
+ detectedKeys: Array.from(detectedKeys)
306
+ };
307
+ }
308
+ }
309
+
310
+ if (!sawKeyValue) {
311
+ return {
312
+ hasFrontmatter: false,
313
+ malformedFrontmatter: false,
314
+ frontmatter: null,
315
+ body: source
316
+ };
317
+ }
318
+
319
+ if (!sawMirroredKey) {
320
+ return {
321
+ hasFrontmatter: false,
322
+ malformedFrontmatter: false,
323
+ frontmatter: null,
324
+ body: source
325
+ };
326
+ }
327
+
328
+ return {
329
+ hasFrontmatter: true,
330
+ malformedFrontmatter: true,
331
+ frontmatter: null,
332
+ body: "",
333
+ detectedKeys: Array.from(detectedKeys)
334
+ };
335
+ }
336
+
337
+ export function applyManifestFrontmatterToSkill(content, manifest) {
338
+ const expectedFrontmatter = renderSkillFrontmatter(manifest);
339
+ const parts = splitSkillMarkdown(content);
340
+ return `${expectedFrontmatter}${parts.body}`;
341
+ }
342
+
343
+ export function verifySkillFrontmatterContent(content, manifest) {
344
+ const expectedFrontmatter = renderSkillFrontmatter(manifest);
345
+ const parts = splitSkillMarkdown(content);
346
+ if (!parts.hasFrontmatter) {
347
+ return { ok: false, reason: "missing" };
348
+ }
349
+ if (parts.malformedFrontmatter || !parts.frontmatter) {
350
+ return { ok: false, reason: "malformed" };
351
+ }
352
+ if (parts.frontmatter !== expectedFrontmatter) {
353
+ return { ok: false, reason: "mismatch" };
354
+ }
355
+ const residual = splitSkillMarkdown(parts.body);
356
+ if (residual.hasFrontmatter && !residual.malformedFrontmatter && residual.frontmatter === expectedFrontmatter) {
357
+ return { ok: false, reason: "residual-frontmatter" };
358
+ }
359
+ return { ok: true, reason: null };
360
+ }
361
+
362
+ export async function syncSkillFrontmatter({ skillId, dryRun = false } = {}) {
363
+ const plan = await planSkillFrontmatterSync({ skillId });
364
+ if (!dryRun) {
365
+ await applyTextUpdatesAtomically(plan.updates);
366
+ }
367
+ return {
368
+ skillCount: plan.skillCount,
369
+ updatedSkillIds: plan.updatedSkillIds
370
+ };
371
+ }
372
+
373
+ export async function planSkillFrontmatterSync({ skillId } = {}) {
374
+ const allIds = await listSkillIds();
375
+ if (skillId && !allIds.includes(skillId)) {
376
+ throw new Error(`Unknown skill id: ${skillId}`);
377
+ }
378
+
379
+ const ids = skillId ? [skillId] : allIds;
380
+ const updatedSkillIds = [];
381
+ const updates = [];
382
+
383
+ for (const id of ids) {
384
+ const manifest = await loadSkillManifest(id);
385
+ const skillPath = path.join(skillsDir, id, "SKILL.md");
386
+ const current = await readFile(skillPath, "utf8");
387
+ const next = applyManifestFrontmatterToSkill(current, manifest);
388
+ if (next !== toLf(current)) {
389
+ updatedSkillIds.push(id);
390
+ updates.push({
391
+ skillId: id,
392
+ path: skillPath,
393
+ content: next
394
+ });
395
+ }
396
+ }
397
+
398
+ return {
399
+ skillCount: ids.length,
400
+ updatedSkillIds,
401
+ updates
402
+ };
403
+ }
404
+
405
+ export async function applyTextUpdatesAtomically(updates) {
406
+ const deduped = [];
407
+ const seenPaths = new Set();
408
+ for (let index = updates.length - 1; index >= 0; index -= 1) {
409
+ const update = updates[index];
410
+ if (!seenPaths.has(update.path)) {
411
+ seenPaths.add(update.path);
412
+ deduped.push(update);
413
+ }
414
+ }
415
+ deduped.reverse();
416
+
417
+ const originals = new Map();
418
+ for (const update of deduped) {
419
+ try {
420
+ originals.set(update.path, await readFile(update.path, "utf8"));
421
+ } catch (error) {
422
+ if (error?.code === "ENOENT") {
423
+ originals.set(update.path, null);
424
+ } else {
425
+ throw error;
426
+ }
427
+ }
428
+ }
429
+
430
+ const writtenPaths = [];
431
+ try {
432
+ for (const update of deduped) {
433
+ await writeFile(update.path, update.content, "utf8");
434
+ writtenPaths.push(update.path);
435
+ }
436
+ } catch (error) {
437
+ for (const targetPath of writtenPaths.reverse()) {
438
+ const original = originals.get(targetPath);
439
+ if (original === null) {
440
+ await rm(targetPath, { force: true });
441
+ } else {
442
+ await writeFile(targetPath, original, "utf8");
443
+ }
444
+ }
445
+ throw error;
446
+ }
447
+ }
448
+
76
449
  export async function copySkillTo(targetCatalogDir, skillId) {
77
450
  const source = path.join(skillsDir, skillId);
78
451
  const destination = path.join(targetCatalogDir, skillId);
@@ -88,6 +461,7 @@ export async function readTemplate(templateName) {
88
461
  }
89
462
 
90
463
  export function renderAgentsMarkdown({ skills, detections, generatedAt, projectName }) {
464
+ const escapeTableCell = (value) => String(value).replaceAll("|", "\\|").replaceAll("\n", "<br>");
91
465
  const sortedSkills = [...skills].sort((a, b) => a.id.localeCompare(b.id));
92
466
  const sortedDetections = [...detections].sort((a, b) => a.technology.localeCompare(b.technology));
93
467
  const autoInvokeSkills = sortedSkills.filter((skill) => skill.skillMetadata?.["auto-invoke"]);
@@ -114,7 +488,7 @@ export function renderAgentsMarkdown({ skills, detections, generatedAt, projectN
114
488
  ];
115
489
 
116
490
  for (const skill of sortedSkills) {
117
- lines.push(`| \`${skill.id}\` | ${skill.description} | ${skill.tags.join(", ")} |`);
491
+ lines.push(`| \`${skill.id}\` | ${escapeTableCell(skill.description)} | ${escapeTableCell(skill.tags.join(", "))} |`);
118
492
  }
119
493
 
120
494
  lines.push(
@@ -126,7 +500,7 @@ export function renderAgentsMarkdown({ skills, detections, generatedAt, projectN
126
500
  "1. Always run `token-optimizer` first to classify complexity and set the minimum viable reasoning depth.",
127
501
  "2. Always run `output-optimizer` immediately after `token-optimizer` for response-shape control.",
128
502
  "3. `output-optimizer` mode policy:",
129
- " - Default: select a random canonical mode for each new interaction.",
503
+ " - Default: use `step-brief` when there is no explicit mode or strong phrasing signal.",
130
504
  " - Override: if user explicitly requests a mode (for example `mode: step-brief`), that explicit mode wins.",
131
505
  " - Persistence: keep the explicitly requested mode active until the user asks for a different mode.",
132
506
  "",
@@ -154,7 +528,7 @@ export function renderAgentsMarkdown({ skills, detections, generatedAt, projectN
154
528
  } else {
155
529
  lines.push("| Action | Skill |", "| ------ | ----- |");
156
530
  for (const skill of autoInvokeSkills) {
157
- lines.push(`| ${skill.skillMetadata["auto-invoke"]} | \`${skill.id}\` |`);
531
+ lines.push(`| ${escapeTableCell(skill.skillMetadata["auto-invoke"])} | \`${skill.id}\` |`);
158
532
  }
159
533
  }
160
534
 
@@ -230,6 +604,28 @@ export async function verifyCatalogFiles() {
230
604
  issues.push(`Missing file for ${skillId}: ${file.path}`);
231
605
  }
232
606
  }
607
+
608
+ const skillDocPath = path.join(skillPath, "SKILL.md");
609
+ let skillDocContent;
610
+ try {
611
+ skillDocContent = await readFile(skillDocPath, "utf8");
612
+ } catch (error) {
613
+ if (error?.code === "ENOENT") {
614
+ // Missing file is already surfaced above via manifest file verification.
615
+ continue;
616
+ }
617
+ issues.push(`Cannot read ${skillId}/SKILL.md: ${error.message}`);
618
+ continue;
619
+ }
620
+
621
+ try {
622
+ const status = verifySkillFrontmatterContent(skillDocContent, manifest);
623
+ if (!status.ok) {
624
+ issues.push(`Frontmatter ${status.reason} for ${skillId}: SKILL.md`);
625
+ }
626
+ } catch (error) {
627
+ issues.push(`Frontmatter validation failed for ${skillId}: ${error.message}`);
628
+ }
233
629
  }
234
630
 
235
631
  return issues;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilly-hand/cli",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "bin": {