From b1d42615de4c685322319bd3b898e26cc7f5f1a9 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 11:16:24 -0500 Subject: [PATCH 01/16] feat(autoskills): foundation for opt-in markdown scanner + LLM mode - widen Technology interface with optional aliases?: string[] and description?: string - add tests/fixtures/specs/: well-formed, only-fences, only-heading, empty, malformed, non-english - add tests/fixtures/projects/: with-claude-md, with-agents-md, with-both (package.json + CLAUDE.md/AGENTS.md) - no existing SKILLS_MAP entries changed; 332 existing tests still pass; tsc clean --- packages/autoskills/skills-map.ts | 2 ++ .../projects/with-agents-md/AGENTS.md | 4 ++++ .../projects/with-agents-md/package.json | 1 + .../fixtures/projects/with-both/AGENTS.md | 5 +++++ .../fixtures/projects/with-both/CLAUDE.md | 3 +++ .../fixtures/projects/with-both/package.json | 1 + .../projects/with-claude-md/CLAUDE.md | 4 ++++ .../projects/with-claude-md/package.json | 1 + .../autoskills/tests/fixtures/specs/empty.md | 0 .../tests/fixtures/specs/malformed.md | 11 ++++++++++ .../tests/fixtures/specs/non-english.md | 4 ++++ .../tests/fixtures/specs/only-fences.md | 9 ++++++++ .../tests/fixtures/specs/only-heading.md | 4 ++++ .../tests/fixtures/specs/well-formed.md | 21 +++++++++++++++++++ 14 files changed, 70 insertions(+) create mode 100644 packages/autoskills/tests/fixtures/projects/with-agents-md/AGENTS.md create mode 100644 packages/autoskills/tests/fixtures/projects/with-agents-md/package.json create mode 100644 packages/autoskills/tests/fixtures/projects/with-both/AGENTS.md create mode 100644 packages/autoskills/tests/fixtures/projects/with-both/CLAUDE.md create mode 100644 packages/autoskills/tests/fixtures/projects/with-both/package.json create mode 100644 packages/autoskills/tests/fixtures/projects/with-claude-md/CLAUDE.md create mode 100644 packages/autoskills/tests/fixtures/projects/with-claude-md/package.json create mode 100644 packages/autoskills/tests/fixtures/specs/empty.md create mode 100644 packages/autoskills/tests/fixtures/specs/malformed.md create mode 100644 packages/autoskills/tests/fixtures/specs/non-english.md create mode 100644 packages/autoskills/tests/fixtures/specs/only-fences.md create mode 100644 packages/autoskills/tests/fixtures/specs/only-heading.md create mode 100644 packages/autoskills/tests/fixtures/specs/well-formed.md diff --git a/packages/autoskills/skills-map.ts b/packages/autoskills/skills-map.ts index 55348b77..dee4f37a 100644 --- a/packages/autoskills/skills-map.ts +++ b/packages/autoskills/skills-map.ts @@ -20,6 +20,8 @@ export interface Technology { name: string; detect: DetectConfig; skills: string[]; + aliases?: string[]; + description?: string; } export interface ComboSkill { diff --git a/packages/autoskills/tests/fixtures/projects/with-agents-md/AGENTS.md b/packages/autoskills/tests/fixtures/projects/with-agents-md/AGENTS.md new file mode 100644 index 00000000..18faec4d --- /dev/null +++ b/packages/autoskills/tests/fixtures/projects/with-agents-md/AGENTS.md @@ -0,0 +1,4 @@ +## Stack + +- Astro +- Supabase diff --git a/packages/autoskills/tests/fixtures/projects/with-agents-md/package.json b/packages/autoskills/tests/fixtures/projects/with-agents-md/package.json new file mode 100644 index 00000000..34f9d049 --- /dev/null +++ b/packages/autoskills/tests/fixtures/projects/with-agents-md/package.json @@ -0,0 +1 @@ +{ "name": "fixture", "version": "0.0.0" } diff --git a/packages/autoskills/tests/fixtures/projects/with-both/AGENTS.md b/packages/autoskills/tests/fixtures/projects/with-both/AGENTS.md new file mode 100644 index 00000000..e36abb87 --- /dev/null +++ b/packages/autoskills/tests/fixtures/projects/with-both/AGENTS.md @@ -0,0 +1,5 @@ +## Install + +```bash +pnpm add oxlint +``` diff --git a/packages/autoskills/tests/fixtures/projects/with-both/CLAUDE.md b/packages/autoskills/tests/fixtures/projects/with-both/CLAUDE.md new file mode 100644 index 00000000..5c3e5aaa --- /dev/null +++ b/packages/autoskills/tests/fixtures/projects/with-both/CLAUDE.md @@ -0,0 +1,3 @@ +## Tech Stack + +- React diff --git a/packages/autoskills/tests/fixtures/projects/with-both/package.json b/packages/autoskills/tests/fixtures/projects/with-both/package.json new file mode 100644 index 00000000..34f9d049 --- /dev/null +++ b/packages/autoskills/tests/fixtures/projects/with-both/package.json @@ -0,0 +1 @@ +{ "name": "fixture", "version": "0.0.0" } diff --git a/packages/autoskills/tests/fixtures/projects/with-claude-md/CLAUDE.md b/packages/autoskills/tests/fixtures/projects/with-claude-md/CLAUDE.md new file mode 100644 index 00000000..aa503b90 --- /dev/null +++ b/packages/autoskills/tests/fixtures/projects/with-claude-md/CLAUDE.md @@ -0,0 +1,4 @@ +## Tech Stack + +- React +- Tailwind CSS diff --git a/packages/autoskills/tests/fixtures/projects/with-claude-md/package.json b/packages/autoskills/tests/fixtures/projects/with-claude-md/package.json new file mode 100644 index 00000000..34f9d049 --- /dev/null +++ b/packages/autoskills/tests/fixtures/projects/with-claude-md/package.json @@ -0,0 +1 @@ +{ "name": "fixture", "version": "0.0.0" } diff --git a/packages/autoskills/tests/fixtures/specs/empty.md b/packages/autoskills/tests/fixtures/specs/empty.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/autoskills/tests/fixtures/specs/malformed.md b/packages/autoskills/tests/fixtures/specs/malformed.md new file mode 100644 index 00000000..e47879b9 --- /dev/null +++ b/packages/autoskills/tests/fixtures/specs/malformed.md @@ -0,0 +1,11 @@ +# Notes + +We'll use React and maybe Vue. + +```json +{ "dependencies": { "react": ^19 } +``` + +``` +no-language fence — ignored +``` diff --git a/packages/autoskills/tests/fixtures/specs/non-english.md b/packages/autoskills/tests/fixtures/specs/non-english.md new file mode 100644 index 00000000..326013f8 --- /dev/null +++ b/packages/autoskills/tests/fixtures/specs/non-english.md @@ -0,0 +1,4 @@ +## Tecnologías + +- Next.js +- Tailwind CSS diff --git a/packages/autoskills/tests/fixtures/specs/only-fences.md b/packages/autoskills/tests/fixtures/specs/only-fences.md new file mode 100644 index 00000000..277f2354 --- /dev/null +++ b/packages/autoskills/tests/fixtures/specs/only-fences.md @@ -0,0 +1,9 @@ +# Setup + +```json +{ "dependencies": { "react": "^19", "vue": "^3" } } +``` + +```bash +npm install express +``` diff --git a/packages/autoskills/tests/fixtures/specs/only-heading.md b/packages/autoskills/tests/fixtures/specs/only-heading.md new file mode 100644 index 00000000..9d6d2c4f --- /dev/null +++ b/packages/autoskills/tests/fixtures/specs/only-heading.md @@ -0,0 +1,4 @@ +## Dependencies + +- Astro +- Prisma diff --git a/packages/autoskills/tests/fixtures/specs/well-formed.md b/packages/autoskills/tests/fixtures/specs/well-formed.md new file mode 100644 index 00000000..b3f5c6e6 --- /dev/null +++ b/packages/autoskills/tests/fixtures/specs/well-formed.md @@ -0,0 +1,21 @@ +# Feature Spec + +## Tech Stack + +- React 19 +- Tailwind CSS +- Supabase — auth + db + +## Install + +```bash +pnpm add react tailwindcss @supabase/supabase-js +``` + +```json +{ + "dependencies": { + "next": "^15.0.0" + } +} +``` From 146948c731ad72753e40bb7f8b0e815442669cbd Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 11:44:21 -0500 Subject: [PATCH 02/16] feat(scanner): add markdown-scanner with JSON fence support + test helpers - add scanMarkdown(content, skillsMap) -> MarkdownMatch[] with JSON fence branch (deps + devDeps) - extract fences, skip malformed JSON, skip no-language fences, dedupe by techId - add test helpers: writeMarkdown, readFixtureSpec, parseJsonOutput, buildMarkdownFromParts, mockInstaller - parseJsonOutput uses backward line-walk for nested JSON and top-level arrays - unicode-safe evidence truncation in scanner - 344 tests pass, tsc clean --- packages/autoskills/markdown-scanner.ts | 68 +++++++++++++++++++ packages/autoskills/tests/helpers.test.ts | 58 ++++++++++++++++ packages/autoskills/tests/helpers.ts | 61 ++++++++++++++++- .../autoskills/tests/markdown-scanner.test.ts | 35 ++++++++++ 4 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 packages/autoskills/markdown-scanner.ts create mode 100644 packages/autoskills/tests/helpers.test.ts create mode 100644 packages/autoskills/tests/markdown-scanner.test.ts diff --git a/packages/autoskills/markdown-scanner.ts b/packages/autoskills/markdown-scanner.ts new file mode 100644 index 00000000..2eac4be7 --- /dev/null +++ b/packages/autoskills/markdown-scanner.ts @@ -0,0 +1,68 @@ +import type { Technology } from "./skills-map.ts"; + +export interface MarkdownMatch { + techId: string; + source: "code-fence" | "stack-heading"; + evidence: string; +} + +interface CodeFence { lang: string; body: string; line: number } + +function extractFences(content: string): CodeFence[] { + const fences: CodeFence[] = []; + const lines = content.split("\n"); + let i = 0; + while (i < lines.length) { + const m = lines[i].match(/^```(\S+)?\s*$/); + if (m) { + const lang = (m[1] ?? "").toLowerCase(); + const start = i + 1; + i++; + while (i < lines.length && !/^```\s*$/.test(lines[i])) i++; + fences.push({ lang, body: lines.slice(start, i).join("\n"), line: start }); + } + i++; + } + return fences; +} + +function packagesFromJsonFence(body: string): string[] { + try { + const obj = JSON.parse(body); + const deps = { ...(obj.dependencies ?? {}), ...(obj.devDependencies ?? {}) }; + return Object.keys(deps); + } catch { + return []; + } +} + +function matchByPackages(pkgs: string[], map: readonly Technology[]): string[] { + const ids: string[] = []; + for (const tech of map) { + const required = tech.detect.packages ?? []; + if (required.length && required.some(p => pkgs.includes(p))) ids.push(tech.id); + } + return ids; +} + +export function scanMarkdown(content: string, skillsMap: readonly Technology[]): MarkdownMatch[] { + const seen = new Set(); + const matches: MarkdownMatch[] = []; + const pushMatch = (techId: string, source: MarkdownMatch["source"], evidence: string) => { + if (seen.has(techId)) return; + seen.add(techId); + matches.push({ techId, source, evidence }); + }; + + for (const fence of extractFences(content)) { + if (fence.lang === "json") { + const pkgs = packagesFromJsonFence(fence.body); + for (const id of matchByPackages(pkgs, skillsMap)) { + pushMatch(id, "code-fence", [...fence.body].slice(0, 80).join("")); + } + } + // bash / yaml / ruby / headings added in later tasks (T6-T9) + } + + return matches; +} diff --git a/packages/autoskills/tests/helpers.test.ts b/packages/autoskills/tests/helpers.test.ts new file mode 100644 index 00000000..816a98a9 --- /dev/null +++ b/packages/autoskills/tests/helpers.test.ts @@ -0,0 +1,58 @@ +import { describe, it } from "node:test"; +import { equal, ok, deepEqual } from "node:assert/strict"; +import { resolve } from "node:path"; +import { readFileSync, existsSync } from "node:fs"; +import { + useTmpDir, + writeMarkdown, + readFixtureSpec, + parseJsonOutput, + buildMarkdownFromParts, + mockInstaller, +} from "./helpers.ts"; + +describe("helpers (new)", () => { + const tmp = useTmpDir(); + + it("writeMarkdown writes file and returns absolute path", () => { + const p = writeMarkdown(tmp.path, "spec.md", "# hi\n"); + ok(existsSync(p)); + equal(readFileSync(p, "utf-8"), "# hi\n"); + equal(p, resolve(tmp.path, "spec.md")); + }); + + it("readFixtureSpec reads from tests/fixtures/specs", () => { + const content = readFixtureSpec("empty.md"); + equal(content, ""); + }); + + it("parseJsonOutput extracts last JSON object from stdout", () => { + const stdout = "→ scanning...\n{\"ok\":true}\n"; + deepEqual(parseJsonOutput(stdout), { ok: true }); + }); + + it("parseJsonOutput parses nested objects", () => { + deepEqual(parseJsonOutput("noise\n{\"a\":{\"b\":1}}\n"), { a: { b: 1 } }); + }); + + it("parseJsonOutput parses top-level arrays", () => { + deepEqual(parseJsonOutput("noise\n[{\"id\":\"react\"},{\"id\":\"ts\"}]\n"), [{ id: "react" }, { id: "ts" }]); + }); + + it("buildMarkdownFromParts composes fences and headings", () => { + const md = buildMarkdownFromParts({ + fences: [{ lang: "bash", body: "pnpm add react" }], + headings: [{ level: 2, title: "Tech Stack", bullets: ["React"] }], + }); + ok(md.includes("```bash")); + ok(md.includes("## Tech Stack")); + ok(md.includes("- React")); + }); + + it("mockInstaller returns controllable result", async () => { + const stub = mockInstaller({ success: true }); + const result = await stub.installSkill("any/path", []); + equal(result.success, true); + equal(stub.calls.length, 1); + }); +}); diff --git a/packages/autoskills/tests/helpers.ts b/packages/autoskills/tests/helpers.ts index 35883887..74d65d8a 100644 --- a/packages/autoskills/tests/helpers.ts +++ b/packages/autoskills/tests/helpers.ts @@ -1,8 +1,11 @@ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { join, dirname } from "node:path"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; import { beforeEach, afterEach } from "node:test"; +const HELPERS_DIR = dirname(fileURLToPath(import.meta.url)); + export function useTmpDir(prefix: string = "autoskills-"): { path: string } { const ctx = { path: "" }; beforeEach(() => { @@ -41,3 +44,57 @@ export function addWorkspace( mkdirSync(fullPath, { recursive: true }); writeFileSync(join(fullPath, "package.json"), JSON.stringify(packageJson)); } + +export function writeMarkdown(dir: string, name: string, content: string): string { + const p = resolve(dir, name); + writeFileSync(p, content); + return p; +} + +export function readFixtureSpec(name: string): string { + return readFileSync(join(HELPERS_DIR, "fixtures", "specs", name), "utf-8"); +} + +export function parseJsonOutput(stdout: string): unknown { + const trimmed = stdout.trimEnd(); + const lines = trimmed.split("\n"); + for (let j = lines.length - 1; j >= 0; j--) { + const t = lines[j].trimStart(); + if (t.startsWith("{") || t.startsWith("[")) { + return JSON.parse(lines.slice(j).join("\n")); + } + } + throw new Error("no JSON found in stdout"); +} + +export interface Fence { lang: string; body: string } +export interface Heading { level: 1 | 2 | 3; title: string; bullets: string[] } + +export function buildMarkdownFromParts(opts: { fences?: Fence[]; headings?: Heading[] }): string { + const parts: string[] = []; + for (const h of opts.headings ?? []) { + parts.push(`${"#".repeat(h.level)} ${h.title}`, "", ...h.bullets.map(b => `- ${b}`), ""); + } + for (const f of opts.fences ?? []) { + parts.push("```" + f.lang, f.body, "```", ""); + } + return parts.join("\n"); +} + +export interface MockInstallerResult { success: boolean; output?: string; stderr?: string } +export interface MockInstaller { + installSkill: (path: string, agents: string[]) => Promise; + calls: Array<{ path: string; agents: string[] }>; +} + +export function mockInstaller(behavior: MockInstallerResult | ((path: string) => MockInstallerResult)): MockInstaller { + const calls: Array<{ path: string; agents: string[] }> = []; + return { + calls, + async installSkill(path, agents) { + calls.push({ path, agents }); + const r = typeof behavior === "function" ? behavior(path) : behavior; + return { ...r, command: "mock", exitCode: r.success ? 0 : 1, output: r.output ?? "", stderr: r.stderr ?? "" }; + }, + }; +} diff --git a/packages/autoskills/tests/markdown-scanner.test.ts b/packages/autoskills/tests/markdown-scanner.test.ts new file mode 100644 index 00000000..b0a9de8a --- /dev/null +++ b/packages/autoskills/tests/markdown-scanner.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from "node:test"; +import { deepEqual, equal } from "node:assert/strict"; +import { scanMarkdown } from "../markdown-scanner.ts"; +import { SKILLS_MAP } from "../skills-map.ts"; + +describe("scanMarkdown — json fences", () => { + it("detects tech from dependencies block", () => { + const md = "```json\n{\"dependencies\":{\"react\":\"^19\"}}\n```"; + const matches = scanMarkdown(md, SKILLS_MAP); + equal(matches.length, 1); + equal(matches[0].techId, "react"); + equal(matches[0].source, "code-fence"); + }); + + it("detects tech from devDependencies block", () => { + const md = "```json\n{\"devDependencies\":{\"typescript\":\"^5\"}}\n```"; + const matches = scanMarkdown(md, SKILLS_MAP); + equal(matches[0].techId, "typescript"); + }); + + it("skips malformed JSON without throwing", () => { + const md = "```json\n{\"dependencies\":{\"react\":^19}\n```"; + deepEqual(scanMarkdown(md, SKILLS_MAP), []); + }); + + it("returns [] for empty fence", () => { + const md = "```json\n\n```"; + deepEqual(scanMarkdown(md, SKILLS_MAP), []); + }); + + it("returns [] for no-language fence", () => { + const md = "```\n{\"dependencies\":{\"react\":\"^19\"}}\n```"; + deepEqual(scanMarkdown(md, SKILLS_MAP), []); + }); +}); From ab1a7112c17201f881c50f09abb9e176f45543b4 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 12:33:57 -0500 Subject: [PATCH 03/16] feat(scanner): add shell/yaml/toml/ruby fences + stack headings with alias matching - shell fences (bash/sh/shell/zsh): extract from npm/pnpm/yarn/bun add/install with \b anchor + shell-operator chain stop - yaml/toml fences: match against detect.configFileContent patterns - ruby/gemfile fences: match `gem ''` against detect.gems - stack headings (h1-h3, EN + Tecnologias): parse bullets, normalize, match via name + aliases - seed aliases on nextjs, vue, svelte, tailwind, typescript, node - 363 tests pass, tsc clean --- packages/autoskills/markdown-scanner.ts | 126 +++++++++++++++++- packages/autoskills/skills-map.ts | 6 + .../autoskills/tests/markdown-scanner.test.ts | 117 ++++++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) diff --git a/packages/autoskills/markdown-scanner.ts b/packages/autoskills/markdown-scanner.ts index 2eac4be7..8fd8ebc4 100644 --- a/packages/autoskills/markdown-scanner.ts +++ b/packages/autoskills/markdown-scanner.ts @@ -36,6 +36,22 @@ function packagesFromJsonFence(body: string): string[] { } } +function packagesFromShellFence(body: string): string[] { + const pkgs: string[] = []; + const re = /\b(?:npm|pnpm|yarn|bun)\s+(?:add|install|i)\s+([^\n]+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(body))) { + // Stop at shell operators that chain another command + const commandTail = m[1].split(/\s+(?:&&|\|\||;|\|)\s*/)[0]; + for (const tok of commandTail.split(/\s+/)) { + if (!tok) continue; + if (tok.startsWith("-")) continue; // skip flags like --save-dev + pkgs.push(tok); + } + } + return pkgs; +} + function matchByPackages(pkgs: string[], map: readonly Technology[]): string[] { const ids: string[] = []; for (const tech of map) { @@ -45,6 +61,85 @@ function matchByPackages(pkgs: string[], map: readonly Technology[]): string[] { return ids; } +function matchByConfigContent(body: string, map: readonly Technology[]): string[] { + const ids: string[] = []; + for (const tech of map) { + const raw = tech.detect.configFileContent; + if (!raw) continue; + const blocks = Array.isArray(raw) ? raw : [raw]; + const matched = blocks.some(block => + block.patterns.length > 0 && block.patterns.some(p => body.includes(p)) + ); + if (matched) ids.push(tech.id); + } + return ids; +} + +function gemsFromRubyFence(body: string): string[] { + const gems: string[] = []; + const re = /^\s*gem\s+['"]([^'"]+)['"]/gm; + let m: RegExpExecArray | null; + while ((m = re.exec(body))) { + gems.push(m[1]); + } + return gems; +} + +function matchByGems(gems: string[], map: readonly Technology[]): string[] { + const ids: string[] = []; + for (const tech of map) { + const required = tech.detect.gems ?? []; + if (required.length && required.some(g => gems.includes(g))) ids.push(tech.id); + } + return ids; +} + +const HEADING_RE = /^(#{1,3})\s+(Tech Stack|Stack|Dependencies|Built With|Technologies|Tecnolog[ií]as)\s*$/i; + +function extractStackBlocks(content: string): { bullets: string[]; evidence: string }[] { + const lines = content.split("\n"); + const blocks: { bullets: string[]; evidence: string }[] = []; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(HEADING_RE); + if (!m) continue; + const level = m[1].length; + const bullets: string[] = []; + let j = i + 1; + for (; j < lines.length; j++) { + const hm = lines[j].match(/^(#{1,6})\s+/); + if (hm && hm[1].length <= level) break; + const bm = lines[j].match(/^\s*[-*+]\s+(.+)$/); + if (bm) bullets.push(bm[1]); + } + blocks.push({ bullets, evidence: lines[i] }); + i = j - 1; + } + return blocks; +} + +function normalizeBullet(raw: string): string { + // 1. Drop parenthetical annotations + // 2. Keep only the part before em-dash / en-dash / " - " + // 3. Strip trailing version tokens (e.g. " 19", " 1.0.0") + return raw + .replace(/\([^)]*\)/g, "") + .split(/\s+[—–-]\s+/)[0] + .trim() + .replace(/\s+\d[\w.]*$/, "") + .trim(); +} + +function matchByName(phrase: string, map: readonly Technology[]): string | null { + if (!phrase) return null; + if (/^\d/.test(phrase)) return null; // version-only bullets + const low = phrase.toLowerCase(); + for (const tech of map) { + if (tech.name.toLowerCase() === low) return tech.id; + if (tech.aliases?.some(a => a.toLowerCase() === low)) return tech.id; + } + return null; +} + export function scanMarkdown(content: string, skillsMap: readonly Technology[]): MarkdownMatch[] { const seen = new Set(); const matches: MarkdownMatch[] = []; @@ -54,6 +149,15 @@ export function scanMarkdown(content: string, skillsMap: readonly Technology[]): matches.push({ techId, source, evidence }); }; + for (const block of extractStackBlocks(content)) { + for (const bullet of block.bullets) { + const id = matchByName(normalizeBullet(bullet), skillsMap); + if (id) { + pushMatch(id, "stack-heading", [...("- " + bullet)].slice(0, 80).join("")); + } + } + } + for (const fence of extractFences(content)) { if (fence.lang === "json") { const pkgs = packagesFromJsonFence(fence.body); @@ -61,7 +165,27 @@ export function scanMarkdown(content: string, skillsMap: readonly Technology[]): pushMatch(id, "code-fence", [...fence.body].slice(0, 80).join("")); } } - // bash / yaml / ruby / headings added in later tasks (T6-T9) + if (["bash", "sh", "shell", "zsh"].includes(fence.lang)) { + const pkgs = packagesFromShellFence(fence.body); + for (const id of matchByPackages(pkgs, skillsMap)) { + const firstLine = fence.body.split("\n")[0]; + pushMatch(id, "code-fence", [...firstLine].slice(0, 80).join("")); + } + } + + if (["yaml", "yml", "toml"].includes(fence.lang)) { + for (const id of matchByConfigContent(fence.body, skillsMap)) { + pushMatch(id, "code-fence", [...fence.body].slice(0, 80).join("")); + } + } + + if (["ruby", "gemfile"].includes(fence.lang)) { + const gems = gemsFromRubyFence(fence.body); + for (const id of matchByGems(gems, skillsMap)) { + pushMatch(id, "code-fence", [...fence.body].slice(0, 80).join("")); + } + } + // dedupe + precedence finalized in T9 } return matches; diff --git a/packages/autoskills/skills-map.ts b/packages/autoskills/skills-map.ts index dee4f37a..8b527726 100644 --- a/packages/autoskills/skills-map.ts +++ b/packages/autoskills/skills-map.ts @@ -48,6 +48,7 @@ export const SKILLS_MAP: Technology[] = [ { id: "nextjs", name: "Next.js", + aliases: ["NextJS", "Next"], detect: { packages: ["next"], configFiles: ["next.config.js", "next.config.mjs", "next.config.ts"], @@ -61,6 +62,7 @@ export const SKILLS_MAP: Technology[] = [ { id: "vue", name: "Vue", + aliases: ["Vue.js"], detect: { packages: ["vue"], }, @@ -91,6 +93,7 @@ export const SKILLS_MAP: Technology[] = [ { id: "svelte", name: "Svelte", + aliases: ["SvelteKit", "Svelte Kit"], detect: { packages: ["svelte", "@sveltejs/kit"], configFiles: ["svelte.config.js"], @@ -128,6 +131,7 @@ export const SKILLS_MAP: Technology[] = [ { id: "tailwind", name: "Tailwind CSS", + aliases: ["TailwindCSS", "tailwindcss", "Tailwind"], detect: { packages: ["tailwindcss", "@tailwindcss/vite"], configFiles: ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.cjs"], @@ -145,6 +149,7 @@ export const SKILLS_MAP: Technology[] = [ { id: "typescript", name: "TypeScript", + aliases: ["TS"], detect: { packages: ["typescript"], configFiles: ["tsconfig.json"], @@ -585,6 +590,7 @@ export const SKILLS_MAP: Technology[] = [ { id: "node", name: "Node.js", + aliases: ["Node", "NodeJS"], detect: { configFiles: ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", ".nvmrc", ".node-version"], }, diff --git a/packages/autoskills/tests/markdown-scanner.test.ts b/packages/autoskills/tests/markdown-scanner.test.ts index b0a9de8a..b50ea26c 100644 --- a/packages/autoskills/tests/markdown-scanner.test.ts +++ b/packages/autoskills/tests/markdown-scanner.test.ts @@ -33,3 +33,120 @@ describe("scanMarkdown — json fences", () => { deepEqual(scanMarkdown(md, SKILLS_MAP), []); }); }); + +describe("scanMarkdown — shell fences", () => { + it("extracts packages from 'pnpm add'", () => { + const md = "```bash\npnpm add react tailwindcss\n```"; + const matches = scanMarkdown(md, SKILLS_MAP); + const ids = matches.map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind"].sort()); + }); + + it("handles npm install / yarn add / bun add / npm i", () => { + for (const cmd of ["npm install react", "yarn add react", "bun add react", "npm i react"]) { + const matches = scanMarkdown("```sh\n" + cmd + "\n```", SKILLS_MAP); + equal(matches[0]?.techId, "react"); + } + }); + + it("accepts sh / shell / zsh as aliases for bash", () => { + for (const lang of ["sh", "shell", "zsh"]) { + const matches = scanMarkdown("```" + lang + "\npnpm add react\n```", SKILLS_MAP); + equal(matches[0]?.techId, "react"); + } + }); + + it("rejects pseudo-command prefix (F-002 regression: requires word boundary)", () => { + const md = "```bash\nxnpm install react\n```"; + deepEqual(scanMarkdown(md, SKILLS_MAP), []); + }); + + it("stops at shell operators when chaining commands (F-003 regression)", () => { + const md = "```bash\nnpm install react && cd apps/web\n```"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId); + deepEqual(ids, ["react"]); + }); + + it("stops at semicolon chain", () => { + const md = "```bash\nnpm install react ; npm run build\n```"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId); + deepEqual(ids, ["react"]); + }); +}); + +describe("scanMarkdown — yaml/toml/ruby fences", () => { + it("detects flutter from a yaml fence containing 'flutter:'", () => { + const md = "```yaml\nname: my_app\nflutter:\n uses-material-design: true\n```"; + const matches = scanMarkdown(md, SKILLS_MAP); + const ids = matches.map(m => m.techId); + equal(ids.includes("flutter"), true); + equal(matches[0].source, "code-fence"); + }); + + it("detects cloudflare-durable-objects from a toml fence containing 'durable_objects'", () => { + const md = "```toml\nname = \"my-worker\"\n[durable_objects]\nbindings = []\n```"; + const matches = scanMarkdown(md, SKILLS_MAP); + const ids = matches.map(m => m.techId); + equal(ids.includes("cloudflare-durable-objects"), true); + }); + + it("detects rails from a ruby fence with gem 'rails'", () => { + const md = "```ruby\nsource 'https://rubygems.org'\ngem 'rails', '~> 7.1'\ngem 'pg'\n```"; + const matches = scanMarkdown(md, SKILLS_MAP); + const ids = matches.map(m => m.techId); + equal(ids.includes("rails"), true); + equal(ids.includes("postgres-ruby"), true); + }); + + it("returns [] for a no-language fence with YAML-looking content", () => { + const md = "```\nflutter:\n uses-material-design: true\n```"; + deepEqual(scanMarkdown(md, SKILLS_MAP), []); + }); + + it("does not throw on an unterminated yaml fence", () => { + const md = "```yaml\nflutter:\n uses-material-design: true"; + // extractFences reads to EOF; the important thing is no exception is thrown + const result = scanMarkdown(md, SKILLS_MAP); + equal(Array.isArray(result), true); + }); +}); + +describe("scanMarkdown — stack headings", () => { + it("detects bullets under 'Tech Stack' heading", () => { + const md = "## Tech Stack\n\n- React\n- Tailwind CSS\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind"].sort()); + }); + + it("accepts Stack | Dependencies | Built With | Technologies | Tecnologías", () => { + for (const title of ["Stack", "Dependencies", "Built With", "Technologies", "Tecnologías"]) { + const md = `## ${title}\n- React\n`; + equal(scanMarkdown(md, SKILLS_MAP)[0]?.techId, "react"); + } + }); + + it("is case-insensitive", () => { + equal(scanMarkdown("### TECH STACK\n- React\n", SKILLS_MAP)[0]?.techId, "react"); + }); + + it("accepts h1, h2, h3 but not h4", () => { + equal(scanMarkdown("#### Tech Stack\n- React\n", SKILLS_MAP).length, 0); + }); + + it("strips version numbers and parens from bullets", () => { + equal(scanMarkdown("## Stack\n- React 19 (UI library)\n", SKILLS_MAP)[0]?.techId, "react"); + }); + + it("skips version-only bullets", () => { + deepEqual(scanMarkdown("## Stack\n- 19\n- 1.0\n", SKILLS_MAP), []); + }); + + it("matches via aliases (Next.js -> nextjs)", () => { + equal(scanMarkdown("## Stack\n- Next.js\n", SKILLS_MAP)[0]?.techId, "nextjs"); + }); + + it("does NOT match prose mid-paragraph", () => { + const md = "# Intro\n\nOur tech stack includes React but we also use Vue.\n"; + deepEqual(scanMarkdown(md, SKILLS_MAP), []); + }); +}); From b0d0fa08ffd90d947ca22976d01edfd1bf094df8 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 13:13:38 -0500 Subject: [PATCH 04/16] feat(lib): add markdown source loader + merger, scanner edge-case tests - add loadMarkdownSources({fromSpec,scanDocs,projectDir}) to lib.ts: reads --from-spec, auto-discovers CLAUDE.md/AGENTS.md, dedupes by absolute path, throws "spec file not found" on missing fromSpec - add mergeMarkdownDetections(coreIds,matches): union preserving core order then scanner order - scanner: 5 edge-case tests (precedence, empty, 100KB, unterminated fence, cross-fence dedup); no scanner logic changes - 382 tests pass, tsc clean --- packages/autoskills/lib.ts | 56 +++++++++++++++ .../autoskills/tests/load-md-sources.test.ts | 68 +++++++++++++++++++ .../autoskills/tests/markdown-scanner.test.ts | 35 +++++++++- packages/autoskills/tests/merge-md.test.ts | 40 +++++++++++ 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 packages/autoskills/tests/load-md-sources.test.ts create mode 100644 packages/autoskills/tests/merge-md.test.ts diff --git a/packages/autoskills/lib.ts b/packages/autoskills/lib.ts index 535c74eb..6c6d5491 100644 --- a/packages/autoskills/lib.ts +++ b/packages/autoskills/lib.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path"; import { homedir } from "node:os"; import type { Technology, ComboSkill, ConfigFileContentBlock } from "./skills-map.ts"; +import type { MarkdownMatch } from "./markdown-scanner.ts"; export { SKILLS_MAP, @@ -657,3 +658,58 @@ export function collectSkills({ return skills; } + +// ── Markdown Sources ───────────────────────────────────────── + +export interface MarkdownSource { + content: string; + path: string; +} + +export function loadMarkdownSources(args: { + fromSpec?: string; + scanDocs?: boolean; + projectDir: string; +}): MarkdownSource[] { + const out: MarkdownSource[] = []; + const seen = new Set(); + + if (args.fromSpec) { + const p = resolve(args.projectDir, args.fromSpec); + if (!existsSync(p)) { + throw new Error(`spec file not found: ${args.fromSpec}`); + } + out.push({ path: p, content: readFileSync(p, "utf-8") }); + seen.add(p); + } + + if (args.scanDocs) { + for (const name of ["CLAUDE.md", "AGENTS.md"]) { + const p = resolve(args.projectDir, name); + if (existsSync(p) && !seen.has(p)) { + out.push({ path: p, content: readFileSync(p, "utf-8") }); + seen.add(p); + } + } + } + + return out; +} + +// ── Markdown Detection Merge ───────────────────────────────── + +/** Assumes `coreIds` is already deduplicated; preserves core order, then appends new scanner matches in scanner order. */ +export function mergeMarkdownDetections( + coreIds: string[], + matches: MarkdownMatch[], +): string[] { + const seen = new Set(coreIds); + const out = [...coreIds]; + for (const m of matches) { + if (!seen.has(m.techId)) { + out.push(m.techId); + seen.add(m.techId); + } + } + return out; +} diff --git a/packages/autoskills/tests/load-md-sources.test.ts b/packages/autoskills/tests/load-md-sources.test.ts new file mode 100644 index 00000000..7e725480 --- /dev/null +++ b/packages/autoskills/tests/load-md-sources.test.ts @@ -0,0 +1,68 @@ +import { describe, it } from "node:test"; +import { equal, ok, throws } from "node:assert/strict"; +import { useTmpDir, writeFile } from "./helpers.ts"; +import { loadMarkdownSources } from "../lib.ts"; + +describe("loadMarkdownSources", () => { + const tmp = useTmpDir(); + + it("reads --from-spec file", () => { + writeFile(tmp.path, "spec.md", "# hi"); + const sources = loadMarkdownSources({ fromSpec: "spec.md", projectDir: tmp.path }); + equal(sources.length, 1); + equal(sources[0].content, "# hi"); + }); + + it("throws spec-file-not-found on missing path", () => { + throws( + () => loadMarkdownSources({ fromSpec: "nope.md", projectDir: tmp.path }), + /spec file not found/, + ); + }); + + it("resolves --from-spec relative paths against projectDir", () => { + writeFile(tmp.path, "nested/spec.md", "ok"); + const sources = loadMarkdownSources({ fromSpec: "nested/spec.md", projectDir: tmp.path }); + equal(sources.length, 1); + equal(sources[0].content, "ok"); + }); + + it("scan-docs reads CLAUDE.md only when present", () => { + writeFile(tmp.path, "CLAUDE.md", "# claude"); + const sources = loadMarkdownSources({ scanDocs: true, projectDir: tmp.path }); + equal(sources.length, 1); + equal(sources[0].content, "# claude"); + }); + + it("scan-docs reads AGENTS.md only when present", () => { + writeFile(tmp.path, "AGENTS.md", "# agents"); + const sources = loadMarkdownSources({ scanDocs: true, projectDir: tmp.path }); + equal(sources.length, 1); + }); + + it("scan-docs reads both CLAUDE.md and AGENTS.md when both present", () => { + writeFile(tmp.path, "CLAUDE.md", "# c"); + writeFile(tmp.path, "AGENTS.md", "# a"); + const sources = loadMarkdownSources({ scanDocs: true, projectDir: tmp.path }); + equal(sources.length, 2); + }); + + it("scan-docs returns [] when neither CLAUDE.md nor AGENTS.md exists", () => { + const sources = loadMarkdownSources({ scanDocs: true, projectDir: tmp.path }); + equal(sources.length, 0); + }); + + it("combines fromSpec + scanDocs without duplicating an auto-discovered file also passed explicitly", () => { + writeFile(tmp.path, "CLAUDE.md", "# c"); + writeFile(tmp.path, "extra.md", "# e"); + const sources = loadMarkdownSources({ fromSpec: "extra.md", scanDocs: true, projectDir: tmp.path }); + equal(sources.length, 2); + }); + + it("deduplicates when fromSpec explicitly points at CLAUDE.md while scanDocs is set", () => { + writeFile(tmp.path, "CLAUDE.md", "# c"); + const sources = loadMarkdownSources({ fromSpec: "CLAUDE.md", scanDocs: true, projectDir: tmp.path }); + equal(sources.length, 1); + ok(sources[0].path.endsWith("CLAUDE.md")); + }); +}); diff --git a/packages/autoskills/tests/markdown-scanner.test.ts b/packages/autoskills/tests/markdown-scanner.test.ts index b50ea26c..bf5ad7d6 100644 --- a/packages/autoskills/tests/markdown-scanner.test.ts +++ b/packages/autoskills/tests/markdown-scanner.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "node:test"; -import { deepEqual, equal } from "node:assert/strict"; +import { deepEqual, equal, ok } from "node:assert/strict"; import { scanMarkdown } from "../markdown-scanner.ts"; import { SKILLS_MAP } from "../skills-map.ts"; @@ -150,3 +150,36 @@ describe("scanMarkdown — stack headings", () => { deepEqual(scanMarkdown(md, SKILLS_MAP), []); }); }); + +describe("scanMarkdown — dedupe and edge cases", () => { + it("one match when tech appears in both fence and heading (source = stack-heading, headings scanned first)", () => { + const md = "## Stack\n- React\n\n```bash\npnpm add react\n```\n"; + const matches = scanMarkdown(md, SKILLS_MAP); + equal(matches.length, 1); + equal(matches[0].techId, "react"); + equal(matches[0].source, "stack-heading"); + }); + + it("returns [] for empty input", () => { + deepEqual(scanMarkdown("", SKILLS_MAP), []); + }); + + it("does not throw on 100KB input and returns no matches for non-tech prose", () => { + const big = "lorem ipsum ".repeat(10000); + const matches = scanMarkdown(big, SKILLS_MAP); + equal(matches.length, 0); + }); + + it("does not throw on unterminated fence", () => { + const md = "```json\n{\"dependencies\":{\"react\":\"^19\"}}\n"; + const matches = scanMarkdown(md, SKILLS_MAP); + ok(Array.isArray(matches)); + }); + + it("deduplicates same tech appearing in two separate fences", () => { + const md = "```bash\npnpm add react\n```\n```json\n{\"dependencies\":{\"react\":\"^19\"}}\n```"; + const matches = scanMarkdown(md, SKILLS_MAP); + equal(matches.length, 1); + equal(matches[0].techId, "react"); + }); +}); diff --git a/packages/autoskills/tests/merge-md.test.ts b/packages/autoskills/tests/merge-md.test.ts new file mode 100644 index 00000000..1ccb5261 --- /dev/null +++ b/packages/autoskills/tests/merge-md.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from "node:test"; +import { deepEqual } from "node:assert/strict"; +import { mergeMarkdownDetections } from "../lib.ts"; + +describe("mergeMarkdownDetections", () => { + it("union with no duplicates, preserves core order then scanner order", () => { + const core = ["react", "typescript"]; + const matches = [ + { techId: "tailwind", source: "code-fence" as const, evidence: "" }, + { techId: "react", source: "stack-heading" as const, evidence: "" }, + ]; + deepEqual(mergeMarkdownDetections(core, matches), ["react", "typescript", "tailwind"]); + }); + + it("returns core unchanged when matches empty", () => { + deepEqual(mergeMarkdownDetections(["react"], []), ["react"]); + }); + + it("returns scanner ids when core empty", () => { + const matches = [{ techId: "react", source: "code-fence" as const, evidence: "" }]; + deepEqual(mergeMarkdownDetections([], matches), ["react"]); + }); + + it("preserves scanner order for multiple new ids", () => { + const matches = [ + { techId: "tailwind", source: "code-fence" as const, evidence: "" }, + { techId: "nextjs", source: "code-fence" as const, evidence: "" }, + { techId: "vue", source: "stack-heading" as const, evidence: "" }, + ]; + deepEqual(mergeMarkdownDetections([], matches), ["tailwind", "nextjs", "vue"]); + }); + + it("deduplicates scanner matches among themselves", () => { + const matches = [ + { techId: "react", source: "code-fence" as const, evidence: "" }, + { techId: "react", source: "stack-heading" as const, evidence: "" }, + ]; + deepEqual(mergeMarkdownDetections([], matches), ["react"]); + }); +}); From 8d0c23458e2b85790eb27cd6b764b643e311a300 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 14:08:10 -0500 Subject: [PATCH 05/16] feat(cli): wire --from-spec/--scan-docs + add cli-json serializers - main.ts: parse --from-spec and --scan-docs; gate loadMarkdownSources+scanMarkdown+mergeMarkdownDetections behind opt-in; shadow detected/combos/isFrontend defaulting to core.*; parseArgs validates --from-spec arg - add cli-json.ts: serializeList/DryRun/Install/Error; ListJson hides detect; aliases defensively copied - REGRESSION guard: default autoskills ignores CLAUDE.md - 400 tests pass, tsc clean --- packages/autoskills/cli-json.ts | 104 ++++++++++++++ packages/autoskills/main.ts | 70 ++++++++-- .../autoskills/tests/cli-from-spec.test.ts | 65 +++++++++ packages/autoskills/tests/cli-json.test.ts | 129 ++++++++++++++++++ 4 files changed, 360 insertions(+), 8 deletions(-) create mode 100644 packages/autoskills/cli-json.ts create mode 100644 packages/autoskills/tests/cli-from-spec.test.ts create mode 100644 packages/autoskills/tests/cli-json.test.ts diff --git a/packages/autoskills/cli-json.ts b/packages/autoskills/cli-json.ts new file mode 100644 index 00000000..8cef70cc --- /dev/null +++ b/packages/autoskills/cli-json.ts @@ -0,0 +1,104 @@ +import type { Technology, ComboSkill } from "./skills-map.ts"; +import { SKILLS_MAP, COMBO_SKILLS_MAP, FRONTEND_BONUS_SKILLS } from "./skills-map.ts"; + +// ── list ──────────────────────────────────────────────────── + +export interface ListJsonTechnology { + id: string; + name: string; + aliases: string[]; + description?: string; + skills: string[]; +} + +export interface ListJsonCombo { + id: string; + name: string; + requires: string[]; + skills: string[]; +} + +export interface ListJson { + version: string; + technologies: ListJsonTechnology[]; + combos: ListJsonCombo[]; + frontend_bonus: string[]; +} + +export function serializeList(args: { version: string; filter?: string }): ListJson { + const filter = args.filter?.trim().toLowerCase(); + const techs = filter + ? SKILLS_MAP.filter(t => + t.id.toLowerCase() === filter || + t.name.toLowerCase() === filter || + (t.aliases?.some(a => a.toLowerCase() === filter) ?? false), + ) + : SKILLS_MAP; + return { + version: args.version, + technologies: techs.map(t => ({ + id: t.id, + name: t.name, + aliases: [...(t.aliases ?? [])], + description: t.description, + skills: [...t.skills], + })), + combos: COMBO_SKILLS_MAP.map(c => ({ + id: c.id, + name: c.name, + requires: [...c.requires], + skills: [...c.skills], + })), + frontend_bonus: [...FRONTEND_BONUS_SKILLS], + }; +} + +// ── dry-run ───────────────────────────────────────────────── + +export interface DryRunSkill { + id: string; + path: string; + source_tech: string; + installed: boolean; +} + +export interface DryRunJson { + detected_technologies: string[]; + detected_combos: string[]; + is_frontend: boolean; + skills_resolved: DryRunSkill[]; + agents_detected: string[]; +} + +export function serializeDryRun(data: DryRunJson): DryRunJson { + return data; +} + +// ── install ───────────────────────────────────────────────── + +export interface InstallJson { + installed: { id: string; path: string }[]; + failed: { id: string; error: string }[]; + agents: string[]; +} + +export function serializeInstall(data: InstallJson): InstallJson { + return data; +} + +// ── error ─────────────────────────────────────────────────── + +export interface ErrorEnvelope { + code: string; + message: string; + hint?: string; + details?: Record; +} + +export interface ErrorJson { + error: ErrorEnvelope; +} + +export function serializeError(err: ErrorEnvelope): ErrorJson { + return { error: err }; +} diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index e645ac64..96b19126 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -2,8 +2,18 @@ import { resolve, dirname, join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { detectTechnologies, collectSkills, detectAgents, getInstalledSkillNames } from "./lib.ts"; +import { + detectTechnologies, + detectCombos, + collectSkills, + detectAgents, + getInstalledSkillNames, + loadMarkdownSources, + mergeMarkdownDetections, +} from "./lib.ts"; import type { SkillEntry, Technology, ComboSkill } from "./lib.ts"; +import { scanMarkdown } from "./markdown-scanner.ts"; +import { SKILLS_MAP } from "./skills-map.ts"; import { log, write, @@ -48,6 +58,8 @@ interface CliArgs { verbose: boolean; help: boolean; agents: string[]; + fromSpec?: string; + scanDocs: boolean; } function parseArgs(): CliArgs { @@ -60,12 +72,23 @@ function parseArgs(): CliArgs { agents.push(args[i]); } } + const fromSpecIdx = args.findIndex((a) => a === "--from-spec"); + let fromSpec: string | undefined; + if (fromSpecIdx !== -1) { + const next = args[fromSpecIdx + 1]; + if (!next || next.startsWith("-")) { + throw new Error("--from-spec requires a path argument"); + } + fromSpec = next; + } return { autoYes: args.includes("-y") || args.includes("--yes"), dryRun: args.includes("--dry-run"), verbose: args.includes("--verbose") || args.includes("-v"), help: args.includes("--help") || args.includes("-h"), agents, + fromSpec, + scanDocs: args.includes("--scan-docs"), }; } @@ -80,11 +103,13 @@ function showHelp(): void { npx autoskills ${dim("-a cursor claude-code")} Install for specific IDEs only ${bold("Options:")} - -y, --yes Skip confirmation prompt - --dry-run Show skills without installing - -v, --verbose Show error details on failure - -a, --agent Install for specific IDEs only (e.g. cursor, claude-code) - -h, --help Show this help message + -y, --yes Skip confirmation prompt + --dry-run Show skills without installing + -v, --verbose Show error details on failure + -a, --agent Install for specific IDEs only (e.g. cursor, claude-code) + --from-spec Detect tech from a markdown spec file + --scan-docs Auto-scan CLAUDE.md / AGENTS.md in project root + -h, --help Show this help message `); } @@ -361,7 +386,7 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise { - const { autoYes, dryRun, verbose, help, agents } = parseArgs(); + const { autoYes, dryRun, verbose, help, agents, fromSpec, scanDocs } = parseArgs(); if (help) { showHelp(); @@ -373,9 +398,38 @@ async function main(): Promise { const projectDir = resolve("."); write(dim(" Scanning project...\r")); - const { detected, isFrontend, combos } = detectTechnologies(projectDir); + const core = detectTechnologies(projectDir); write("\x1b[K"); + // Merge markdown-scanner results (opt-in) — default path unchanged. + let detected: Technology[] = core.detected; + let combos: ComboSkill[] = core.combos; + let isFrontend = core.isFrontend; + + if (fromSpec || scanDocs) { + const sources = loadMarkdownSources({ + fromSpec, + scanDocs, + projectDir, + }); + if (scanDocs && !fromSpec && sources.length === 0) { + console.error(yellow(" warning: no CLAUDE.md or AGENTS.md found")); + } + if (sources.length > 0) { + const mdMatches = sources.flatMap(s => scanMarkdown(s.content, SKILLS_MAP)); + const coreIds = core.detected.map(t => t.id); + const mergedIds = mergeMarkdownDetections(coreIds, mdMatches); + // mergeMarkdownDetections only appends; length !== means scanner contributed new ids. + if (mergedIds.length !== coreIds.length) { + detected = mergedIds + .map(id => SKILLS_MAP.find(t => t.id === id)) + .filter((t): t is Technology => t !== undefined); + combos = detectCombos(mergedIds); + // isFrontend stays as core.isFrontend — core's heuristic is file-system based, not ID-based + } + } + } + if (detected.length === 0 && !isFrontend) { log(yellow(" ⚠ No supported technologies detected.")); log(dim(" Make sure you run this in a project directory.")); diff --git a/packages/autoskills/tests/cli-from-spec.test.ts b/packages/autoskills/tests/cli-from-spec.test.ts new file mode 100644 index 00000000..2a06ce0a --- /dev/null +++ b/packages/autoskills/tests/cli-from-spec.test.ts @@ -0,0 +1,65 @@ +import { describe, it } from "node:test"; +import { equal, ok } from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { useTmpDir, writePackageJson, writeFile } from "./helpers.ts"; + +const CLI = resolve(import.meta.dirname!, "..", "index.mjs"); + +function run(args: string[], cwd: string): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync(process.execPath, [CLI, ...args], { + cwd, + encoding: "utf-8", + timeout: 10_000, + env: { ...process.env, NO_COLOR: "1" }, + }); + return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status }; +} + +describe("--from-spec / --scan-docs", () => { + const tmp = useTmpDir(); + + it("--from-spec adds tech detected in a markdown spec", () => { + writePackageJson(tmp.path, { dependencies: { react: "^19" } }); + writeFile(tmp.path, "spec.md", "## Tech Stack\n- Tailwind CSS\n"); + const { stdout } = run(["--dry-run", "--from-spec", "./spec.md"], tmp.path); + ok(stdout.toLowerCase().includes("react")); + ok(stdout.toLowerCase().includes("tailwind")); + }); + + it("--scan-docs picks up CLAUDE.md", () => { + writePackageJson(tmp.path, {}); + writeFile(tmp.path, "CLAUDE.md", "## Stack\n- Astro\n"); + const { stdout } = run(["--dry-run", "--scan-docs"], tmp.path); + ok(stdout.toLowerCase().includes("astro")); + }); + + it("--scan-docs without docs prints warning, continues", () => { + writePackageJson(tmp.path, { dependencies: { react: "^19" } }); + const { stdout, stderr } = run(["--dry-run", "--scan-docs"], tmp.path); + ok(stdout.toLowerCase().includes("react")); + ok(stderr.includes("no CLAUDE.md or AGENTS.md found"), "expected warning in stderr, got: " + stderr); + }); + + it("--from-spec nonexistent exits 1 with clear error", () => { + writePackageJson(tmp.path, {}); + const { stderr, status } = run(["--dry-run", "--from-spec", "./missing.md"], tmp.path); + ok(stderr.includes("spec file not found"), "expected 'spec file not found' in stderr, got: " + stderr); + equal(status, 1); + }); + + it("--from-spec without a value exits 1 with clear error", () => { + writePackageJson(tmp.path, {}); + const { stderr, status } = run(["--dry-run", "--from-spec"], tmp.path); + ok(stderr.includes("--from-spec requires a path argument"), "expected arg-validation error, got: " + stderr); + equal(status, 1); + }); + + it("REGRESSION: default autoskills with CLAUDE.md does NOT read it", () => { + writePackageJson(tmp.path, { dependencies: { react: "^19" } }); + writeFile(tmp.path, "CLAUDE.md", "## Stack\n- Astro\n"); + const { stdout } = run(["--dry-run"], tmp.path); + ok(stdout.toLowerCase().includes("react")); + ok(!stdout.toLowerCase().includes("astro"), "Astro leaked into default flow — opt-in guarantee broken"); + }); +}); diff --git a/packages/autoskills/tests/cli-json.test.ts b/packages/autoskills/tests/cli-json.test.ts new file mode 100644 index 00000000..7c2cd0e6 --- /dev/null +++ b/packages/autoskills/tests/cli-json.test.ts @@ -0,0 +1,129 @@ +import { describe, it } from "node:test"; +import { equal, deepEqual, ok } from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + serializeList, + serializeDryRun, + serializeInstall, + serializeError, +} from "../cli-json.ts"; + +const PKG_VERSION = (() => { + const pkg = JSON.parse(readFileSync(resolve(import.meta.dirname!, "..", "package.json"), "utf-8")); + return pkg.version as string; +})(); + +describe("serializeList", () => { + it("returns version + technologies + combos + frontend_bonus", () => { + const json = serializeList({ version: PKG_VERSION }); + equal(json.version, PKG_VERSION); + ok(Array.isArray(json.technologies)); + ok(Array.isArray(json.combos)); + ok(Array.isArray(json.frontend_bonus)); + ok(json.technologies.length > 0, "SKILLS_MAP should not be empty"); + ok(json.combos.length > 0, "COMBO_SKILLS_MAP should not be empty"); + ok(json.frontend_bonus.length > 0, "FRONTEND_BONUS_SKILLS should not be empty"); + }); + + it("must NOT expose detect rules", () => { + const json = serializeList({ version: "test" }); + for (const t of json.technologies) { + ok(!("detect" in t), `detect must not appear in list JSON for ${t.id}`); + } + }); + + it("serialized technology shape is id + name + aliases + description? + skills", () => { + const json = serializeList({ version: "test" }); + const tech = json.technologies.find(t => t.id === "react"); + ok(tech, "react must exist in SKILLS_MAP"); + equal(typeof tech!.id, "string"); + equal(typeof tech!.name, "string"); + ok(Array.isArray(tech!.aliases)); + ok(Array.isArray(tech!.skills)); + }); + + it("with filter: exact id match returns only that tech", () => { + const json = serializeList({ version: "test", filter: "react" }); + equal(json.technologies.length, 1); + equal(json.technologies[0].id, "react"); + }); + + it("with filter: name match (case-insensitive)", () => { + const json = serializeList({ version: "test", filter: "React" }); + equal(json.technologies.length, 1); + equal(json.technologies[0].id, "react"); + }); + + it("with filter: alias match (e.g., NextJS -> nextjs)", () => { + const json = serializeList({ version: "test", filter: "NextJS" }); + equal(json.technologies.length, 1); + equal(json.technologies[0].id, "nextjs"); + }); + + it("with unknown filter: empty technologies array, still exits cleanly", () => { + const json = serializeList({ version: "test", filter: "zzz-not-a-tech" }); + deepEqual(json.technologies, []); + }); + + it("aliases array is copied, not shared (mutation safety)", () => { + const json1 = serializeList({ version: "test", filter: "nextjs" }); + const aliasesSnapshot = [...json1.technologies[0].aliases]; + json1.technologies[0].aliases.push("MUTATED"); + const json2 = serializeList({ version: "test", filter: "nextjs" }); + deepEqual(json2.technologies[0].aliases, aliasesSnapshot); + }); +}); + +describe("serializeDryRun", () => { + it("passes through the provided data shape", () => { + const input = { + detected_technologies: ["react", "tailwind"], + detected_combos: [], + is_frontend: true, + skills_resolved: [ + { id: "react-best-practices", path: "vercel-labs/agent-skills/vercel-react-best-practices", source_tech: "react", installed: false }, + ], + agents_detected: ["claude-code"], + }; + const json = serializeDryRun(input); + deepEqual(json, input); + ok("detected_technologies" in json); + ok("is_frontend" in json); + ok("skills_resolved" in json); + }); +}); + +describe("serializeInstall", () => { + it("passes through installed/failed/agents", () => { + const json = serializeInstall({ + installed: [{ id: "react-best-practices", path: "vercel-labs/.../react" }], + failed: [], + agents: ["claude-code"], + }); + equal(json.installed.length, 1); + equal(json.failed.length, 0); + deepEqual(json.agents, ["claude-code"]); + }); +}); + +describe("serializeError", () => { + it("wraps error in { error: {...} } envelope", () => { + const json = serializeError({ + code: "install-unknown-id", + message: "unknown tech id 'reakt'", + hint: "did you mean: react?", + details: { provided: "reakt", suggestions: ["react"] }, + }); + equal(json.error.code, "install-unknown-id"); + equal(json.error.hint, "did you mean: react?"); + deepEqual(json.error.details, { provided: "reakt", suggestions: ["react"] }); + }); + + it("handles minimal error (no hint or details)", () => { + const json = serializeError({ code: "foo", message: "bar" }); + equal(json.error.code, "foo"); + equal(json.error.hint, undefined); + equal(json.error.details, undefined); + }); +}); From 12768810d842fdad2870b539901d639cd1ae457b Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 15:34:45 -0500 Subject: [PATCH 06/16] feat(cli): add list+prompt subcommands, ship skill-selection.md - subcommands.ts: runList (json + human + alias filter) and runPrompt (reads shipped prompts/skill-selection.md) - resolvePromptPath tries dev + dist layouts (source: pkg/prompts, built: pkg/dist/../prompts) - promptPath DI for testability + ENOENT try/catch -> prompt-file-missing JSON envelope - ship prompts/skill-selection.md (LLM selection guide, v1) - package.json files: add "prompts"; version unchanged (owner releases) - 409 tests pass, tsc clean --- packages/autoskills/package.json | 3 +- .../autoskills/prompts/skill-selection.md | 45 ++++++ packages/autoskills/subcommands.ts | 88 +++++++++++ .../tests/subcommands-list-prompt.test.ts | 145 ++++++++++++++++++ 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 packages/autoskills/prompts/skill-selection.md create mode 100644 packages/autoskills/subcommands.ts create mode 100644 packages/autoskills/tests/subcommands-list-prompt.test.ts diff --git a/packages/autoskills/package.json b/packages/autoskills/package.json index 837fab28..ca8dc485 100644 --- a/packages/autoskills/package.json +++ b/packages/autoskills/package.json @@ -25,7 +25,8 @@ }, "files": [ "index.mjs", - "dist/" + "dist/", + "prompts" ], "type": "module", "scripts": { diff --git a/packages/autoskills/prompts/skill-selection.md b/packages/autoskills/prompts/skill-selection.md new file mode 100644 index 00000000..28f271c7 --- /dev/null +++ b/packages/autoskills/prompts/skill-selection.md @@ -0,0 +1,45 @@ +# autoskills — Skill Selection Guide + +You are helping a user select skills for their project using autoskills. + +## What is autoskills + +autoskills is a CLI that installs curated AI skills (markdown instruction +bundles) for agents like Claude Code, Cursor, Cline, Codex. + +You interact with autoskills via: +- `autoskills list --json` — full catalog +- `autoskills install --only ` — install specific skills +- `autoskills --dry-run --json` — structural detection baseline + +## Workflow + +1. Read the user's spec or project context. +2. Run `autoskills list --json` to get the catalog. +3. Optionally run `autoskills --dry-run --json` to see what structural detection finds. +4. Match user's needs to technologies in the catalog. +5. Propose a list to the user with reasoning per skill. +6. After confirmation, run `autoskills install --only `. + +## Categories (infer, not hardcoded) + +- **Frontend** — UI, styling, a11y, SEO → React, Vue, Svelte, Astro, Next, Tailwind plus generalist (frontend-design, accessibility, seo). +- **Backend** — APIs, databases, auth → Express, Fastify, Hono, NestJS, Spring, ASP.NET, Rails, Prisma. +- **Mobile** — native / cross-platform → Expo, React Native, Flutter, SwiftUI. +- **DevOps / Cloud** — deploy, IaC, edge → Vercel, Cloudflare, AWS, Terraform. + +## Rules + +- Do not suggest skills for techs not mentioned or inferred. +- Prefer combos when both required techs are present. +- Include frontend_bonus when the project is clearly frontend. +- Match by aliases ("Next.js" → tech id `nextjs`). +- Ambiguous? Ask the user before installing. +- Always list your reasoning before proposing an install. + +## Matching hints + +- Prose counts: "built with React" → react. +- Negations matter: "don't want jQuery" → skip jQuery. +- Synonyms: "edge functions" → Cloudflare Workers or Vercel. +- Stack shorthands: "MERN" → mongo + express + react + node. diff --git a/packages/autoskills/subcommands.ts b/packages/autoskills/subcommands.ts new file mode 100644 index 00000000..45eea408 --- /dev/null +++ b/packages/autoskills/subcommands.ts @@ -0,0 +1,88 @@ +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { serializeList, serializeError } from "./cli-json.ts"; +import type { ListJson } from "./cli-json.ts"; +import { log, write } from "./colors.ts"; + +// AGENTS.md: never use console.log / process.stdout.write directly. Use log/write from colors.ts. Errors go to console.error. + +const SUBCOMMANDS_DIR = dirname(fileURLToPath(import.meta.url)); + +export interface RunListArgs { + json: boolean; + filter?: string; + version: string; +} + +export function runList(args: RunListArgs): number { + const payload = serializeList({ version: args.version, filter: args.filter }); + if (args.json) { + write(JSON.stringify(payload, null, 2) + "\n"); + return 0; + } + if (payload.technologies.length === 0 && args.filter) { + console.error(`no technologies match '${args.filter}'`); + return 0; + } + for (const t of payload.technologies) { + log(`${t.id} ${t.name}${t.description ? ` — ${t.description}` : ""}`); + } + return 0; +} + +export interface RunPromptArgs { + printPath: boolean; + /** Override the prompt file path (for tests). Normally resolved from the package layout. */ + promptPath?: string; +} + +/** + * Candidate paths for the shipped prompts/skill-selection.md. + * When running from TypeScript sources, SUBCOMMANDS_DIR is the package root. + * When running from the built tarball, SUBCOMMANDS_DIR is /dist/, so we walk up one level. + */ +function resolvePromptPath(): string { + const candidates = [ + resolve(SUBCOMMANDS_DIR, "prompts", "skill-selection.md"), + resolve(SUBCOMMANDS_DIR, "..", "prompts", "skill-selection.md"), + ]; + for (const p of candidates) { + try { + readFileSync(p); + return p; + } catch { + continue; + } + } + // Return the first candidate; readFileSync in runPrompt will surface the ENOENT uniformly. + return candidates[0]; +} + +export function runPrompt(args: RunPromptArgs): number { + const promptPath = args.promptPath ?? resolvePromptPath(); + let content: string; + try { + content = readFileSync(promptPath, "utf-8"); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === "ENOENT") { + console.error( + JSON.stringify( + serializeError({ + code: "prompt-file-missing", + message: "prompt file missing from package (build issue). reinstall autoskills", + }), + ), + ); + return 1; + } + throw err; + } + if (args.printPath) { + write(promptPath + "\n"); + } else { + write(content); + } + return 0; +} diff --git a/packages/autoskills/tests/subcommands-list-prompt.test.ts b/packages/autoskills/tests/subcommands-list-prompt.test.ts new file mode 100644 index 00000000..6a7f0a21 --- /dev/null +++ b/packages/autoskills/tests/subcommands-list-prompt.test.ts @@ -0,0 +1,145 @@ +import { describe, it } from "node:test"; +import { equal, deepEqual, ok } from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { runList, runPrompt } from "../subcommands.ts"; + +// Capture stdout / stderr via monkey-patching for the duration of a single test. +// +// NOTE: colors.ts exports `write = process.stdout.write.bind(process.stdout)`, a pre-bound +// function. Patching `process.stdout.write` after the bind doesn't intercept calls through +// that bound reference (bind captures the function value, not the property). Similarly, +// patching `require('node:fs').writeSync` misses the native binding used by SyncWriteStream. +// +// Solution: patch `SyncWriteStream.prototype._write` — the method that Writable internals +// eventually dispatch to via `this._write(...)`, which IS a live prototype lookup and therefore +// interceptable regardless of when the write function was bound. This captures: +// - calls via the pre-bound `write` export from colors.ts +// - calls via `process.stdout.write` directly +// - calls via `console.log` (which goes through process.stdout) +// +// stderr is captured by patching `console.error` at the JS level (errors from subcommands.ts +// always go through console.error, never through a pre-bound reference). +// fn MUST be synchronous. captureStdio mutates a shared Writable prototype; async work would race the restore across tests. +function captureStdio(fn: () => T): { out: string; err: string; result: T } { + let out = ""; + let err = ""; + + // Get the SyncWriteStream prototype (immediate prototype of process.stdout). + const stdoutProto = Object.getPrototypeOf(process.stdout) as { + _write: (chunk: Buffer | string, encoding: string, cb: (err?: Error | null) => void) => void; + }; + const origUnderscoreWrite = stdoutProto._write; + const origErr = console.error; + + // Intercept _write so that both pre-bound and live writes to stdout are captured. + stdoutProto._write = function ( + chunk: Buffer | string, + encoding: string, + cb: (err?: Error | null) => void, + ) { + if (this === process.stdout) { + out += typeof chunk === "string" ? chunk : chunk.toString(); + cb(); + return; + } + return origUnderscoreWrite.call(this, chunk, encoding, cb); + }; + + // Capture console.error (used by subcommands.ts for error output). + console.error = (...args: unknown[]) => { + err += args.map(String).join(" ") + "\n"; + }; + + try { + const result = fn(); + return { out, err, result }; + } finally { + stdoutProto._write = origUnderscoreWrite; + console.error = origErr; + } +} + +describe("runList", () => { + it("--json emits parseable ListJson on stdout", () => { + const { out, result } = captureStdio(() => runList({ json: true, version: "test-1.0.0" })); + equal(result, 0); + const parsed = JSON.parse(out); + equal(parsed.version, "test-1.0.0"); + ok(Array.isArray(parsed.technologies)); + ok(parsed.technologies.length > 0); + }); + + it("--json --filter react returns one tech", () => { + const { out, result } = captureStdio(() => runList({ json: true, filter: "react", version: "t" })); + equal(result, 0); + const parsed = JSON.parse(out); + equal(parsed.technologies.length, 1); + equal(parsed.technologies[0].id, "react"); + }); + + it("--json --filter NextJS (alias) returns nextjs", () => { + const { out, result } = captureStdio(() => runList({ json: true, filter: "NextJS", version: "t" })); + equal(result, 0); + const parsed = JSON.parse(out); + equal(parsed.technologies.length, 1); + equal(parsed.technologies[0].id, "nextjs"); + }); + + it("no --json prints human table", () => { + const { out, result } = captureStdio(() => runList({ json: false, version: "t" })); + equal(result, 0); + ok(out.includes("react")); + ok(out.includes("React")); + }); + + it("no --json with unknown --filter prints 'no technologies match' to stderr, exit 0", () => { + const { out, err, result } = captureStdio(() => + runList({ json: false, filter: "zzz-nope", version: "t" }), + ); + equal(result, 0); + ok(err.includes("no technologies match 'zzz-nope'")); + equal(out, ""); + }); + + it("--json with unknown --filter still emits valid JSON (empty technologies)", () => { + const { out, result } = captureStdio(() => runList({ json: true, filter: "zzz", version: "t" })); + equal(result, 0); + const parsed = JSON.parse(out); + deepEqual(parsed.technologies, []); + }); +}); + +describe("runPrompt", () => { + const PROMPT_PATH = resolve(import.meta.dirname!, "..", "prompts", "skill-selection.md"); + + it("stdouts the prompt file when it exists", () => { + if (!existsSync(PROMPT_PATH)) { + // File may not exist yet if T15 hasn't run. The other runPrompt test (path mode) will handle that. + return; + } + const expected = readFileSync(PROMPT_PATH, "utf-8"); + const { out, result } = captureStdio(() => runPrompt({ printPath: false })); + equal(result, 0); + equal(out, expected); + }); + + it("--path prints absolute path of the shipped prompt", () => { + if (!existsSync(PROMPT_PATH)) { + return; // guarded: file-missing path is tested below + } + const { out, result } = captureStdio(() => runPrompt({ printPath: true })); + equal(result, 0); + ok(out.trim().endsWith("prompts/skill-selection.md")); + }); + + it("emits prompt-file-missing JSON envelope (exit 1) when the injected path does not exist", () => { + const { err, result } = captureStdio(() => + runPrompt({ printPath: false, promptPath: "/definitely/does/not/exist/skill-selection.md" }), + ); + equal(result, 1); + const parsed = JSON.parse(err.trim()); + equal(parsed.error.code, "prompt-file-missing"); + ok(parsed.error.message.includes("prompt file missing")); + }); +}); From cff1b1552cb417a9900ef8d189ceafac9e84d859 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 16:07:14 -0500 Subject: [PATCH 07/16] feat(subcommands): add install --only with fuzzy suggest + DI installer - runInstall: parse comma-separated ids, dedupe, validate against SKILLS_MAP - Levenshtein <=2 fuzzy suggestions (catalog-order tiebreak) for unknown ids - install-missing-only / install-empty-only / install-unknown-id JSON envelopes - parallel installSkill via Promise.all with mockInstaller DI hook for tests - human + json output modes; exit 1 on any failure or validation error - autoYes reserved for T17 interactive prompt - 420 tests pass, tsc clean --- packages/autoskills/subcommands.ts | 150 ++++++++++++++- .../autoskills/tests/install-only.test.ts | 180 ++++++++++++++++++ 2 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 packages/autoskills/tests/install-only.test.ts diff --git a/packages/autoskills/subcommands.ts b/packages/autoskills/subcommands.ts index 45eea408..20abb409 100644 --- a/packages/autoskills/subcommands.ts +++ b/packages/autoskills/subcommands.ts @@ -1,9 +1,11 @@ import { readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { serializeList, serializeError } from "./cli-json.ts"; +import { serializeList, serializeError, serializeInstall } from "./cli-json.ts"; import type { ListJson } from "./cli-json.ts"; -import { log, write } from "./colors.ts"; +import { log, write, green, red } from "./colors.ts"; +import { SKILLS_MAP } from "./skills-map.ts"; +import { installSkill as defaultInstallSkill } from "./installer.ts"; // AGENTS.md: never use console.log / process.stdout.write directly. Use log/write from colors.ts. Errors go to console.error. @@ -86,3 +88,147 @@ export function runPrompt(args: RunPromptArgs): number { } return 0; } + +// ── runInstall ──────────────────────────────────────────────── + +export interface InstallDeps { + installSkill: (skillPath: string, agents: string[]) => Promise<{ + success: boolean; + output: string; + stderr: string; + exitCode: number | null; + command: string; + }>; +} + +export interface RunInstallArgs { + only: string; + agents: string[]; + /** Reserved for T17 interactive prompt. Non-functional in this wave. */ + autoYes: boolean; + json: boolean; + verbose: boolean; + deps?: InstallDeps; +} + +function levenshtein(a: string, b: string): number { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +function suggestTechId(bad: string): string | null { + let best: { id: string; d: number } | null = null; + for (const t of SKILLS_MAP) { + const d = levenshtein(bad, t.id); + if (d <= 2 && (!best || d < best.d)) best = { id: t.id, d }; + } + return best?.id ?? null; +} + +function emitInstallError(args: { + json: boolean; + code: string; + message: string; + hint?: string; + details?: Record; +}): number { + if (args.json) { + write( + JSON.stringify( + serializeError({ code: args.code, message: args.message, hint: args.hint, details: args.details }), + ) + "\n", + ); + } else { + const hint = args.hint ? ` (${args.hint})` : ""; + console.error(red(`error: ${args.message}${hint}`)); + } + return 1; +} + +export async function runInstall(args: RunInstallArgs): Promise { + if (args.only === "") { + return emitInstallError({ + json: args.json, + code: "install-missing-only", + message: "'install' requires --only ", + }); + } + + const raw = args.only.trim(); + if (raw === "") { + return emitInstallError({ + json: args.json, + code: "install-empty-only", + message: "--only cannot be empty", + }); + } + + const ids = [...new Set(raw.split(",").map(s => s.trim()).filter(Boolean))]; + if (ids.length === 0) { + return emitInstallError({ + json: args.json, + code: "install-empty-only", + message: "--only cannot be empty", + }); + } + + const unknown: string[] = []; + const validEntries: (typeof SKILLS_MAP)[number][] = []; + for (const id of ids) { + const tech = SKILLS_MAP.find(t => t.id === id); + if (!tech) unknown.push(id); + else validEntries.push(tech); + } + + if (unknown.length > 0) { + const firstBad = unknown[0]; + const suggestion = suggestTechId(firstBad); + return emitInstallError({ + json: args.json, + code: "install-unknown-id", + message: `unknown tech id '${firstBad}'`, + hint: suggestion ? `did you mean: ${suggestion}?` : undefined, + details: { + provided: firstBad, + suggestions: suggestion ? [suggestion] : [], + unknown_ids: unknown, + }, + }); + } + + const skillPaths = [...new Set(validEntries.flatMap(t => t.skills))]; + const installFn = args.deps?.installSkill ?? defaultInstallSkill; + + const results = await Promise.all( + skillPaths.map(async (p) => ({ path: p, result: await installFn(p, args.agents) })), + ); + + const installed = results.filter(r => r.result.success).map(r => ({ id: r.path, path: r.path })); + const failed = results.filter(r => !r.result.success).map(r => ({ + id: r.path, + error: r.result.stderr || r.result.output || "install failed", + })); + + if (args.json) { + write(JSON.stringify(serializeInstall({ installed, failed, agents: args.agents }), null, 2) + "\n"); + } else { + for (const ok of installed) log(green("installed") + " " + ok.path); + for (const f of failed) { + log(red("failed") + " " + f.id); + if (args.verbose) console.error(f.error); + } + } + + return failed.length > 0 ? 1 : 0; +} diff --git a/packages/autoskills/tests/install-only.test.ts b/packages/autoskills/tests/install-only.test.ts new file mode 100644 index 00000000..54f1dfc8 --- /dev/null +++ b/packages/autoskills/tests/install-only.test.ts @@ -0,0 +1,180 @@ +import { describe, it } from "node:test"; +import { equal, deepEqual, ok } from "node:assert/strict"; +import { runInstall } from "../subcommands.ts"; +import { mockInstaller } from "./helpers.ts"; + +async function captureAsync(fn: () => Promise): Promise<{ out: string; err: string; result: T }> { + let out = ""; + let err = ""; + const stdoutProto = Object.getPrototypeOf(process.stdout); + const origWrite = stdoutProto._write; + const origLog = console.log; + const origErr = console.error; + stdoutProto._write = function (chunk: Buffer | string, encoding: string, cb: () => void) { + if (this === process.stdout) { + out += typeof chunk === "string" ? chunk : chunk.toString(encoding as BufferEncoding); + cb(); + return true; + } + return origWrite.call(this, chunk, encoding, cb); + }; + console.log = (...a: unknown[]) => { out += a.map(String).join(" ") + "\n"; }; + console.error = (...a: unknown[]) => { err += a.map(String).join(" ") + "\n"; }; + try { + const result = await fn(); + return { out, err, result }; + } finally { + stdoutProto._write = origWrite; + console.log = origLog; + console.error = origErr; + } +} + +describe("runInstall — validation", () => { + it("missing --only returns install-missing-only error (json)", async () => { + const { out, result } = await captureAsync(() => + runInstall({ only: "", agents: [], autoYes: false, json: true, verbose: false }), + ); + equal(result, 1); + const parsed = JSON.parse(out.trim()); + equal(parsed.error.code, "install-missing-only"); + }); + + it("--only '' (empty string after trim) returns install-empty-only (json)", async () => { + const { out, result } = await captureAsync(() => + runInstall({ only: " ", agents: [], autoYes: false, json: true, verbose: false }), + ); + equal(result, 1); + const parsed = JSON.parse(out.trim()); + equal(parsed.error.code, "install-empty-only"); + }); + + it("--only ',,,' returns install-empty-only after dedupe", async () => { + const { out, result } = await captureAsync(() => + runInstall({ only: ",,,", agents: [], autoYes: false, json: true, verbose: false }), + ); + equal(result, 1); + const parsed = JSON.parse(out.trim()); + equal(parsed.error.code, "install-empty-only"); + }); + + it("--only reakt suggests react (distance 1, json)", async () => { + const { out, result } = await captureAsync(() => + runInstall({ only: "reakt", agents: [], autoYes: false, json: true, verbose: false }), + ); + equal(result, 1); + const parsed = JSON.parse(out.trim()); + equal(parsed.error.code, "install-unknown-id"); + equal(parsed.error.hint, "did you mean: react?"); + deepEqual(parsed.error.details.suggestions, ["react"]); + deepEqual(parsed.error.details.unknown_ids, ["reakt"]); + }); + + it("--only unknownfoo (distance > 2) returns error without suggestion", async () => { + const { out, result } = await captureAsync(() => + runInstall({ only: "unknownfoo", agents: [], autoYes: false, json: true, verbose: false }), + ); + equal(result, 1); + const parsed = JSON.parse(out.trim()); + equal(parsed.error.code, "install-unknown-id"); + equal(parsed.error.hint, undefined); + deepEqual(parsed.error.details.suggestions, []); + }); + + it("--only react,reakt returns unknown-id for reakt (mixed valid/invalid)", async () => { + const { out, result } = await captureAsync(() => + runInstall({ only: "react,reakt", agents: [], autoYes: false, json: true, verbose: false }), + ); + equal(result, 1); + const parsed = JSON.parse(out.trim()); + equal(parsed.error.code, "install-unknown-id"); + deepEqual(parsed.error.details.unknown_ids, ["reakt"]); + }); + + it("human mode error goes to stderr, not stdout", async () => { + const { out, err, result } = await captureAsync(() => + runInstall({ only: "reakt", agents: [], autoYes: false, json: false, verbose: false }), + ); + equal(result, 1); + equal(out, ""); + ok(err.includes("unknown tech id 'reakt'")); + ok(err.includes("did you mean: react?")); + }); +}); + +describe("runInstall — happy path with mock installer", () => { + it("installs one tech's skills with mock installer, json output", async () => { + const mock = mockInstaller({ success: true }); + const { out, result } = await captureAsync(() => + runInstall({ + only: "react", + agents: ["claude-code"], + autoYes: true, + json: true, + verbose: false, + deps: { installSkill: mock.installSkill }, + }), + ); + equal(result, 0); + const parsed = JSON.parse(out.trim()); + ok(Array.isArray(parsed.installed)); + ok(parsed.installed.length >= 1); + ok(mock.calls.length >= 1); + // Confirm agents were propagated + ok(mock.calls.every(c => c.agents[0] === "claude-code")); + }); + + it("dedupes duplicate ids before resolution", async () => { + const mock = mockInstaller({ success: true }); + const { result } = await captureAsync(() => + runInstall({ + only: "react,react", + agents: [], + autoYes: true, + json: true, + verbose: false, + deps: { installSkill: mock.installSkill }, + }), + ); + equal(result, 0); + // Because ids are deduped before resolution, calls should equal react's unique skill count + const reactSkills = (await import("../skills-map.ts")).SKILLS_MAP.find(t => t.id === "react")!.skills; + equal(mock.calls.length, reactSkills.length); + }); + + it("reports failures in json output", async () => { + const mock = mockInstaller({ success: false, stderr: "boom" }); + const { out, result } = await captureAsync(() => + runInstall({ + only: "react", + agents: [], + autoYes: true, + json: true, + verbose: false, + deps: { installSkill: mock.installSkill }, + }), + ); + equal(result, 1); + const parsed = JSON.parse(out.trim()); + equal(parsed.installed.length, 0); + ok(parsed.failed.length >= 1); + equal(parsed.failed[0].error, "boom"); + }); + + it("human output prints installed/failed lines", async () => { + const mock = mockInstaller({ success: true }); + const { out, result } = await captureAsync(() => + runInstall({ + only: "react", + agents: [], + autoYes: true, + json: false, + verbose: false, + deps: { installSkill: mock.installSkill }, + }), + ); + equal(result, 0); + ok(out.includes("installed")); + }); + +}); From afb8faab32d41a2a81884688f632f9a873df1977 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 17:33:46 -0500 Subject: [PATCH 08/16] - main.ts: dispatch list/prompt/install subcommands before default flow; --dry-run --json emits serializeDryRun; banner/printDetected gated on !args.json - parseArgs: positional subcommand + --only/--filter/--json/--path; consumed-index tracking; implicit --filter for `list ` - ArgError class -> structured cli-arg-invalid / internal-error envelopes on --json - --json without subcommand or --dry-run rejected with envelope - unknown-subcommand envelope - installer: AUTOSKILLS_MOCK_INSTALL @internal test hook - 448 tests pass, tsc clean, REGRESSION intact --- packages/autoskills/installer.ts | 13 ++ packages/autoskills/main.ts | 191 ++++++++++++++++-- .../autoskills/tests/dry-run-json.test.ts | 53 +++++ packages/autoskills/tests/errors.test.ts | 86 ++++++++ packages/autoskills/tests/logging.test.ts | 46 +++++ packages/autoskills/tests/subcommands.test.ts | 143 +++++++++++++ 6 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 packages/autoskills/tests/dry-run-json.test.ts create mode 100644 packages/autoskills/tests/errors.test.ts create mode 100644 packages/autoskills/tests/logging.test.ts create mode 100644 packages/autoskills/tests/subcommands.test.ts diff --git a/packages/autoskills/installer.ts b/packages/autoskills/installer.ts index 7fada017..111e1ba3 100644 --- a/packages/autoskills/installer.ts +++ b/packages/autoskills/installer.ts @@ -73,6 +73,19 @@ interface InstallResult { } export function installSkill(skillPath: string, agents: string[] = []): Promise { + /** + * @internal — test-only hook. Bypasses the real spawn and returns a synthetic success result. + * Set by the test harness; do not rely on this env var from user-facing code. + */ + if (process.env.AUTOSKILLS_MOCK_INSTALL === "1") { + return Promise.resolve({ + success: true, + output: `mock-installed ${skillPath}`, + stderr: "", + exitCode: 0, + command: "mock", + }); + } const bin = resolveSkillsBin(); let cmd: string; diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index 96b19126..6b7eac20 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -31,6 +31,8 @@ import { import { printBanner, multiSelect, formatTime } from "./ui.ts"; import { installAll, resolveSkillsBin } from "./installer.ts"; import { cleanupClaudeMd } from "./claude.ts"; +import { runList, runPrompt, runInstall } from "./subcommands.ts"; +import { serializeDryRun, serializeError } from "./cli-json.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const VERSION: string = (() => { @@ -52,6 +54,13 @@ process.on("SIGINT", () => { // ── CLI ────────────────────────────────────────────────────── +class ArgError extends Error { + constructor(message: string) { + super(message); + this.name = "ArgError"; + } +} + interface CliArgs { autoYes: boolean; dryRun: boolean; @@ -60,27 +69,78 @@ interface CliArgs { agents: string[]; fromSpec?: string; scanDocs: boolean; + // subcommand dispatch + subcommand?: string; + json: boolean; + only?: string; + filter?: string; + promptPath: boolean; } function parseArgs(): CliArgs { const args = process.argv.slice(2); + const subcommand = args[0] && !args[0].startsWith("-") ? args[0] : undefined; + const consumed = new Set(); + if (subcommand !== undefined) consumed.add(0); + const agents: string[] = []; const agentIdx = args.findIndex((a) => a === "-a" || a === "--agent"); if (agentIdx !== -1) { + consumed.add(agentIdx); for (let i = agentIdx + 1; i < args.length; i++) { if (args[i].startsWith("-")) break; agents.push(args[i]); + consumed.add(i); } } + const fromSpecIdx = args.findIndex((a) => a === "--from-spec"); let fromSpec: string | undefined; if (fromSpecIdx !== -1) { + consumed.add(fromSpecIdx); const next = args[fromSpecIdx + 1]; if (!next || next.startsWith("-")) { - throw new Error("--from-spec requires a path argument"); + throw new ArgError("--from-spec requires a path argument"); } fromSpec = next; + consumed.add(fromSpecIdx + 1); + } + + const onlyIdx = args.findIndex((a) => a === "--only"); + let only: string | undefined; + if (onlyIdx !== -1) { + consumed.add(onlyIdx); + const next = args[onlyIdx + 1]; + if (!next || next.startsWith("-")) { + throw new ArgError("--only requires a value"); + } + only = next; + consumed.add(onlyIdx + 1); + } + + const filterIdx = args.findIndex((a) => a === "--filter"); + let filter: string | undefined; + if (filterIdx !== -1) { + consumed.add(filterIdx); + const next = args[filterIdx + 1]; + if (!next || next.startsWith("-")) { + throw new ArgError("--filter requires a value"); + } + filter = next; + consumed.add(filterIdx + 1); + } + + // Implicit filter: `autoskills list ` is equivalent to `autoskills list --filter `. + // Scan for the first unconsumed non-flag token after the subcommand. + if (subcommand === "list" && filter === undefined) { + for (let i = 1; i < args.length; i++) { + if (consumed.has(i)) continue; + if (args[i].startsWith("-")) continue; + filter = args[i]; + break; + } } + return { autoYes: args.includes("-y") || args.includes("--yes"), dryRun: args.includes("--dry-run"), @@ -89,6 +149,11 @@ function parseArgs(): CliArgs { agents, fromSpec, scanDocs: args.includes("--scan-docs"), + subcommand, + json: args.includes("--json"), + only, + filter, + promptPath: args.includes("--path"), }; } @@ -97,10 +162,13 @@ function showHelp(): void { ${bold("autoskills")} — Auto-install the best AI skills for your project ${bold("Usage:")} - npx autoskills Detect & install skills - npx autoskills ${dim("-y")} Skip confirmation - npx autoskills ${dim("--dry-run")} Show what would be installed - npx autoskills ${dim("-a cursor claude-code")} Install for specific IDEs only + npx autoskills Detect & install skills + npx autoskills ${dim("-y")} Skip confirmation + npx autoskills ${dim("--dry-run")} ${dim("[--json]")} Show what would be installed + npx autoskills ${dim("-a cursor claude-code")} Install for specific IDEs only + npx autoskills ${dim("list")} ${dim("[--json] [--filter ]")} List catalog + npx autoskills ${dim("prompt")} ${dim("[--path]")} Print skill-selection prompt + npx autoskills ${dim("install --only ")} ${dim("[-a agents] [-y] [--json]")} ${bold("Options:")} -y, --yes Skip confirmation prompt @@ -109,6 +177,10 @@ function showHelp(): void { -a, --agent Install for specific IDEs only (e.g. cursor, claude-code) --from-spec Detect tech from a markdown spec file --scan-docs Auto-scan CLAUDE.md / AGENTS.md in project root + --json JSON output (subcommands / dry-run) + --only Comma-separated tech ids for 'install' + --filter Filter catalog for 'list' + --path Print prompt file path (for 'prompt') -h, --help Show this help message `); } @@ -386,20 +458,75 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise { - const { autoYes, dryRun, verbose, help, agents, fromSpec, scanDocs } = parseArgs(); + const args = parseArgs(); + const { autoYes, dryRun, verbose, help, agents, fromSpec, scanDocs } = args; if (help) { showHelp(); process.exit(0); } - printBanner(VERSION); + // ── Subcommand dispatch (BEFORE any default-flow side-effects) ── + if (args.subcommand !== undefined) { + const KNOWN = new Set(["list", "prompt", "install"]); + if (!KNOWN.has(args.subcommand)) { + const msg = { + code: "unknown-subcommand", + message: `unknown subcommand '${args.subcommand}'`, + hint: "run 'autoskills --help'", + }; + if (args.json) { + write(JSON.stringify(serializeError(msg)) + "\n"); + } else { + console.error(red(` error: ${msg.message}. ${msg.hint}`)); + } + process.exit(1); + } + let code = 0; + switch (args.subcommand) { + case "list": + code = runList({ json: args.json, filter: args.filter, version: VERSION }); + break; + case "prompt": + code = runPrompt({ printPath: args.promptPath }); + break; + case "install": + code = await runInstall({ + only: args.only ?? "", + agents: args.agents, + autoYes: args.autoYes, + json: args.json, + verbose: args.verbose, + }); + break; + } + process.exit(code); + } + + // MUST stay before any write() to stdout when args.json is true. Any new branch added + // between dispatch and this gate could leak banner/detection output and break JSON consumers. + if (args.json && !args.dryRun) { + const msg = { + code: "json-requires-subcommand-or-dry-run", + message: "--json requires a subcommand (list/prompt/install) or --dry-run", + }; + write(JSON.stringify(serializeError(msg)) + "\n"); + process.exit(1); + } + + if (!args.json) { + printBanner(VERSION); + } const projectDir = resolve("."); - write(dim(" Scanning project...\r")); + if (!args.json) { + write(dim(" Scanning project...\r")); + } const core = detectTechnologies(projectDir); - write("\x1b[K"); + if (!args.json) { + write("\x1b[K"); + } // Merge markdown-scanner results (opt-in) — default path unchanged. let detected: Technology[] = core.detected; @@ -431,22 +558,28 @@ async function main(): Promise { } if (detected.length === 0 && !isFrontend) { - log(yellow(" ⚠ No supported technologies detected.")); - log(dim(" Make sure you run this in a project directory.")); - log(); + if (!args.json) { + log(yellow(" ⚠ No supported technologies detected.")); + log(dim(" Make sure you run this in a project directory.")); + log(); + } process.exit(0); } - printDetected(detected, combos, isFrontend); + if (!args.json) { + printDetected(detected, combos, isFrontend); + } const installedNames = getInstalledSkillNames(projectDir); const skills = collectSkills({ detected, isFrontend, combos, installedNames }); const resolvedAgents = agents.length > 0 ? agents : detectAgents(); if (skills.length === 0) { - log(yellow(" No skills available for your stack yet.")); - log(dim(" Check https://skills.sh for the latest.")); - log(); + if (!args.json) { + log(yellow(" No skills available for your stack yet.")); + log(dim(" Check https://skills.sh for the latest.")); + log(); + } process.exit(0); } @@ -455,6 +588,22 @@ async function main(): Promise { } if (dryRun) { + if (args.json) { + const payload = serializeDryRun({ + detected_technologies: detected.map((t) => t.id), + detected_combos: combos.map((c) => c.id), + is_frontend: isFrontend, + skills_resolved: skills.map((s) => ({ + id: s.skill, + path: s.skill, + source_tech: s.sources[0] ?? "", + installed: s.installed, + })), + agents_detected: resolvedAgents, + }); + write(JSON.stringify(payload, null, 2) + "\n"); + return; + } printSkillsList(skills); log(dim(` Agents: ${resolvedAgents.join(", ")}`)); log(dim(" --dry-run: nothing was installed.")); @@ -500,6 +649,14 @@ async function main(): Promise { } main().catch((err: Error) => { - console.error(red(`\n Error: ${err.message}\n`)); + const isArgError = err instanceof ArgError; + if (process.argv.includes("--json")) { + write(JSON.stringify(serializeError({ + code: isArgError ? "cli-arg-invalid" : "internal-error", + message: err.message, + })) + "\n"); + } else { + console.error(red(`\n Error: ${err.message}\n`)); + } process.exit(1); }); diff --git a/packages/autoskills/tests/dry-run-json.test.ts b/packages/autoskills/tests/dry-run-json.test.ts new file mode 100644 index 00000000..cbd6fe6d --- /dev/null +++ b/packages/autoskills/tests/dry-run-json.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from "node:test"; +import { equal, ok } from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { useTmpDir, writePackageJson, writeFile } from "./helpers.ts"; + +const CLI = resolve(import.meta.dirname!, "..", "index.mjs"); + +function run(args: string[], cwd: string) { + const r = spawnSync(process.execPath, [CLI, ...args], { + cwd, + encoding: "utf-8", + timeout: 15_000, + env: { ...process.env, NO_COLOR: "1" }, + }); + return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", status: r.status }; +} + +describe("--dry-run --json", () => { + const tmp = useTmpDir(); + + it("emits parseable JSON with the required top-level fields", () => { + writePackageJson(tmp.path, { dependencies: { react: "^19" } }); + const { stdout, status } = run(["--dry-run", "--json"], tmp.path); + equal(status, 0); + const parsed = JSON.parse(stdout); + ok("detected_technologies" in parsed); + ok("detected_combos" in parsed); + ok("is_frontend" in parsed); + ok("skills_resolved" in parsed); + ok("agents_detected" in parsed); + ok(Array.isArray(parsed.detected_technologies)); + ok(parsed.detected_technologies.includes("react")); + }); + + it("works with --from-spec", () => { + writePackageJson(tmp.path, {}); + writeFile(tmp.path, "spec.md", "## Tech Stack\n- React\n- Tailwind CSS\n"); + const { stdout, status } = run(["--dry-run", "--json", "--from-spec", "./spec.md"], tmp.path); + equal(status, 0); + const parsed = JSON.parse(stdout); + ok(parsed.detected_technologies.includes("react")); + ok(parsed.detected_technologies.includes("tailwind")); + }); + + it("JSON output goes to stdout, not stderr", () => { + writePackageJson(tmp.path, { dependencies: { react: "^19" } }); + const { stdout, stderr } = run(["--dry-run", "--json"], tmp.path); + ok(stdout.length > 0); + // stderr may be empty or contain warnings — shouldn't contain JSON braces-at-start + ok(!stderr.trimStart().startsWith("{")); + }); +}); diff --git a/packages/autoskills/tests/errors.test.ts b/packages/autoskills/tests/errors.test.ts new file mode 100644 index 00000000..d14dfb90 --- /dev/null +++ b/packages/autoskills/tests/errors.test.ts @@ -0,0 +1,86 @@ +import { describe, it } from "node:test"; +import { equal, ok } from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { useTmpDir, writePackageJson } from "./helpers.ts"; + +const CLI = resolve(import.meta.dirname!, "..", "index.mjs"); + +function run(args: string[], cwd: string) { + const r = spawnSync(process.execPath, [CLI, ...args], { + cwd, + encoding: "utf-8", + timeout: 15_000, + env: { ...process.env, NO_COLOR: "1" }, + }); + return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", status: r.status }; +} + +describe("error taxonomy", () => { + const tmp = useTmpDir(); + + it("spec-file-not-found (via --from-spec)", () => { + writePackageJson(tmp.path, {}); + const { stderr, status } = run(["--dry-run", "--from-spec", "./missing.md"], tmp.path); + equal(status, 1); + ok(stderr.includes("spec file not found")); + }); + + it("unknown-subcommand (human)", () => { + writePackageJson(tmp.path, {}); + const { stderr, status } = run(["foobar"], tmp.path); + equal(status, 1); + ok(stderr.includes("unknown subcommand 'foobar'")); + }); + + it("unknown-subcommand (json)", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["foobar", "--json"], tmp.path); + equal(status, 1); + const parsed = JSON.parse(stdout); + equal(parsed.error.code, "unknown-subcommand"); + }); + + it("install-missing-only (json)", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["install", "--json"], tmp.path); + equal(status, 1); + const parsed = JSON.parse(stdout); + equal(parsed.error.code, "install-missing-only"); + }); + + it("install-unknown-id with fuzzy suggestion (json)", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["install", "--only", "reakt", "--json"], tmp.path); + equal(status, 1); + const parsed = JSON.parse(stdout); + equal(parsed.error.code, "install-unknown-id"); + equal(parsed.error.hint, "did you mean: react?"); + }); + + it("install-unknown-id without suggestion (distance > 2)", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["install", "--only", "totallyunknown", "--json"], tmp.path); + equal(status, 1); + const parsed = JSON.parse(stdout); + equal(parsed.error.code, "install-unknown-id"); + equal(parsed.error.hint, undefined); + }); + + it("cli-arg-invalid (--only with missing value + --json)", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["install", "--only", "--json"], tmp.path); + equal(status, 1); + const parsed = JSON.parse(stdout); + equal(parsed.error.code, "cli-arg-invalid"); + ok(parsed.error.message.includes("--only")); + }); + + it("json-requires-subcommand-or-dry-run (plain --json)", () => { + writePackageJson(tmp.path, { dependencies: { react: "^19" } }); + const { stdout, status } = run(["--json"], tmp.path); + equal(status, 1); + const parsed = JSON.parse(stdout); + equal(parsed.error.code, "json-requires-subcommand-or-dry-run"); + }); +}); diff --git a/packages/autoskills/tests/logging.test.ts b/packages/autoskills/tests/logging.test.ts new file mode 100644 index 00000000..57dce894 --- /dev/null +++ b/packages/autoskills/tests/logging.test.ts @@ -0,0 +1,46 @@ +import { describe, it } from "node:test"; +import { equal, ok } from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { useTmpDir, writePackageJson } from "./helpers.ts"; + +const CLI = resolve(import.meta.dirname!, "..", "index.mjs"); + +function run(args: string[], cwd: string) { + const r = spawnSync(process.execPath, [CLI, ...args], { + cwd, + encoding: "utf-8", + timeout: 15_000, + env: { ...process.env, NO_COLOR: "1" }, + }); + return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", status: r.status }; +} + +describe("logging / verbosity", () => { + const tmp = useTmpDir(); + + it("--json on subcommand does not produce human banner in stdout", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["list", "--json"], tmp.path); + equal(status, 0); + // stdout should start with '{' (JSON), not the ASCII banner + ok(stdout.trimStart().startsWith("{")); + }); + + it("default dry-run (no --json) prints human-readable output with banner", () => { + writePackageJson(tmp.path, { dependencies: { react: "^19" } }); + const { stdout, status } = run(["--dry-run"], tmp.path); + equal(status, 0); + // some banner / detection text should be present + ok(stdout.length > 10); + ok(stdout.toLowerCase().includes("react")); + }); + + it("--dry-run --json emits ONLY json on stdout (no banner)", () => { + writePackageJson(tmp.path, { dependencies: { react: "^19" } }); + const { stdout, status } = run(["--dry-run", "--json"], tmp.path); + equal(status, 0); + const trimmed = stdout.trimStart(); + ok(trimmed.startsWith("{"), "stdout should start with '{' for JSON, got: " + trimmed.slice(0, 40)); + }); +}); diff --git a/packages/autoskills/tests/subcommands.test.ts b/packages/autoskills/tests/subcommands.test.ts new file mode 100644 index 00000000..3eed9956 --- /dev/null +++ b/packages/autoskills/tests/subcommands.test.ts @@ -0,0 +1,143 @@ +import { describe, it } from "node:test"; +import { equal, ok } from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { useTmpDir, writePackageJson } from "./helpers.ts"; + +const CLI = resolve(import.meta.dirname!, "..", "index.mjs"); + +function run(args: string[], cwd: string) { + const r = spawnSync(process.execPath, [CLI, ...args], { + cwd, + encoding: "utf-8", + timeout: 15_000, + env: { ...process.env, NO_COLOR: "1" }, + }); + return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", status: r.status }; +} + +describe("subcommand dispatch", () => { + const tmp = useTmpDir(); + + it("autoskills list --json parses and has version", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["list", "--json"], tmp.path); + equal(status, 0); + const parsed = JSON.parse(stdout); + ok(typeof parsed.version === "string"); + ok(Array.isArray(parsed.technologies)); + }); + + it("autoskills list --json --filter react returns one tech", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["list", "--json", "--filter", "react"], tmp.path); + equal(status, 0); + const parsed = JSON.parse(stdout); + equal(parsed.technologies.length, 1); + equal(parsed.technologies[0].id, "react"); + }); + + it("autoskills prompt --path prints an absolute path", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["prompt", "--path"], tmp.path); + equal(status, 0); + ok(stdout.trim().endsWith("skill-selection.md")); + }); + + it("autoskills prompt outputs the prompt file contents", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["prompt"], tmp.path); + equal(status, 0); + ok(stdout.includes("# autoskills — Skill Selection Guide")); + }); + + it("autoskills install --only react-that-does-not-exist --json -> install-unknown-id", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["install", "--only", "reakt", "--json"], tmp.path); + equal(status, 1); + const parsed = JSON.parse(stdout); + equal(parsed.error.code, "install-unknown-id"); + equal(parsed.error.hint, "did you mean: react?"); + }); + + it("autoskills install without --only errors", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["install", "--json"], tmp.path); + equal(status, 1); + const parsed = JSON.parse(stdout); + equal(parsed.error.code, "install-missing-only"); + }); + + it("default autoskills (no subcommand) still runs dry-run path", () => { + writePackageJson(tmp.path, { dependencies: { react: "^19" } }); + const { stdout, status } = run(["--dry-run"], tmp.path); + equal(status, 0); + ok(stdout.toLowerCase().includes("react")); + }); + + it("unknown subcommand exits 1 with unknown-subcommand error", () => { + writePackageJson(tmp.path, {}); + const { stderr, status } = run(["foo"], tmp.path); + equal(status, 1); + ok(stderr.includes("unknown subcommand 'foo'")); + }); + + it("unknown subcommand --json emits JSON error", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["foo", "--json"], tmp.path); + equal(status, 1); + const parsed = JSON.parse(stdout); + equal(parsed.error.code, "unknown-subcommand"); + }); + + it("autoskills list react (implicit --filter) returns one tech", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["list", "--json", "react"], tmp.path); + equal(status, 0); + const parsed = JSON.parse(stdout); + equal(parsed.technologies.length, 1); + equal(parsed.technologies[0].id, "react"); + }); + + it("autoskills list react --json (positional before --json) returns one tech", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["list", "react", "--json"], tmp.path); + equal(status, 0); + const parsed = JSON.parse(stdout); + equal(parsed.technologies.length, 1); + equal(parsed.technologies[0].id, "react"); + }); + + it("autoskills list react (human mode, no --json) prints only react's row", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["list", "react"], tmp.path); + equal(status, 0); + ok(stdout.toLowerCase().includes("react")); + // crude but effective: if only 1 tech printed, output should be small + ok(stdout.split("\n").filter(l => l.trim()).length < 10, "expected compact single-row output"); + }); + + it("autoskills list --filter react extra-positional: explicit --filter wins, extra is ignored", () => { + writePackageJson(tmp.path, {}); + const { stdout, status } = run(["list", "--filter", "react", "extra", "--json"], tmp.path); + equal(status, 0); + const parsed = JSON.parse(stdout); + equal(parsed.technologies.length, 1); + equal(parsed.technologies[0].id, "react"); + }); + + it("autoskills install --only react -y --json (positive path, mocked installer)", () => { + writePackageJson(tmp.path, {}); + const r = spawnSync(process.execPath, [CLI, "install", "--only", "react", "-y", "--json"], { + cwd: tmp.path, + encoding: "utf-8", + timeout: 15_000, + env: { ...process.env, NO_COLOR: "1", AUTOSKILLS_MOCK_INSTALL: "1" }, + }); + equal(r.status, 0); + const parsed = JSON.parse(r.stdout ?? ""); + ok(Array.isArray(parsed.installed)); + ok(parsed.installed.length >= 1); + equal(parsed.failed.length, 0); + }); +}); From 03f9e760b6d6d31fb729dad7d52c1145f042a5af Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 18:25:31 -0500 Subject: [PATCH 09/16] docs: document markdown scanner + LLM subcommands + refresh helpers/refs - packages/autoskills/README.md: Options table (+--from-spec, --scan-docs, --json), new sections Markdown scanner (opt-in) + Subcommands (for LLM integration); fix -a syntax example; complete error codes list - root README.md: Options block +new flags, new LLM-driven mode section linking package README - AGENTS.md: fix stale .mjs refs -> .ts; expand shared helpers list with writeMarkdown/readFixtureSpec/parseJsonOutput/buildMarkdownFromParts/mockInstaller --- AGENTS.md | 6 ++-- README.md | 21 +++++++++-- packages/autoskills/README.md | 65 +++++++++++++++++++++++++++++++---- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43923ba8..f7ee998b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,15 +39,15 @@ assert.ok(value); assert.strictEqual(a, b); ``` -- Use the shared helpers from `tests/helpers.mjs` (`useTmpDir`, `writePackageJson`, `writeJson`, `writeFile`, `addWorkspace`) to avoid duplicating filesystem setup logic in tests. +- Use the shared helpers from `tests/helpers.ts` (`useTmpDir`, `writePackageJson`, `writeJson`, `writeFile`, `addWorkspace`, `writeMarkdown`, `readFixtureSpec`, `parseJsonOutput`, `buildMarkdownFromParts`, `mockInstaller`) to avoid duplicating filesystem setup, CLI-output parsing, and installer-stubbing logic in tests. ## Output helpers -- **Never use `console.log` or `process.stdout.write` directly** in the CLI package (`packages/autoskills`). Use the `log` and `write` helpers exported from `colors.mjs` instead. +- **Never use `console.log` or `process.stdout.write` directly** in the CLI package (`packages/autoskills`). Use the `log` and `write` helpers exported from `colors.ts` instead. ```js // ✅ Correct -import { log, write } from "./colors.mjs"; +import { log, write } from "./colors.ts"; log("hello"); write("raw output\n"); diff --git a/README.md b/README.md index 82adc213..171c9778 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,26 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` also writes ## Options ``` --y, --yes Skip confirmation prompt ---dry-run Show what would be installed without installing --h, --help Show help message +-y, --yes Skip confirmation prompt +--dry-run Show what would be installed without installing +--json Emit structured JSON (with --dry-run or subcommands) +--from-spec Detect tech from a markdown spec file +--scan-docs Auto-scan CLAUDE.md / AGENTS.md in the project +-h, --help Show help message ``` +## LLM-driven mode + +Beyond structural detection, `autoskills` exposes atomic subcommands so an external LLM CLI (Claude Code, Cursor, Codex) can reason over prose specs and drive installation: + +```bash +npx autoskills list --json # full catalog +npx autoskills prompt # shipped selection guide +npx autoskills install --only +``` + +See the [package README](./packages/autoskills/README.md) for details. + ## Supported Technologies Built to work across modern frontend, backend, mobile, cloud, and media stacks. diff --git a/packages/autoskills/README.md b/packages/autoskills/README.md index 6fc63020..97231aae 100644 --- a/packages/autoskills/README.md +++ b/packages/autoskills/README.md @@ -42,12 +42,65 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` writes a `CL ## Options -| Flag | Description | -| ----------------- | ----------------------------------------------------- | -| `-y`, `--yes` | Skip confirmation prompt, install all detected skills | -| `--dry-run` | Show detected skills without installing anything | -| `-v`, `--verbose` | Show error details if any installation fails | -| `-h`, `--help` | Show help message | +| Flag | Description | +| -------------------------- | --------------------------------------------------------------------------- | +| `-y`, `--yes` | Skip confirmation prompt, install all detected skills | +| `--dry-run` | Show detected skills without installing | +| `--json` | Emit structured JSON (used with `--dry-run` or subcommands; errors return `{error:{code,message}}`) | +| `--from-spec ` | Scan a markdown spec file for tech (code fences + Tech Stack headings) | +| `--scan-docs` | Auto-scan `CLAUDE.md` / `AGENTS.md` in the project root | +| `-v`, `--verbose` | Show error details if any installation fails | +| `-a`, `--agent ` | Install for specific IDEs only (e.g. `cursor`, `claude-code`) | +| `-h`, `--help` | Show help message | + +## Markdown scanner (opt-in) + +Detect tech from structured markdown docs — feature specs, `CLAUDE.md`, +`AGENTS.md` — without having to populate `package.json`. + +```bash +# Scan a specific spec file +npx autoskills --from-spec ./docs/feature-spec.md + +# Auto-scan CLAUDE.md / AGENTS.md in the current project +npx autoskills --scan-docs + +# Combine with default detection (union) +npx autoskills --scan-docs --dry-run +``` + +The scanner recognizes two structures: + +- **Code fences** — `json` (reads `dependencies`/`devDependencies`), `bash`/`sh`/`shell`/`zsh` (extracts `npm|pnpm|yarn|bun add/install` packages), `yaml`/`yml`/`toml`, `ruby`/`gemfile` (`gem ''`). +- **Stack headings** — `## Tech Stack`, `## Stack`, `## Dependencies`, `## Built With`, `## Technologies`, `## Tecnologías` (English + Spanish, case-insensitive, h1–h3). Bullets under the heading are matched against technology names and aliases. + +Prose ("we'll use React") outside these structures is ignored to prevent false positives. + +**Default behavior is unchanged.** No markdown is read unless `--from-spec` or `--scan-docs` is passed. + +## Subcommands (for LLM integration) + +Atomic subcommands let an external LLM CLI (Claude Code, Cursor, Codex) drive autoskills over prose specs that the structural scanner cannot parse. + +```bash +# List the full catalog as JSON +npx autoskills list --json +npx autoskills list --filter react # or: npx autoskills list react + +# Print the shipped skill-selection prompt (LLM guidance) +npx autoskills prompt # stdout the prompt +npx autoskills prompt --path # print absolute path + +# Install specific skills by id +npx autoskills install --only react,tailwind -y +npx autoskills install --only react -a claude-code cursor +``` + +Typical LLM workflow: fetch the guide (`autoskills prompt`) + catalog (`autoskills list --json`), reason over the user's spec, propose skills, then call `autoskills install --only ` after confirmation. + +The skill-selection prompt is shipped at `prompts/skill-selection.md` inside the package and covers workflow, category inference, matching rules, and alias resolution. + +All subcommands emit structured JSON errors when `--json` is passed, for programmatic parsing. Error codes include `unknown-subcommand`, `install-missing-only`, `install-empty-only`, `install-unknown-id` (with fuzzy-match suggestion), `json-requires-subcommand-or-dry-run`, `cli-arg-invalid`, `internal-error`, and `prompt-file-missing`. ## Supported Technologies From e9be97ef7257adcd2a84f90236fe47485effd94f Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 19:06:23 -0500 Subject: [PATCH 10/16] feat: Updated format examples for doc parsing --- README.md | 2 ++ packages/autoskills/README.md | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/README.md b/README.md index 171c9778..a3d19fa6 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` also writes -h, --help Show help message ``` +> `--from-spec` and `--scan-docs` only parse code fences (`json`, `bash`/`sh`, `yaml`/`toml`, `ruby`) and **bullet lists** under headings like `## Tech Stack` / `## Stack` / `## Dependencies`. Markdown tables are ignored. See [Markdown scanner](./packages/autoskills/README.md#markdown-scanner-opt-in) for bullet shapes and examples. + ## LLM-driven mode Beyond structural detection, `autoskills` exposes atomic subcommands so an external LLM CLI (Claude Code, Cursor, Codex) can reason over prose specs and drive installation: diff --git a/packages/autoskills/README.md b/packages/autoskills/README.md index 97231aae..6fef3619 100644 --- a/packages/autoskills/README.md +++ b/packages/autoskills/README.md @@ -76,6 +76,46 @@ The scanner recognizes two structures: Prose ("we'll use React") outside these structures is ignored to prevent false positives. +### Bullet format (under stack headings) + +Only bullet lists are parsed. **Markdown tables are not recognized** — convert to bullets or add a code fence. + +Each bullet must resolve to a `name` or alias in the catalog (`autoskills list --json`). The following shapes all match **Astro**: + +```markdown +## Tech Stack + +- Astro +- Astro (SSR) +- Astro — 4.0 +- Astro - 4.0 +- Astro 4.0.1 +``` + +Normalization rules applied to each bullet: + +1. Parenthetical annotations are dropped — `Astro (SSR)` → `Astro`. +2. Em-dash / en-dash / hyphen + trailing text is dropped — `Astro — 4.0` → `Astro`. +3. A trailing version token is dropped — `Astro 4.0.1` → `Astro`. + +Tables are ignored, even under a supported heading: + +```markdown +## Tech Stack + +| Tech | Version | +| ----- | ------- | +| Astro | 4.0 | +``` + +For tables or free-form docs, add a fenced block with dependencies instead: + +````markdown +```json +{ "devDependencies": { "astro": "^4.0.0" } } +``` +```` + **Default behavior is unchanged.** No markdown is read unless `--from-spec` or `--scan-docs` is passed. ## Subcommands (for LLM integration) From 56994c93f642f37f1c352ff885e55588b7a20a5c Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 23:00:53 -0500 Subject: [PATCH 11/16] feat(scanner): flexible headings + numbered/comma/tables under stack - normalizeHeadingTitle: strip numbering, emoji, bold/italic, brackets, colon, trailing paren - extractStackBlocks: isStackHeading(line) via normalize-then-compare - accept numbered bullets (1./1)), comma-separated inline, GFM tables - table cell normalization: links, images, bold/italic, backticks, emoji; multi-tech split on comma - pickTechColumn heuristic (tech/framework/library/package/dependency/name/stack) + fallback col 1 - MarkdownMatch API unchanged: all new detections reuse source: "stack-heading" - 32 new tests (14 helper + 7 heading decoration + 2 numbered + 3 inline + 6 tables); 480/480 pass --- packages/autoskills/markdown-scanner.ts | 162 ++++++++++++++++-- .../autoskills/tests/markdown-scanner.test.ts | 158 ++++++++++++++++- 2 files changed, 309 insertions(+), 11 deletions(-) diff --git a/packages/autoskills/markdown-scanner.ts b/packages/autoskills/markdown-scanner.ts index 8fd8ebc4..31b68985 100644 --- a/packages/autoskills/markdown-scanner.ts +++ b/packages/autoskills/markdown-scanner.ts @@ -94,24 +94,89 @@ function matchByGems(gems: string[], map: readonly Technology[]): string[] { return ids; } -const HEADING_RE = /^(#{1,3})\s+(Tech Stack|Stack|Dependencies|Built With|Technologies|Tecnolog[ií]as)\s*$/i; +export function normalizeHeadingTitle(raw: string): string { + let s = raw.trim(); + // 1. Strip leading numbering: "2." / "1)" / "3 -" / "2:" + s = s.replace(/^\d+\s*[.)\-:]\s+/, ""); + // 2. Strip leading non-letter decoration (emoji, punctuation) — stop at letter, *, _, [ + s = s.replace(/^[^\p{L}*_\[]+/u, ""); + // 3. Strip trailing non-letter/non-closer decoration — keep closing ) and ] so step 5 handles them + s = s.replace(/[^\p{L}\p{N}*_)\]]+$/u, ""); + // 4. Unwrap bracket / bold / italic wrappers (single pass each, outermost first) + s = s.replace(/^\[(.+)\]$/, "$1"); + s = s.replace(/^\*\*(.+)\*\*$/, "$1"); + s = s.replace(/^__(.+)__$/, "$1"); + s = s.replace(/^\*(.+)\*$/, "$1"); + s = s.replace(/^_(.+)_$/, "$1"); + // 5. Strip trailing paren annotation "(frontend)" + s = s.replace(/\s*\([^)]*\)\s*$/, ""); + // 6. Strip trailing colon + s = s.replace(/\s*:\s*$/, ""); + return s.trim(); +} + +const HEADING_LINE_RE = /^(#{1,3})\s+(.+?)\s*$/; +const STACK_KEYWORDS = new Set([ + "tech stack", + "stack", + "dependencies", + "built with", + "technologies", + "tecnologías", + "tecnologias", +]); + +function isStackHeading(line: string): { level: number } | null { + const m = line.match(HEADING_LINE_RE); + if (!m) return null; + const normalized = normalizeHeadingTitle(m[2]).toLowerCase(); + if (!STACK_KEYWORDS.has(normalized)) return null; + return { level: m[1].length }; +} -function extractStackBlocks(content: string): { bullets: string[]; evidence: string }[] { +function extractStackBlocks(content: string): { + bullets: string[]; + inlines: string[]; + tables: ParsedTable[]; + evidence: string; +}[] { const lines = content.split("\n"); - const blocks: { bullets: string[]; evidence: string }[] = []; + const blocks: { + bullets: string[]; + inlines: string[]; + tables: ParsedTable[]; + evidence: string; + }[] = []; for (let i = 0; i < lines.length; i++) { - const m = lines[i].match(HEADING_RE); - if (!m) continue; - const level = m[1].length; + const heading = isStackHeading(lines[i]); + if (!heading) continue; + const level = heading.level; const bullets: string[] = []; + const inlines: string[] = []; + const tables: ParsedTable[] = []; let j = i + 1; - for (; j < lines.length; j++) { + while (j < lines.length) { const hm = lines[j].match(/^(#{1,6})\s+/); if (hm && hm[1].length <= level) break; - const bm = lines[j].match(/^\s*[-*+]\s+(.+)$/); - if (bm) bullets.push(bm[1]); + const bm = lines[j].match(/^\s*(?:[-*+]|\d+[.)])\s+(.+)$/); + if (bm) { + bullets.push(bm[1]); + j++; + continue; + } + const tableAttempt = tryParseTable(lines, j); + if (tableAttempt) { + tables.push(tableAttempt.table); + j = tableAttempt.end; + continue; + } + const trimmed = lines[j].trim(); + if (trimmed && trimmed.includes(",")) { + inlines.push(trimmed); + } + j++; } - blocks.push({ bullets, evidence: lines[i] }); + blocks.push({ bullets, inlines, tables, evidence: lines[i] }); i = j - 1; } return blocks; @@ -129,6 +194,62 @@ function normalizeBullet(raw: string): string { .trim(); } +const TABLE_SEP_RE = /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/; + +function splitRow(line: string): string[] { + let s = line.trim(); + if (s.startsWith("|")) s = s.slice(1); + if (s.endsWith("|")) s = s.slice(0, -1); + return s.split(/(? c.replace(/\\\|/g, "|").trim()); +} + +function normalizeCell(raw: string): string { + let s = raw; + // Strip images: ![alt](url) + s = s.replace(/!\[[^\]]*\]\([^)]*\)/g, ""); + // Unwrap links: [text](url) -> text + s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); + // Strip backticks + s = s.replace(/`([^`]+)`/g, "$1"); + // Unwrap bold/italic + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/__([^_]+)__/g, "$1"); + s = s.replace(/\*([^*]+)\*/g, "$1"); + s = s.replace(/_([^_]+)_/g, "$1"); + // Strip leading/trailing emoji + whitespace + s = s.replace(/^[^\p{L}\p{N}]+/u, ""); + s = s.replace(/[^\p{L}\p{N}+./#)]+$/u, ""); + return s.trim(); +} + +const TECH_COLUMN_KEYWORDS = /\b(tech|technology|technologies|framework|library|libraries|package|packages|dependency|dependencies|name|stack)\b/i; + +function pickTechColumn(headerCells: string[]): number { + for (let i = 0; i < headerCells.length; i++) { + if (TECH_COLUMN_KEYWORDS.test(headerCells[i])) return i; + } + return 0; +} + +interface ParsedTable { headerCells: string[]; rows: string[][] } + +function tryParseTable(lines: string[], start: number): { table: ParsedTable; end: number } | null { + const header = lines[start]; + if (!header || !/\|/.test(header)) return null; + const sep = lines[start + 1]; + if (!sep || !TABLE_SEP_RE.test(sep)) return null; + const headerCells = splitRow(header); + const rows: string[][] = []; + let j = start + 2; + for (; j < lines.length; j++) { + const line = lines[j]; + if (!line.trim()) break; + if (!line.includes("|")) break; + rows.push(splitRow(line)); + } + return { table: { headerCells, rows }, end: j }; +} + function matchByName(phrase: string, map: readonly Technology[]): string | null { if (!phrase) return null; if (/^\d/.test(phrase)) return null; // version-only bullets @@ -156,6 +277,27 @@ export function scanMarkdown(content: string, skillsMap: readonly Technology[]): pushMatch(id, "stack-heading", [...("- " + bullet)].slice(0, 80).join("")); } } + for (const inline of block.inlines) { + for (const piece of inline.split(",").map(s => s.trim())) { + const id = matchByName(normalizeBullet(piece), skillsMap); + if (id) { + pushMatch(id, "stack-heading", [...piece].slice(0, 80).join("")); + } + } + } + for (const table of block.tables) { + const col = pickTechColumn(table.headerCells); + for (const row of table.rows) { + const cell = row[col] ?? ""; + const normalized = normalizeCell(cell); + for (const piece of normalized.split(",").map(s => s.trim()).filter(Boolean)) { + const id = matchByName(normalizeBullet(piece), skillsMap); + if (id) { + pushMatch(id, "stack-heading", [...piece].slice(0, 80).join("")); + } + } + } + } } for (const fence of extractFences(content)) { diff --git a/packages/autoskills/tests/markdown-scanner.test.ts b/packages/autoskills/tests/markdown-scanner.test.ts index bf5ad7d6..77bf14a6 100644 --- a/packages/autoskills/tests/markdown-scanner.test.ts +++ b/packages/autoskills/tests/markdown-scanner.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import { deepEqual, equal, ok } from "node:assert/strict"; -import { scanMarkdown } from "../markdown-scanner.ts"; +import { scanMarkdown, normalizeHeadingTitle } from "../markdown-scanner.ts"; import { SKILLS_MAP } from "../skills-map.ts"; describe("scanMarkdown — json fences", () => { @@ -133,6 +133,117 @@ describe("scanMarkdown — stack headings", () => { equal(scanMarkdown("#### Tech Stack\n- React\n", SKILLS_MAP).length, 0); }); + it("accepts numbered heading (## 2. Tech Stack)", () => { + const md = "## 2. Tech Stack\n\n- React\n- Tailwind CSS\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind"].sort()); + }); + it("accepts emoji-prefixed heading (## 🚀 Tech Stack)", () => { + equal(scanMarkdown("## 🚀 Tech Stack\n- React\n", SKILLS_MAP)[0]?.techId, "react"); + }); + it("accepts bold-wrapped heading (## **Dependencies**)", () => { + equal(scanMarkdown("## **Dependencies**\n- React\n", SKILLS_MAP)[0]?.techId, "react"); + }); + it("accepts bracketed heading (## [Stack])", () => { + equal(scanMarkdown("## [Stack]\n- React\n", SKILLS_MAP)[0]?.techId, "react"); + }); + it("accepts trailing-colon heading (## Tech Stack:)", () => { + equal(scanMarkdown("## Tech Stack:\n- React\n", SKILLS_MAP)[0]?.techId, "react"); + }); + it("rejects prose narrative heading (## Why we picked our Stack)", () => { + deepEqual(scanMarkdown("## Why we picked our Stack\n- React\n", SKILLS_MAP), []); + }); + it("still rejects h4+ after normalization (#### 1. Tech Stack)", () => { + deepEqual(scanMarkdown("#### 1. Tech Stack\n- React\n", SKILLS_MAP), []); + }); + + it("accepts numbered bullets with dot (1. Astro)", () => { + const md = "## Tech Stack\n\n1. React\n2. Tailwind CSS\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind"].sort()); + }); + it("accepts numbered bullets with paren (1) Astro)", () => { + const md = "## Stack\n1) React\n2) Tailwind CSS\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind"].sort()); + }); + + it("accepts comma-separated inline list on one line", () => { + const md = "## Tech Stack\n\nReact, Tailwind CSS, TypeScript\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind", "typescript"].sort()); + }); + it("accepts comma-separated inline mixed with bullets", () => { + const md = "## Tech Stack\n\n- React\nTailwind CSS, TypeScript\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind", "typescript"].sort()); + }); + it("does not accept comma text outside a stack heading", () => { + deepEqual(scanMarkdown("Intro\n\nReact, Vue, Svelte — pick one.\n", SKILLS_MAP), []); + }); + + it("accepts simple 2-column table (Tech | Version)", () => { + const md = + "## Tech Stack\n\n" + + "| Tech | Version |\n" + + "|------|---------|\n" + + "| React | 19 |\n" + + "| Tailwind CSS | 4 |\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind"].sort()); + }); + + it("picks 'Framework' column from 3-col table via header heuristic", () => { + const md = + "## Tech Stack\n\n" + + "| Category | Framework | Notes |\n" + + "|----------|-----------|-------|\n" + + "| UI | React | renderer |\n" + + "| Styling | Tailwind CSS | utility |\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind"].sort()); + }); + + it("falls back to first column when no heuristic keyword in header", () => { + const md = + "## Stack\n\n" + + "| Name | Purpose |\n" + + "|------|---------|\n" + + "| React | UI |\n"; + equal(scanMarkdown(md, SKILLS_MAP)[0]?.techId, "react"); + }); + + it("normalizes cell content: links, bold, backticks", () => { + const md = + "## Tech Stack\n\n" + + "| Tech |\n" + + "|------|\n" + + "| [React](https://react.dev) |\n" + + "| **Tailwind CSS** |\n" + + "| `typescript` |\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind", "typescript"].sort()); + }); + + it("splits comma-separated cell (| React, Tailwind |)", () => { + const md = + "## Tech Stack\n\n" + + "| Tech |\n" + + "|------|\n" + + "| React, Tailwind CSS |\n"; + const ids = scanMarkdown(md, SKILLS_MAP).map(m => m.techId).sort(); + deepEqual(ids, ["react", "tailwind"].sort()); + }); + + it("ignores table outside stack heading", () => { + const md = + "## Overview\n\n" + + "| Tech | Version |\n" + + "|------|---------|\n" + + "| React | 19 |\n"; + deepEqual(scanMarkdown(md, SKILLS_MAP), []); + }); + it("strips version numbers and parens from bullets", () => { equal(scanMarkdown("## Stack\n- React 19 (UI library)\n", SKILLS_MAP)[0]?.techId, "react"); }); @@ -183,3 +294,48 @@ describe("scanMarkdown — dedupe and edge cases", () => { equal(matches[0].techId, "react"); }); }); + +describe("normalizeHeadingTitle", () => { + it("returns plain title unchanged", () => { + equal(normalizeHeadingTitle("Tech Stack"), "Tech Stack"); + }); + it("strips leading numbering with dot (2. Tech Stack)", () => { + equal(normalizeHeadingTitle("2. Tech Stack"), "Tech Stack"); + }); + it("strips leading numbering with paren (1) Stack)", () => { + equal(normalizeHeadingTitle("1) Stack"), "Stack"); + }); + it("strips leading numbering with dash (3 - Dependencies)", () => { + equal(normalizeHeadingTitle("3 - Dependencies"), "Dependencies"); + }); + it("strips leading emoji prefix", () => { + equal(normalizeHeadingTitle("🚀 Tech Stack"), "Tech Stack"); + }); + it("strips trailing emoji", () => { + equal(normalizeHeadingTitle("Tech Stack 🚀"), "Tech Stack"); + }); + it("strips trailing colon", () => { + equal(normalizeHeadingTitle("Tech Stack:"), "Tech Stack"); + }); + it("unwraps bold **Tech Stack**", () => { + equal(normalizeHeadingTitle("**Tech Stack**"), "Tech Stack"); + }); + it("unwraps italic *Tech Stack*", () => { + equal(normalizeHeadingTitle("*Tech Stack*"), "Tech Stack"); + }); + it("unwraps underscore italic _Dependencies_", () => { + equal(normalizeHeadingTitle("_Dependencies_"), "Dependencies"); + }); + it("unwraps double-underscore __Dependencies__", () => { + equal(normalizeHeadingTitle("__Dependencies__"), "Dependencies"); + }); + it("unwraps brackets [Tech Stack]", () => { + equal(normalizeHeadingTitle("[Tech Stack]"), "Tech Stack"); + }); + it("strips trailing paren annotation", () => { + equal(normalizeHeadingTitle("Tech Stack (frontend)"), "Tech Stack"); + }); + it("combines numbering + bold + colon", () => { + equal(normalizeHeadingTitle("2. **Tech Stack**:"), "Tech Stack"); + }); +}); From 6b6c34a583fb514633ed1f70cc418595ec200c55 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Thu, 23 Apr 2026 23:25:49 -0500 Subject: [PATCH 12/16] feat(scan-docs): include README.md in auto-scan whitelist - loadMarkdownSources: add README.md alongside CLAUDE.md / AGENTS.md (order preserved) - warning text updated: "no CLAUDE.md, AGENTS.md, or README.md found" - tests: new README.md-only and three-file cases in load-md-sources - cli-from-spec: update warning assertion to match new string --- packages/autoskills/lib.ts | 2 +- packages/autoskills/main.ts | 2 +- packages/autoskills/tests/cli-from-spec.test.ts | 2 +- packages/autoskills/tests/load-md-sources.test.ts | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/autoskills/lib.ts b/packages/autoskills/lib.ts index 6c6d5491..cd210d46 100644 --- a/packages/autoskills/lib.ts +++ b/packages/autoskills/lib.ts @@ -684,7 +684,7 @@ export function loadMarkdownSources(args: { } if (args.scanDocs) { - for (const name of ["CLAUDE.md", "AGENTS.md"]) { + for (const name of ["CLAUDE.md", "AGENTS.md", "README.md"]) { const p = resolve(args.projectDir, name); if (existsSync(p) && !seen.has(p)) { out.push({ path: p, content: readFileSync(p, "utf-8") }); diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index 6b7eac20..780b9f37 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -540,7 +540,7 @@ async function main(): Promise { projectDir, }); if (scanDocs && !fromSpec && sources.length === 0) { - console.error(yellow(" warning: no CLAUDE.md or AGENTS.md found")); + console.error(yellow(" warning: no CLAUDE.md, AGENTS.md, or README.md found")); } if (sources.length > 0) { const mdMatches = sources.flatMap(s => scanMarkdown(s.content, SKILLS_MAP)); diff --git a/packages/autoskills/tests/cli-from-spec.test.ts b/packages/autoskills/tests/cli-from-spec.test.ts index 2a06ce0a..198d0521 100644 --- a/packages/autoskills/tests/cli-from-spec.test.ts +++ b/packages/autoskills/tests/cli-from-spec.test.ts @@ -38,7 +38,7 @@ describe("--from-spec / --scan-docs", () => { writePackageJson(tmp.path, { dependencies: { react: "^19" } }); const { stdout, stderr } = run(["--dry-run", "--scan-docs"], tmp.path); ok(stdout.toLowerCase().includes("react")); - ok(stderr.includes("no CLAUDE.md or AGENTS.md found"), "expected warning in stderr, got: " + stderr); + ok(stderr.includes("no CLAUDE.md, AGENTS.md, or README.md found"), "expected warning in stderr, got: " + stderr); }); it("--from-spec nonexistent exits 1 with clear error", () => { diff --git a/packages/autoskills/tests/load-md-sources.test.ts b/packages/autoskills/tests/load-md-sources.test.ts index 7e725480..f884d389 100644 --- a/packages/autoskills/tests/load-md-sources.test.ts +++ b/packages/autoskills/tests/load-md-sources.test.ts @@ -65,4 +65,19 @@ describe("loadMarkdownSources", () => { equal(sources.length, 1); ok(sources[0].path.endsWith("CLAUDE.md")); }); + + it("reads README.md when scanDocs is true", () => { + writeFile(tmp.path, "README.md", "# Hello\n\n## Tech Stack\n- React\n"); + const sources = loadMarkdownSources({ scanDocs: true, projectDir: tmp.path }); + equal(sources.length, 1); + equal(sources[0].content.includes("Tech Stack"), true); + }); + + it("reads CLAUDE.md + AGENTS.md + README.md together", () => { + writeFile(tmp.path, "CLAUDE.md", "# a\n"); + writeFile(tmp.path, "AGENTS.md", "# b\n"); + writeFile(tmp.path, "README.md", "# c\n"); + const sources = loadMarkdownSources({ scanDocs: true, projectDir: tmp.path }); + equal(sources.length, 3); + }); }); From 375460a913bbecfb9b99f1f26dbf0c26fba7055f Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Fri, 24 Apr 2026 00:07:14 -0500 Subject: [PATCH 13/16] =?UTF-8?q?refactor(scanner):=20drop=20Tecnolog?= =?UTF-8?q?=C3=ADas=20keyword=20+=20document=20flexible=20formats=20=20=20?= =?UTF-8?q?-=20STACK=5FKEYWORDS:=20remove=20"tecnolog=C3=ADas"=20/=20"tecn?= =?UTF-8?q?ologias"=20=E2=80=94=20keep=20keyword=20set=20language-agnostic?= =?UTF-8?q?;=20accepted=20headings=20self-evident=20from=20listed=20Englis?= =?UTF-8?q?h=20keywords=20=20=20-=20tests:=20update=20heading-keywords=20t?= =?UTF-8?q?est=20title=20+=20array;=20delete=20orphan=20fixture=20tests/fi?= =?UTF-8?q?xtures/specs/non-english.md=20(no=20test=20imported=20it)=20=20?= =?UTF-8?q?=20-=20packages/autoskills/README.md:=20rewrite=20"Bullet=20for?= =?UTF-8?q?mat"=20=E2=86=92=20"Supported=20formats=20under=20stack=20headi?= =?UTF-8?q?ngs"=20with=20heading-shape=20examples,=204=20content=20formats?= =?UTF-8?q?=20(dash/numbered=20bullets,=20GFM=20tables=20with=20header=20h?= =?UTF-8?q?euristic,=20comma-inline),=20normalization=20rules=20=20=20-=20?= =?UTF-8?q?packages/autoskills/README.md:=20document=20--from-spec=20exten?= =?UTF-8?q?sion=20flexibility=20(.md/.mdx/.markdown/.txt);=20note=20"Also:?= =?UTF-8?q?"=20prefixes=20not=20stripped;=20heuristic=20keyword=20list=20i?= =?UTF-8?q?ncludes=20plurals;=20stack-headings=20bullet=20points=20to=20ne?= =?UTF-8?q?w=20subsection=20=20=20-=20root=20README.md:=20sync=20Options?= =?UTF-8?q?=20+=20expand=20blockquote=20(fences,=20keywords,=20content=20f?= =?UTF-8?q?ormats,=20decorated=20headings,=20tables-outside-heading=20excl?= =?UTF-8?q?usion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- packages/autoskills/README.md | 97 +++++++++++++------ packages/autoskills/markdown-scanner.ts | 2 - .../tests/fixtures/specs/non-english.md | 4 - .../autoskills/tests/markdown-scanner.test.ts | 4 +- 5 files changed, 72 insertions(+), 41 deletions(-) delete mode 100644 packages/autoskills/tests/fixtures/specs/non-english.md diff --git a/README.md b/README.md index a3d19fa6..ce9ae089 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,12 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` also writes -y, --yes Skip confirmation prompt --dry-run Show what would be installed without installing --json Emit structured JSON (with --dry-run or subcommands) ---from-spec Detect tech from a markdown spec file ---scan-docs Auto-scan CLAUDE.md / AGENTS.md in the project +--from-spec Detect tech from a markdown spec file (any extension) +--scan-docs Auto-scan CLAUDE.md / AGENTS.md / README.md in the project -h, --help Show help message ``` -> `--from-spec` and `--scan-docs` only parse code fences (`json`, `bash`/`sh`, `yaml`/`toml`, `ruby`) and **bullet lists** under headings like `## Tech Stack` / `## Stack` / `## Dependencies`. Markdown tables are ignored. See [Markdown scanner](./packages/autoskills/README.md#markdown-scanner-opt-in) for bullet shapes and examples. +> `--from-spec` and `--scan-docs` parse **code fences** (`json`, `bash`/`sh`/`shell`/`zsh`, `yaml`/`yml`/`toml`, `ruby`/`gemfile`) plus content under **stack headings** (`## Tech Stack`, `## Stack`, `## Dependencies`, `## Built With`, `## Technologies`). Under a heading we accept dash/numbered bullets, GFM tables, and comma-separated inline lists. Decorated headings (`## 2. Tech Stack`, `## 🚀 Stack`, `## **Dependencies**`) are recognized. Markdown tables outside a stack heading are ignored. See [Markdown scanner](./packages/autoskills/README.md#markdown-scanner-opt-in) for details. ## LLM-driven mode diff --git a/packages/autoskills/README.md b/packages/autoskills/README.md index 6fef3619..00485fe6 100644 --- a/packages/autoskills/README.md +++ b/packages/autoskills/README.md @@ -48,7 +48,7 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` writes a `CL | `--dry-run` | Show detected skills without installing | | `--json` | Emit structured JSON (used with `--dry-run` or subcommands; errors return `{error:{code,message}}`) | | `--from-spec ` | Scan a markdown spec file for tech (code fences + Tech Stack headings) | -| `--scan-docs` | Auto-scan `CLAUDE.md` / `AGENTS.md` in the project root | +| `--scan-docs` | Auto-scan `CLAUDE.md` / `AGENTS.md` / `README.md` in the project root | | `-v`, `--verbose` | Show error details if any installation fails | | `-a`, `--agent ` | Install for specific IDEs only (e.g. `cursor`, `claude-code`) | | `-h`, `--help` | Show help message | @@ -56,7 +56,11 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` writes a `CL ## Markdown scanner (opt-in) Detect tech from structured markdown docs — feature specs, `CLAUDE.md`, -`AGENTS.md` — without having to populate `package.json`. +`AGENTS.md`, or `README.md` — without having to populate `package.json`. + +`--from-spec ` reads the file's contents regardless of extension. +`.md`, `.mdx`, `.markdown`, or `.txt` all work as long as the content uses +markdown code fences and headings. ```bash # Scan a specific spec file @@ -72,49 +76,82 @@ npx autoskills --scan-docs --dry-run The scanner recognizes two structures: - **Code fences** — `json` (reads `dependencies`/`devDependencies`), `bash`/`sh`/`shell`/`zsh` (extracts `npm|pnpm|yarn|bun add/install` packages), `yaml`/`yml`/`toml`, `ruby`/`gemfile` (`gem ''`). -- **Stack headings** — `## Tech Stack`, `## Stack`, `## Dependencies`, `## Built With`, `## Technologies`, `## Tecnologías` (English + Spanish, case-insensitive, h1–h3). Bullets under the heading are matched against technology names and aliases. +- **Stack headings** — `## Tech Stack`, `## Stack`, `## Dependencies`, `## Built With`, `## Technologies` (case-insensitive, h1–h3). Content under the heading is matched against technology names and aliases — see "Supported formats under stack headings" below for bullet, table, numbered-list, and inline shapes. Prose ("we'll use React") outside these structures is ignored to prevent false positives. -### Bullet format (under stack headings) - -Only bullet lists are parsed. **Markdown tables are not recognized** — convert to bullets or add a code fence. +### Supported formats under stack headings -Each bullet must resolve to a `name` or alias in the catalog (`autoskills list --json`). The following shapes all match **Astro**: +**Heading shapes recognized** (h1–h3, case-insensitive, keyword must be exact after stripping decoration): ```markdown ## Tech Stack - -- Astro -- Astro (SSR) -- Astro — 4.0 -- Astro - 4.0 -- Astro 4.0.1 +## 2. Tech Stack +## 1) Stack +## 🚀 Tech Stack +## **Dependencies** +## __Dependencies__ +## [Stack] +## Tech Stack: +## Tech Stack (frontend) ``` -Normalization rules applied to each bullet: +Prose narrative headings like `## Why we chose our Stack` are rejected (decoration is stripped but the keyword itself must be the whole title). -1. Parenthetical annotations are dropped — `Astro (SSR)` → `Astro`. -2. Em-dash / en-dash / hyphen + trailing text is dropped — `Astro — 4.0` → `Astro`. -3. A trailing version token is dropped — `Astro 4.0.1` → `Astro`. +Valid keywords (after normalization, case-insensitive): `Tech Stack`, `Stack`, `Dependencies`, `Built With`, `Technologies`. -Tables are ignored, even under a supported heading: +**Content shapes recognized under the heading:** -```markdown -## Tech Stack +1. **Dash / asterisk / plus bullets** -| Tech | Version | -| ----- | ------- | -| Astro | 4.0 | -``` + ```markdown + ## Tech Stack + - Astro + - Tailwind CSS + ``` -For tables or free-form docs, add a fenced block with dependencies instead: +2. **Numbered bullets** (`1.` or `1)`) -````markdown -```json -{ "devDependencies": { "astro": "^4.0.0" } } -``` -```` + ```markdown + ## Tech Stack + 1. Astro + 2. Tailwind CSS + ``` + +3. **GFM tables** — first column by default; if the header row contains a column matching `tech|technology|technologies|framework|library|libraries|package|packages|dependency|dependencies|name|stack` (case-insensitive), that column is used instead. + + ```markdown + ## Tech Stack + + | Category | Framework | Notes | + | -------- | --------- | ---------- | + | UI | React | renderer | + | Styling | Tailwind | utility | + ``` + + Cell content is normalized: links (`[Astro](https://astro.build)` → `Astro`), bold/italic wrappers, backticks, images, and leading emoji are stripped. Multi-tech cells are split on commas (`| Astro, Tailwind |`). + +4. **Comma-separated inline** — a non-bullet line with commas is split and each piece is matched + + ```markdown + ## Tech Stack + + Astro, Tailwind CSS, TypeScript + ``` + + Note: prefixes like `"Also: Astro, Tailwind"` are NOT parsed — `normalizeBullet` does not strip colon-prefixed labels. Use plain `"Astro, Tailwind"`. + +**Bullet / inline-piece / cell normalization** + +After the format-specific extraction, each candidate string goes through: + +1. Parenthetical annotations are dropped — `Astro (SSR)` → `Astro`. +2. Em-dash, en-dash, or space-hyphen-space + trailing text is dropped — `Astro — 4.0` → `Astro`; `Astro - 4.0` → `Astro`. (Inline hyphens without surrounding spaces are preserved — `shadcn/ui` is kept intact.) +3. A trailing digit-leading version token is dropped — `Astro 4.0.1` → `Astro`. (Non-digit trailing tokens like `latest` are not stripped yet.) + +The resulting string must match a technology `name` or `alias` exactly (run `autoskills list --json` to see the catalog). + +Tables, bullets, inline comma-separated lines, and numbered lists **only** parse under a recognized stack heading — free-floating tables or lists elsewhere in the document are ignored. **Default behavior is unchanged.** No markdown is read unless `--from-spec` or `--scan-docs` is passed. diff --git a/packages/autoskills/markdown-scanner.ts b/packages/autoskills/markdown-scanner.ts index 31b68985..5e24e745 100644 --- a/packages/autoskills/markdown-scanner.ts +++ b/packages/autoskills/markdown-scanner.ts @@ -122,8 +122,6 @@ const STACK_KEYWORDS = new Set([ "dependencies", "built with", "technologies", - "tecnologías", - "tecnologias", ]); function isStackHeading(line: string): { level: number } | null { diff --git a/packages/autoskills/tests/fixtures/specs/non-english.md b/packages/autoskills/tests/fixtures/specs/non-english.md deleted file mode 100644 index 326013f8..00000000 --- a/packages/autoskills/tests/fixtures/specs/non-english.md +++ /dev/null @@ -1,4 +0,0 @@ -## Tecnologías - -- Next.js -- Tailwind CSS diff --git a/packages/autoskills/tests/markdown-scanner.test.ts b/packages/autoskills/tests/markdown-scanner.test.ts index 77bf14a6..3b42563f 100644 --- a/packages/autoskills/tests/markdown-scanner.test.ts +++ b/packages/autoskills/tests/markdown-scanner.test.ts @@ -118,8 +118,8 @@ describe("scanMarkdown — stack headings", () => { deepEqual(ids, ["react", "tailwind"].sort()); }); - it("accepts Stack | Dependencies | Built With | Technologies | Tecnologías", () => { - for (const title of ["Stack", "Dependencies", "Built With", "Technologies", "Tecnologías"]) { + it("accepts Stack | Dependencies | Built With | Technologies", () => { + for (const title of ["Stack", "Dependencies", "Built With", "Technologies"]) { const md = `## ${title}\n- React\n`; equal(scanMarkdown(md, SKILLS_MAP)[0]?.techId, "react"); } From 986fd99c8a7cd9dc60a4bb2623f25e90187c9ad4 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Fri, 24 Apr 2026 01:14:05 -0500 Subject: [PATCH 14/16] =?UTF-8?q?feat(cli):=20add=20--copy-prompt=20+=20re?= =?UTF-8?q?write=20prompt=20for=20spec-doc=20workflow=20=20=20-=20clipboar?= =?UTF-8?q?d.ts:=20cross-platform=20copy=20via=20spawn=20(pbcopy/wl-copy?= =?UTF-8?q?=E2=86=92xclip/clip.exe),=20zero=20deps;=20ENOENT/exit/error=20?= =?UTF-8?q?all=20distinguished=20=20=20-=20subcommands.ts:=20runCopyPrompt?= =?UTF-8?q?=20reuses=20resolvePromptPath;=20success=20=E2=86=92=20?= =?UTF-8?q?=E2=9C=93=20msg=20+=20Cmd/Ctrl+V=20hint;=20failure=20=E2=86=92?= =?UTF-8?q?=20prompt=20to=20stdout=20+=20warning=20to=20stderr=20(exit=200?= =?UTF-8?q?)=20=20=20-=20main.ts:=20--copy-prompt=20early-exit=20before=20?= =?UTF-8?q?subcommand=20dispatch=20(mirrors=20--help)=20=20=20-=20prompts/?= =?UTF-8?q?skill-selection.md=20=E2=86=92=20spec-generator-prompt.md:=20LL?= =?UTF-8?q?M=20now=20generates=20docs/specs-initial.md=20from=20user=20req?= =?UTF-8?q?uirement=20(heading=20+=20dash=20bullets),=20instructs=20user?= =?UTF-8?q?=20to=20run=20--from-spec=20themselves=20(no=20install)=20=20?= =?UTF-8?q?=20-=2013=20new=20tests=20(8=20clipboard=20+=205=20copy-prompt)?= =?UTF-8?q?;=20497/497=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- packages/autoskills/README.md | 14 +- packages/autoskills/clipboard.ts | 84 ++++++++++++ packages/autoskills/main.ts | 13 +- .../autoskills/prompts/skill-selection.md | 45 ------- .../prompts/spec-generator-prompt.md | 49 +++++++ packages/autoskills/subcommands.ts | 58 ++++++++- packages/autoskills/tests/clipboard.test.ts | 121 ++++++++++++++++++ packages/autoskills/tests/copy-prompt.test.ts | 106 +++++++++++++++ .../tests/subcommands-list-prompt.test.ts | 6 +- packages/autoskills/tests/subcommands.test.ts | 24 +++- 11 files changed, 466 insertions(+), 62 deletions(-) create mode 100644 packages/autoskills/clipboard.ts delete mode 100644 packages/autoskills/prompts/skill-selection.md create mode 100644 packages/autoskills/prompts/spec-generator-prompt.md create mode 100644 packages/autoskills/tests/clipboard.test.ts create mode 100644 packages/autoskills/tests/copy-prompt.test.ts diff --git a/README.md b/README.md index ce9ae089..224d6832 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` also writes --json Emit structured JSON (with --dry-run or subcommands) --from-spec Detect tech from a markdown spec file (any extension) --scan-docs Auto-scan CLAUDE.md / AGENTS.md / README.md in the project +--copy-prompt Copy the shipped spec-generator prompt to the OS clipboard -h, --help Show help message ``` @@ -46,15 +47,16 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` also writes ## LLM-driven mode -Beyond structural detection, `autoskills` exposes atomic subcommands so an external LLM CLI (Claude Code, Cursor, Codex) can reason over prose specs and drive installation: +Beyond structural detection, `autoskills` exposes atomic subcommands so an external LLM CLI (Claude Code, Cursor, Codex) can reason over your requirement and produce a parseable spec: ```bash npx autoskills list --json # full catalog -npx autoskills prompt # shipped selection guide +npx autoskills prompt # shipped spec-generator prompt (stdout) +npx autoskills --copy-prompt # copy spec-generator prompt to clipboard npx autoskills install --only ``` -See the [package README](./packages/autoskills/README.md) for details. +**Spec-doc flow:** run `--copy-prompt`, paste it under your requirement in any LLM chat, and the LLM writes `docs/specs-initial.md` for you to feed back via `autoskills --from-spec`. See the [package README](./packages/autoskills/README.md#subcommands-for-llm-integration) for the full workflow. ## Supported Technologies diff --git a/packages/autoskills/README.md b/packages/autoskills/README.md index 00485fe6..db0fcf29 100644 --- a/packages/autoskills/README.md +++ b/packages/autoskills/README.md @@ -49,6 +49,7 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` writes a `CL | `--json` | Emit structured JSON (used with `--dry-run` or subcommands; errors return `{error:{code,message}}`) | | `--from-spec ` | Scan a markdown spec file for tech (code fences + Tech Stack headings) | | `--scan-docs` | Auto-scan `CLAUDE.md` / `AGENTS.md` / `README.md` in the project root | +| `--copy-prompt` | Copy the shipped spec-generator prompt to the OS clipboard | | `-v`, `--verbose` | Show error details if any installation fails | | `-a`, `--agent ` | Install for specific IDEs only (e.g. `cursor`, `claude-code`) | | `-h`, `--help` | Show help message | @@ -164,18 +165,25 @@ Atomic subcommands let an external LLM CLI (Claude Code, Cursor, Codex) drive au npx autoskills list --json npx autoskills list --filter react # or: npx autoskills list react -# Print the shipped skill-selection prompt (LLM guidance) +# Print the shipped spec-generator prompt (LLM guidance) npx autoskills prompt # stdout the prompt npx autoskills prompt --path # print absolute path +npx autoskills --copy-prompt # copy prompt to OS clipboard # Install specific skills by id npx autoskills install --only react,tailwind -y npx autoskills install --only react -a claude-code cursor ``` -Typical LLM workflow: fetch the guide (`autoskills prompt`) + catalog (`autoskills list --json`), reason over the user's spec, propose skills, then call `autoskills install --only ` after confirmation. +The shipped prompt drives a **spec-doc workflow**: -The skill-selection prompt is shipped at `prompts/skill-selection.md` inside the package and covers workflow, category inference, matching rules, and alias resolution. +1. Run `autoskills --copy-prompt` (clipboard gets the prompt). +2. Open your LLM chat. Write your project requirement first ("I want a task manager with Next.js + Tailwind + Supabase"), then paste the prompt below it. +3. The LLM fetches the catalog (`autoskills list --json`), matches techs from your requirement to canonical names, and writes `docs/specs-initial.md` with a `## Tech Stack` section the markdown scanner can parse. +4. The LLM tells you to run `autoskills --from-spec docs/specs-initial.md` in another terminal — it does **not** install anything itself. +5. You run `--from-spec`. autoskills detects + installs deterministically. + +The shipped prompt lives at `prompts/spec-generator-prompt.md` inside the package. All subcommands emit structured JSON errors when `--json` is passed, for programmatic parsing. Error codes include `unknown-subcommand`, `install-missing-only`, `install-empty-only`, `install-unknown-id` (with fuzzy-match suggestion), `json-requires-subcommand-or-dry-run`, `cli-arg-invalid`, `internal-error`, and `prompt-file-missing`. diff --git a/packages/autoskills/clipboard.ts b/packages/autoskills/clipboard.ts new file mode 100644 index 00000000..9b8f61b2 --- /dev/null +++ b/packages/autoskills/clipboard.ts @@ -0,0 +1,84 @@ +import { spawn as realSpawn } from "node:child_process"; + +export interface CopyResult { + ok: boolean; + tool?: string; + error?: string; +} + +interface Candidate { cmd: string; args: string[] } + +interface SpawnedProcess { + stdin: { write(s: string): void; end(): void }; + on(event: "close" | "error", cb: (...args: unknown[]) => void): unknown; +} + +type SpawnFn = (cmd: string, args: string[]) => SpawnedProcess; + +export interface CopyOptions { + platform?: NodeJS.Platform; + spawnFn?: SpawnFn; +} + +function candidatesFor(platform: NodeJS.Platform): Candidate[] { + if (platform === "darwin") return [{ cmd: "pbcopy", args: [] }]; + if (platform === "win32") return [{ cmd: "clip.exe", args: [] }]; + if (platform === "linux") { + return [ + { cmd: "wl-copy", args: [] }, + { cmd: "xclip", args: ["-selection", "clipboard"] }, + ]; + } + return []; +} + +function tryOne(spawnFn: SpawnFn, cmd: string, args: string[], text: string): Promise { + return new Promise((resolve) => { + let settled = false; + const settle = (r: CopyResult & { _enoent?: boolean }) => { + if (settled) return; + settled = true; + resolve(r); + }; + const child = spawnFn(cmd, args); + child.on("error", (...evtArgs) => { + const err = evtArgs[0] as NodeJS.ErrnoException; + if (err && err.code === "ENOENT") { + settle({ ok: false, _enoent: true }); + return; + } + settle({ ok: false, error: err?.message ?? "spawn error" }); + }); + child.on("close", (...evtArgs) => { + const code = evtArgs[0] as number | null; + if (code === 0) settle({ ok: true, tool: cmd }); + else settle({ ok: false, error: `${cmd} exit ${code}` }); + }); + try { + child.stdin.write(text); + child.stdin.end(); + } catch (err) { + settle({ ok: false, error: (err as Error).message }); + } + }); +} + +export async function copyToClipboard(text: string, opts: CopyOptions = {}): Promise { + const platform = opts.platform ?? process.platform; + const spawnFn = opts.spawnFn ?? (realSpawn as unknown as SpawnFn); + const candidates = candidatesFor(platform); + if (candidates.length === 0) { + return { ok: false, error: `unsupported platform: ${platform}` }; + } + let lastError: string | undefined; + for (const { cmd, args } of candidates) { + const r = await tryOne(spawnFn, cmd, args, text); + if (r.ok) return r; + if (r._enoent) { + lastError = "no clipboard tool found"; + continue; + } + return { ok: false, error: r.error }; + } + return { ok: false, error: lastError ?? "no clipboard tool found" }; +} diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index 780b9f37..0ad9424d 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -31,7 +31,7 @@ import { import { printBanner, multiSelect, formatTime } from "./ui.ts"; import { installAll, resolveSkillsBin } from "./installer.ts"; import { cleanupClaudeMd } from "./claude.ts"; -import { runList, runPrompt, runInstall } from "./subcommands.ts"; +import { runList, runPrompt, runInstall, runCopyPrompt } from "./subcommands.ts"; import { serializeDryRun, serializeError } from "./cli-json.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -69,6 +69,7 @@ interface CliArgs { agents: string[]; fromSpec?: string; scanDocs: boolean; + copyPrompt: boolean; // subcommand dispatch subcommand?: string; json: boolean; @@ -149,6 +150,7 @@ function parseArgs(): CliArgs { agents, fromSpec, scanDocs: args.includes("--scan-docs"), + copyPrompt: args.includes("--copy-prompt"), subcommand, json: args.includes("--json"), only, @@ -167,7 +169,8 @@ function showHelp(): void { npx autoskills ${dim("--dry-run")} ${dim("[--json]")} Show what would be installed npx autoskills ${dim("-a cursor claude-code")} Install for specific IDEs only npx autoskills ${dim("list")} ${dim("[--json] [--filter ]")} List catalog - npx autoskills ${dim("prompt")} ${dim("[--path]")} Print skill-selection prompt + npx autoskills ${dim("prompt")} ${dim("[--path]")} Print spec-generator prompt + npx autoskills ${dim("--copy-prompt")} Copy spec-generator prompt to clipboard npx autoskills ${dim("install --only ")} ${dim("[-a agents] [-y] [--json]")} ${bold("Options:")} @@ -177,6 +180,7 @@ function showHelp(): void { -a, --agent Install for specific IDEs only (e.g. cursor, claude-code) --from-spec Detect tech from a markdown spec file --scan-docs Auto-scan CLAUDE.md / AGENTS.md in project root + --copy-prompt Copy spec-generator prompt to clipboard --json JSON output (subcommands / dry-run) --only Comma-separated tech ids for 'install' --filter Filter catalog for 'list' @@ -466,6 +470,11 @@ async function main(): Promise { process.exit(0); } + if (args.copyPrompt) { + const code = await runCopyPrompt(); + process.exit(code); + } + // ── Subcommand dispatch (BEFORE any default-flow side-effects) ── if (args.subcommand !== undefined) { const KNOWN = new Set(["list", "prompt", "install"]); diff --git a/packages/autoskills/prompts/skill-selection.md b/packages/autoskills/prompts/skill-selection.md deleted file mode 100644 index 28f271c7..00000000 --- a/packages/autoskills/prompts/skill-selection.md +++ /dev/null @@ -1,45 +0,0 @@ -# autoskills — Skill Selection Guide - -You are helping a user select skills for their project using autoskills. - -## What is autoskills - -autoskills is a CLI that installs curated AI skills (markdown instruction -bundles) for agents like Claude Code, Cursor, Cline, Codex. - -You interact with autoskills via: -- `autoskills list --json` — full catalog -- `autoskills install --only ` — install specific skills -- `autoskills --dry-run --json` — structural detection baseline - -## Workflow - -1. Read the user's spec or project context. -2. Run `autoskills list --json` to get the catalog. -3. Optionally run `autoskills --dry-run --json` to see what structural detection finds. -4. Match user's needs to technologies in the catalog. -5. Propose a list to the user with reasoning per skill. -6. After confirmation, run `autoskills install --only `. - -## Categories (infer, not hardcoded) - -- **Frontend** — UI, styling, a11y, SEO → React, Vue, Svelte, Astro, Next, Tailwind plus generalist (frontend-design, accessibility, seo). -- **Backend** — APIs, databases, auth → Express, Fastify, Hono, NestJS, Spring, ASP.NET, Rails, Prisma. -- **Mobile** — native / cross-platform → Expo, React Native, Flutter, SwiftUI. -- **DevOps / Cloud** — deploy, IaC, edge → Vercel, Cloudflare, AWS, Terraform. - -## Rules - -- Do not suggest skills for techs not mentioned or inferred. -- Prefer combos when both required techs are present. -- Include frontend_bonus when the project is clearly frontend. -- Match by aliases ("Next.js" → tech id `nextjs`). -- Ambiguous? Ask the user before installing. -- Always list your reasoning before proposing an install. - -## Matching hints - -- Prose counts: "built with React" → react. -- Negations matter: "don't want jQuery" → skip jQuery. -- Synonyms: "edge functions" → Cloudflare Workers or Vercel. -- Stack shorthands: "MERN" → mongo + express + react + node. diff --git a/packages/autoskills/prompts/spec-generator-prompt.md b/packages/autoskills/prompts/spec-generator-prompt.md new file mode 100644 index 00000000..5339a035 --- /dev/null +++ b/packages/autoskills/prompts/spec-generator-prompt.md @@ -0,0 +1,49 @@ +# autoskills — Spec-Doc Generator + +You help the user turn their requirement (written above this message in the chat) into a tech-stack spec doc that `autoskills` can parse and act on. + +## What is autoskills + +autoskills is a CLI that installs curated AI skills (markdown instruction +bundles) for agents like Claude Code, Cursor, Cline, Codex. + +## Your job + +1. **Read the user's requirement** (the message they wrote above this prompt). If empty or unclear, ask before doing anything else. + +2. **Get the catalog:** run `autoskills list --json` to fetch all supported technologies and their canonical names + aliases. Use the canonical `name` field exactly (e.g. `Next.js`, not `NextJS` or `nextjs`). + +3. **Match requirement → catalog.** Identify only techs the user actually mentioned or strongly implied. Aliases count. Negations count ("no jQuery" → skip jQuery). Stack shorthands count ("MERN" → MongoDB + Express + React + Node). + +4. **Show the proposed `Tech Stack` to the user before writing the file.** Brief reasoning per tech. Wait for confirmation. + +5. **Write `docs/specs-initial.md`** with this exact shape (heading + dash bullets — the simplest format the markdown scanner parses): + + ```md + # Project Spec + + + + ## Tech Stack + + - Next.js + - React + - Tailwind CSS + ``` + + One tech per bullet. No versions, no parentheticals, no extra columns. + +6. **Stop. Do NOT run any `autoskills` install command.** End your reply with this exact instruction to the user: + + > Spec written to `docs/specs-initial.md`. In another terminal, run: + > ``` + > autoskills --from-spec docs/specs-initial.md + > ``` + +## Rules + +- Use names from `autoskills list --json`. Don't invent techs. +- If the requirement is ambiguous, ask the user before writing the doc. +- Don't suggest skills for techs the user didn't mention or imply. +- Don't run `autoskills install` — the user runs `--from-spec` themselves. +- Don't add categories or sections beyond `## Tech Stack` unless the user asks. diff --git a/packages/autoskills/subcommands.ts b/packages/autoskills/subcommands.ts index 20abb409..338b866e 100644 --- a/packages/autoskills/subcommands.ts +++ b/packages/autoskills/subcommands.ts @@ -3,9 +3,11 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { serializeList, serializeError, serializeInstall } from "./cli-json.ts"; import type { ListJson } from "./cli-json.ts"; -import { log, write, green, red } from "./colors.ts"; +import { log, write, green, red, yellow, dim } from "./colors.ts"; import { SKILLS_MAP } from "./skills-map.ts"; import { installSkill as defaultInstallSkill } from "./installer.ts"; +import { copyToClipboard } from "./clipboard.ts"; +import type { CopyResult } from "./clipboard.ts"; // AGENTS.md: never use console.log / process.stdout.write directly. Use log/write from colors.ts. Errors go to console.error. @@ -40,14 +42,14 @@ export interface RunPromptArgs { } /** - * Candidate paths for the shipped prompts/skill-selection.md. + * Candidate paths for the shipped prompts/spec-generator-prompt.md. * When running from TypeScript sources, SUBCOMMANDS_DIR is the package root. * When running from the built tarball, SUBCOMMANDS_DIR is /dist/, so we walk up one level. */ function resolvePromptPath(): string { const candidates = [ - resolve(SUBCOMMANDS_DIR, "prompts", "skill-selection.md"), - resolve(SUBCOMMANDS_DIR, "..", "prompts", "skill-selection.md"), + resolve(SUBCOMMANDS_DIR, "prompts", "spec-generator-prompt.md"), + resolve(SUBCOMMANDS_DIR, "..", "prompts", "spec-generator-prompt.md"), ]; for (const p of candidates) { try { @@ -89,6 +91,54 @@ export function runPrompt(args: RunPromptArgs): number { return 0; } +// ── runCopyPrompt ──────────────────────────────────────────── + +export interface RunCopyPromptArgs { + /** Override the prompt file path (for tests). */ + promptPath?: string; + /** Inject clipboard fn (for tests). */ + copyFn?: (text: string) => Promise; + /** Override platform (for tests). */ + platform?: NodeJS.Platform; +} + +export async function runCopyPrompt(args: RunCopyPromptArgs = {}): Promise { + const promptPath = args.promptPath ?? resolvePromptPath(); + let content: string; + try { + content = readFileSync(promptPath, "utf-8"); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === "ENOENT") { + console.error( + JSON.stringify( + serializeError({ + code: "prompt-file-missing", + message: "prompt file missing from package (build issue). reinstall autoskills", + }), + ), + ); + return 1; + } + throw err; + } + + const platform = args.platform ?? process.platform; + const copy = args.copyFn ?? ((text: string) => copyToClipboard(text, { platform })); + const result = await copy(content); + const shortcut = platform === "darwin" ? "Cmd+V" : "Ctrl+V"; + + if (result.ok) { + log(green("✓ prompt copied to clipboard ") + dim(`(${content.length} chars)`)); + log(dim(` go to your LLM chat, write your requirement, then paste below (${shortcut})`)); + return 0; + } + + console.error(yellow(`warning: ${result.error ?? "clipboard copy failed"}. prompt printed below — pipe to your clipboard tool (e.g. 'autoskills --copy-prompt | pbcopy')`)); + write(content); + return 0; +} + // ── runInstall ──────────────────────────────────────────────── export interface InstallDeps { diff --git a/packages/autoskills/tests/clipboard.test.ts b/packages/autoskills/tests/clipboard.test.ts new file mode 100644 index 00000000..df8c888c --- /dev/null +++ b/packages/autoskills/tests/clipboard.test.ts @@ -0,0 +1,121 @@ +import { describe, it } from "node:test"; +import { equal, ok, deepEqual } from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { copyToClipboard } from "../clipboard.ts"; + +interface SpawnCall { cmd: string; args: string[]; stdin: string } + +interface FakeSpawnConfig { + /** Per-cmd outcome. Missing entry = ENOENT. */ + exitCodes?: Record; + /** Per-cmd raw error to emit on "error" event before close. Bypasses exitCodes for that cmd. */ + errors?: Record; +} + +function makeFakeSpawn(cfg: FakeSpawnConfig): { + spawn: (cmd: string, args: string[]) => unknown; + calls: SpawnCall[]; +} { + const calls: SpawnCall[] = []; + const spawn = (cmd: string, args: string[]) => { + const call: SpawnCall = { cmd, args, stdin: "" }; + calls.push(call); + const child = new EventEmitter() as EventEmitter & { + stdin: { write: (s: string) => void; end: () => void }; + }; + child.stdin = { + write(s: string) { call.stdin += s; }, + end() { + const err = cfg.errors?.[cmd]; + if (err) { + queueMicrotask(() => child.emit("error", err)); + return; + } + if (!(cmd in (cfg.exitCodes ?? {}))) { + // Simulate ENOENT (binary not on PATH) + const enoent = Object.assign(new Error(`spawn ${cmd} ENOENT`), { code: "ENOENT" }) as NodeJS.ErrnoException; + queueMicrotask(() => child.emit("error", enoent)); + return; + } + const code = cfg.exitCodes![cmd]; + queueMicrotask(() => child.emit("close", code)); + }, + }; + return child; + }; + return { spawn, calls }; +} + +describe("clipboard.copyToClipboard", () => { + it("darwin invokes pbcopy and writes text to stdin", async () => { + const { spawn, calls } = makeFakeSpawn({ exitCodes: { pbcopy: 0 } }); + const result = await copyToClipboard("hello world", { platform: "darwin", spawnFn: spawn }); + equal(result.ok, true); + equal(result.tool, "pbcopy"); + equal(calls.length, 1); + equal(calls[0].cmd, "pbcopy"); + deepEqual(calls[0].args, []); + equal(calls[0].stdin, "hello world"); + }); + + it("linux tries wl-copy first, falls back to xclip on ENOENT", async () => { + const { spawn, calls } = makeFakeSpawn({ exitCodes: { xclip: 0 } }); + const result = await copyToClipboard("payload", { platform: "linux", spawnFn: spawn }); + equal(result.ok, true); + equal(result.tool, "xclip"); + equal(calls.length, 2); + equal(calls[0].cmd, "wl-copy"); + equal(calls[1].cmd, "xclip"); + deepEqual(calls[1].args, ["-selection", "clipboard"]); + equal(calls[1].stdin, "payload"); + }); + + it("linux uses wl-copy when available", async () => { + const { spawn, calls } = makeFakeSpawn({ exitCodes: { "wl-copy": 0 } }); + const result = await copyToClipboard("payload", { platform: "linux", spawnFn: spawn }); + equal(result.ok, true); + equal(result.tool, "wl-copy"); + equal(calls.length, 1); + equal(calls[0].cmd, "wl-copy"); + }); + + it("win32 invokes clip.exe", async () => { + const { spawn, calls } = makeFakeSpawn({ exitCodes: { "clip.exe": 0 } }); + const result = await copyToClipboard("payload", { platform: "win32", spawnFn: spawn }); + equal(result.ok, true); + equal(result.tool, "clip.exe"); + equal(calls.length, 1); + equal(calls[0].cmd, "clip.exe"); + equal(calls[0].stdin, "payload"); + }); + + it("returns ok:false when no clipboard tool found (all ENOENT)", async () => { + const { spawn } = makeFakeSpawn({}); + const result = await copyToClipboard("x", { platform: "linux", spawnFn: spawn }); + equal(result.ok, false); + ok(result.error?.includes("no clipboard tool")); + }); + + it("non-zero exit code returns ok:false with error", async () => { + const { spawn } = makeFakeSpawn({ exitCodes: { pbcopy: 1 } }); + const result = await copyToClipboard("x", { platform: "darwin", spawnFn: spawn }); + equal(result.ok, false); + ok(result.error?.includes("exit")); + }); + + it("non-ENOENT spawn error returns ok:false with error", async () => { + const err = Object.assign(new Error("permission denied"), { code: "EACCES" }) as NodeJS.ErrnoException; + const { spawn } = makeFakeSpawn({ errors: { pbcopy: err } }); + const result = await copyToClipboard("x", { platform: "darwin", spawnFn: spawn }); + equal(result.ok, false); + ok(result.error?.includes("permission denied")); + }); + + it("unknown platform returns ok:false", async () => { + const { spawn, calls } = makeFakeSpawn({}); + const result = await copyToClipboard("x", { platform: "freebsd" as NodeJS.Platform, spawnFn: spawn }); + equal(result.ok, false); + equal(calls.length, 0); + ok(result.error?.includes("unsupported platform")); + }); +}); diff --git a/packages/autoskills/tests/copy-prompt.test.ts b/packages/autoskills/tests/copy-prompt.test.ts new file mode 100644 index 00000000..db0493d9 --- /dev/null +++ b/packages/autoskills/tests/copy-prompt.test.ts @@ -0,0 +1,106 @@ +import { describe, it } from "node:test"; +import { equal, ok } from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { runCopyPrompt } from "../subcommands.ts"; +import type { CopyResult } from "../clipboard.ts"; + +// Reuse the captureStdio pattern from subcommands-list-prompt.test.ts (kept inline to avoid +// cross-test coupling — captureStdio mutates a shared prototype and must be self-contained). +function captureStdio(fn: () => Promise | T): Promise<{ out: string; err: string; result: T }> { + let out = ""; + let err = ""; + const stdoutProto = Object.getPrototypeOf(process.stdout) as { + _write: (chunk: Buffer | string, encoding: string, cb: (err?: Error | null) => void) => void; + }; + const origUnderscoreWrite = stdoutProto._write; + const origErr = console.error; + stdoutProto._write = function (chunk, encoding, cb) { + if (this === process.stdout) { + out += typeof chunk === "string" ? chunk : chunk.toString(); + cb(); + return; + } + return origUnderscoreWrite.call(this, chunk, encoding, cb); + }; + console.error = (...args: unknown[]) => { + err += args.map(String).join(" ") + "\n"; + }; + const restore = () => { + stdoutProto._write = origUnderscoreWrite; + console.error = origErr; + }; + return Promise.resolve(fn()).then( + (result) => { restore(); return { out, err, result }; }, + (e) => { restore(); throw e; }, + ); +} + +const PROMPT_PATH = resolve(import.meta.dirname!, "..", "prompts", "spec-generator-prompt.md"); + +describe("runCopyPrompt", () => { + it("copies prompt to clipboard and prints success + Cmd+V hint on darwin", async () => { + if (!existsSync(PROMPT_PATH)) return; + const expected = readFileSync(PROMPT_PATH, "utf-8"); + let captured = ""; + const copyFn = async (text: string): Promise => { + captured = text; + return { ok: true, tool: "pbcopy" }; + }; + const { out, result } = await captureStdio(() => + runCopyPrompt({ copyFn, platform: "darwin" }), + ); + equal(result, 0); + equal(captured, expected); + ok(out.includes("✓ prompt copied to clipboard")); + ok(out.includes("Cmd+V")); + ok(!out.includes("Ctrl+V")); + }); + + it("prints Ctrl+V hint on linux", async () => { + if (!existsSync(PROMPT_PATH)) return; + const copyFn = async (): Promise => ({ ok: true, tool: "wl-copy" }); + const { out, result } = await captureStdio(() => + runCopyPrompt({ copyFn, platform: "linux" }), + ); + equal(result, 0); + ok(out.includes("Ctrl+V")); + ok(!out.includes("Cmd+V")); + }); + + it("prints Ctrl+V hint on win32", async () => { + if (!existsSync(PROMPT_PATH)) return; + const copyFn = async (): Promise => ({ ok: true, tool: "clip.exe" }); + const { out, result } = await captureStdio(() => + runCopyPrompt({ copyFn, platform: "win32" }), + ); + equal(result, 0); + ok(out.includes("Ctrl+V")); + }); + + it("falls back to printing prompt + warning when clipboard fails", async () => { + if (!existsSync(PROMPT_PATH)) return; + const expected = readFileSync(PROMPT_PATH, "utf-8"); + const copyFn = async (): Promise => ({ ok: false, error: "no clipboard tool found" }); + const { out, err, result } = await captureStdio(() => + runCopyPrompt({ copyFn, platform: "linux" }), + ); + equal(result, 0); // graceful, no exit-1 + ok(out.includes(expected.split("\n")[0])); // prompt content printed to stdout for manual pipe + ok(err.includes("no clipboard tool found")); + ok(err.toLowerCase().includes("warning")); + }); + + it("emits prompt-file-missing envelope (exit 1) when the injected path does not exist", async () => { + const { err, result } = await captureStdio(() => + runCopyPrompt({ + promptPath: "/definitely/does/not/exist/spec-generator-prompt.md", + copyFn: async () => ({ ok: true }), + platform: "darwin", + }), + ); + equal(result, 1); + const parsed = JSON.parse(err.trim()); + equal(parsed.error.code, "prompt-file-missing"); + }); +}); diff --git a/packages/autoskills/tests/subcommands-list-prompt.test.ts b/packages/autoskills/tests/subcommands-list-prompt.test.ts index 6a7f0a21..8a371d46 100644 --- a/packages/autoskills/tests/subcommands-list-prompt.test.ts +++ b/packages/autoskills/tests/subcommands-list-prompt.test.ts @@ -111,7 +111,7 @@ describe("runList", () => { }); describe("runPrompt", () => { - const PROMPT_PATH = resolve(import.meta.dirname!, "..", "prompts", "skill-selection.md"); + const PROMPT_PATH = resolve(import.meta.dirname!, "..", "prompts", "spec-generator-prompt.md"); it("stdouts the prompt file when it exists", () => { if (!existsSync(PROMPT_PATH)) { @@ -130,12 +130,12 @@ describe("runPrompt", () => { } const { out, result } = captureStdio(() => runPrompt({ printPath: true })); equal(result, 0); - ok(out.trim().endsWith("prompts/skill-selection.md")); + ok(out.trim().endsWith("prompts/spec-generator-prompt.md")); }); it("emits prompt-file-missing JSON envelope (exit 1) when the injected path does not exist", () => { const { err, result } = captureStdio(() => - runPrompt({ printPath: false, promptPath: "/definitely/does/not/exist/skill-selection.md" }), + runPrompt({ printPath: false, promptPath: "/definitely/does/not/exist/spec-generator-prompt.md" }), ); equal(result, 1); const parsed = JSON.parse(err.trim()); diff --git a/packages/autoskills/tests/subcommands.test.ts b/packages/autoskills/tests/subcommands.test.ts index 3eed9956..4cfc0a8c 100644 --- a/packages/autoskills/tests/subcommands.test.ts +++ b/packages/autoskills/tests/subcommands.test.ts @@ -41,14 +41,14 @@ describe("subcommand dispatch", () => { writePackageJson(tmp.path, {}); const { stdout, status } = run(["prompt", "--path"], tmp.path); equal(status, 0); - ok(stdout.trim().endsWith("skill-selection.md")); + ok(stdout.trim().endsWith("spec-generator-prompt.md")); }); it("autoskills prompt outputs the prompt file contents", () => { writePackageJson(tmp.path, {}); const { stdout, status } = run(["prompt"], tmp.path); equal(status, 0); - ok(stdout.includes("# autoskills — Skill Selection Guide")); + ok(stdout.includes("# autoskills — Spec-Doc Generator")); }); it("autoskills install --only react-that-does-not-exist --json -> install-unknown-id", () => { @@ -117,6 +117,26 @@ describe("subcommand dispatch", () => { ok(stdout.split("\n").filter(l => l.trim()).length < 10, "expected compact single-row output"); }); + it("autoskills --copy-prompt early-exits with success-or-fallback (does not run detection)", () => { + // No package.json — would normally trigger "no supported technologies detected". + // --copy-prompt must short-circuit before that path. + const { stdout, stderr, status } = run(["--copy-prompt"], tmp.path); + equal(status, 0); + const combined = stdout + stderr; + // Either clipboard succeeded OR clipboard failed and the prompt was printed as fallback. + const succeeded = stdout.includes("✓ prompt copied to clipboard"); + const fellBack = stderr.includes("warning") && stdout.includes("# autoskills"); + ok(succeeded || fellBack, `expected success or fallback, got: ${combined}`); + // Detection banner must NOT appear (we exited before main flow). + ok(!combined.includes("Detected technologies"), "detection ran but should not have"); + }); + + it("autoskills --help mentions --copy-prompt", () => { + const { stdout, status } = run(["--help"], tmp.path); + equal(status, 0); + ok(stdout.includes("--copy-prompt")); + }); + it("autoskills list --filter react extra-positional: explicit --filter wins, extra is ignored", () => { writePackageJson(tmp.path, {}); const { stdout, status } = run(["list", "--filter", "react", "extra", "--json"], tmp.path); From 7452cc7fc2b8dac0c0ca9a017220fbaa40d3ae63 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Fri, 24 Apr 2026 01:48:02 -0500 Subject: [PATCH 15/16] feat(cli)!: rename copy/show prompt flags + drop `prompt` subcommand BREAKING: `autoskills prompt` and `--copy-prompt` removed. Use `--show-specgen-prompt` or `--copy-specgen-prompt` instead. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new clipboard.ts: cross-platform spawn copy (pbcopy/wl-copy→xclip/clip.exe), zero deps; ENOENT/exit/error distinguished, fallback prints content + warning - new --show-specgen-prompt + --copy-specgen-prompt: top-level flags, early-exit before dispatch (mirrors --help); show wins if both passed - drop prompt subcommand + --path flag; KNOWN now {list, install} - READMEs: document spec-doc flow with two chat variants (bash-tool first, paste second); root example reframed as problem statement - tests: +13, -3; 495/495 pas --- README.md | 39 ++++++++++++--- packages/autoskills/README.md | 34 ++++++++++--- packages/autoskills/main.ts | 49 ++++++++++--------- packages/autoskills/subcommands.ts | 11 ++--- .../tests/subcommands-list-prompt.test.ts | 18 ++----- packages/autoskills/tests/subcommands.test.ts | 33 ++++++------- 6 files changed, 106 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 224d6832..003c256c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` also writes --json Emit structured JSON (with --dry-run or subcommands) --from-spec Detect tech from a markdown spec file (any extension) --scan-docs Auto-scan CLAUDE.md / AGENTS.md / README.md in the project ---copy-prompt Copy the shipped spec-generator prompt to the OS clipboard +--show-specgen-prompt Print the shipped spec-generator prompt to stdout +--copy-specgen-prompt Copy the shipped spec-generator prompt to the OS clipboard -h, --help Show help message ``` @@ -50,13 +51,39 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` also writes Beyond structural detection, `autoskills` exposes atomic subcommands so an external LLM CLI (Claude Code, Cursor, Codex) can reason over your requirement and produce a parseable spec: ```bash -npx autoskills list --json # full catalog -npx autoskills prompt # shipped spec-generator prompt (stdout) -npx autoskills --copy-prompt # copy spec-generator prompt to clipboard -npx autoskills install --only +npx autoskills list --json # full catalog +npx autoskills --show-specgen-prompt # spec-generator prompt to stdout +npx autoskills --copy-specgen-prompt # spec-generator prompt to clipboard ``` -**Spec-doc flow:** run `--copy-prompt`, paste it under your requirement in any LLM chat, and the LLM writes `docs/specs-initial.md` for you to feed back via `autoskills --from-spec`. See the [package README](./packages/autoskills/README.md#subcommands-for-llm-integration) for the full workflow. +**Spec-doc flow:** run `--copy-specgen-prompt` (or `--show-specgen-prompt`), paste it under your requirement in any LLM chat, and the LLM writes `docs/specs-initial.md` for you to feed back via `autoskills --from-spec`. + +What you actually type in the LLM chat — describe the project, not the stack. The LLM picks the techs. + +If your chat has a `bash` tool (Claude Code, Cursor, Codex), one message is enough: + +```text +I'm building an internal task manager for a small remote team. +Users sign in, create tasks, assign them to teammates, and see live +updates when someone changes status. Web + mobile-friendly, free-tier +deploy, end-to-end typed. + +Run `autoskills --show-specgen-prompt` and follow the instructions it prints. +``` + +In any chat (no tools required), paste the prompt under your requirement: + +```text +I'm building an internal task manager for a small remote team. +Users sign in, create tasks, assign them to teammates, and see live +updates when someone changes status. We want it on the web and +mobile-friendly. Need to ship a working demo this week, deploy +on a free tier, and keep the codebase typed end-to-end. + + +``` + +See the [package README](./packages/autoskills/README.md#subcommands-for-llm-integration) for the full workflow. ## Supported Technologies diff --git a/packages/autoskills/README.md b/packages/autoskills/README.md index db0fcf29..2f6550f1 100644 --- a/packages/autoskills/README.md +++ b/packages/autoskills/README.md @@ -49,7 +49,8 @@ If `claude-code` is auto-detected or passed with `-a`, `autoskills` writes a `CL | `--json` | Emit structured JSON (used with `--dry-run` or subcommands; errors return `{error:{code,message}}`) | | `--from-spec ` | Scan a markdown spec file for tech (code fences + Tech Stack headings) | | `--scan-docs` | Auto-scan `CLAUDE.md` / `AGENTS.md` / `README.md` in the project root | -| `--copy-prompt` | Copy the shipped spec-generator prompt to the OS clipboard | +| `--show-specgen-prompt` | Print the shipped spec-generator prompt to stdout | +| `--copy-specgen-prompt` | Copy the shipped spec-generator prompt to the OS clipboard | | `-v`, `--verbose` | Show error details if any installation fails | | `-a`, `--agent ` | Install for specific IDEs only (e.g. `cursor`, `claude-code`) | | `-h`, `--help` | Show help message | @@ -165,24 +166,43 @@ Atomic subcommands let an external LLM CLI (Claude Code, Cursor, Codex) drive au npx autoskills list --json npx autoskills list --filter react # or: npx autoskills list react -# Print the shipped spec-generator prompt (LLM guidance) -npx autoskills prompt # stdout the prompt -npx autoskills prompt --path # print absolute path -npx autoskills --copy-prompt # copy prompt to OS clipboard +# Print or copy the shipped spec-generator prompt (LLM guidance) +npx autoskills --show-specgen-prompt # stdout the prompt +npx autoskills --copy-specgen-prompt # copy prompt to OS clipboard -# Install specific skills by id +# Install specific skills by id (manual path; the spec-doc flow uses --from-spec instead) npx autoskills install --only react,tailwind -y npx autoskills install --only react -a claude-code cursor ``` The shipped prompt drives a **spec-doc workflow**: -1. Run `autoskills --copy-prompt` (clipboard gets the prompt). +1. Run `autoskills --copy-specgen-prompt` (clipboard) or `autoskills --show-specgen-prompt` (stdout — for terminal-only environments or when an LLM with a `bash` tool can fetch it itself). 2. Open your LLM chat. Write your project requirement first ("I want a task manager with Next.js + Tailwind + Supabase"), then paste the prompt below it. 3. The LLM fetches the catalog (`autoskills list --json`), matches techs from your requirement to canonical names, and writes `docs/specs-initial.md` with a `## Tech Stack` section the markdown scanner can parse. 4. The LLM tells you to run `autoskills --from-spec docs/specs-initial.md` in another terminal — it does **not** install anything itself. 5. You run `--from-spec`. autoskills detects + installs deterministically. +#### What you actually type in the LLM chat + +**Option A — let the LLM fetch the prompt** (Claude Code, Cursor, Codex — chats with a `bash` tool): + +```text +I want a task manager with Next.js, Tailwind CSS, and Supabase. + +Run `autoskills --show-specgen-prompt` and follow the instructions it prints. +``` + +**Option B — paste from clipboard** (any chat, no tools required): + +```text +I want a task manager with Next.js, Tailwind CSS, and Supabase. + + +``` + +Either way, the LLM ends by telling you to run `autoskills --from-spec docs/specs-initial.md` yourself. + The shipped prompt lives at `prompts/spec-generator-prompt.md` inside the package. All subcommands emit structured JSON errors when `--json` is passed, for programmatic parsing. Error codes include `unknown-subcommand`, `install-missing-only`, `install-empty-only`, `install-unknown-id` (with fuzzy-match suggestion), `json-requires-subcommand-or-dry-run`, `cli-arg-invalid`, `internal-error`, and `prompt-file-missing`. diff --git a/packages/autoskills/main.ts b/packages/autoskills/main.ts index 0ad9424d..e39c1f18 100644 --- a/packages/autoskills/main.ts +++ b/packages/autoskills/main.ts @@ -69,13 +69,13 @@ interface CliArgs { agents: string[]; fromSpec?: string; scanDocs: boolean; - copyPrompt: boolean; + copySpecgenPrompt: boolean; + showSpecgenPrompt: boolean; // subcommand dispatch subcommand?: string; json: boolean; only?: string; filter?: string; - promptPath: boolean; } function parseArgs(): CliArgs { @@ -150,12 +150,12 @@ function parseArgs(): CliArgs { agents, fromSpec, scanDocs: args.includes("--scan-docs"), - copyPrompt: args.includes("--copy-prompt"), + copySpecgenPrompt: args.includes("--copy-specgen-prompt"), + showSpecgenPrompt: args.includes("--show-specgen-prompt"), subcommand, json: args.includes("--json"), only, filter, - promptPath: args.includes("--path"), }; } @@ -169,23 +169,23 @@ function showHelp(): void { npx autoskills ${dim("--dry-run")} ${dim("[--json]")} Show what would be installed npx autoskills ${dim("-a cursor claude-code")} Install for specific IDEs only npx autoskills ${dim("list")} ${dim("[--json] [--filter ]")} List catalog - npx autoskills ${dim("prompt")} ${dim("[--path]")} Print spec-generator prompt - npx autoskills ${dim("--copy-prompt")} Copy spec-generator prompt to clipboard + npx autoskills ${dim("--show-specgen-prompt")} Print spec-generator prompt to stdout + npx autoskills ${dim("--copy-specgen-prompt")} Copy spec-generator prompt to clipboard npx autoskills ${dim("install --only ")} ${dim("[-a agents] [-y] [--json]")} ${bold("Options:")} - -y, --yes Skip confirmation prompt - --dry-run Show skills without installing - -v, --verbose Show error details on failure - -a, --agent Install for specific IDEs only (e.g. cursor, claude-code) - --from-spec Detect tech from a markdown spec file - --scan-docs Auto-scan CLAUDE.md / AGENTS.md in project root - --copy-prompt Copy spec-generator prompt to clipboard - --json JSON output (subcommands / dry-run) - --only Comma-separated tech ids for 'install' - --filter Filter catalog for 'list' - --path Print prompt file path (for 'prompt') - -h, --help Show this help message + -y, --yes Skip confirmation prompt + --dry-run Show skills without installing + -v, --verbose Show error details on failure + -a, --agent Install for specific IDEs only (e.g. cursor, claude-code) + --from-spec Detect tech from a markdown spec file + --scan-docs Auto-scan CLAUDE.md / AGENTS.md in project root + --show-specgen-prompt Print spec-generator prompt to stdout + --copy-specgen-prompt Copy spec-generator prompt to clipboard + --json JSON output (subcommands / dry-run) + --only Comma-separated tech ids for 'install' + --filter Filter catalog for 'list' + -h, --help Show this help message `); } @@ -470,14 +470,20 @@ async function main(): Promise { process.exit(0); } - if (args.copyPrompt) { + // show wins over copy if both passed — cheaper, no clipboard side effect, no failure mode. + if (args.showSpecgenPrompt) { + const code = runPrompt(); + process.exit(code); + } + + if (args.copySpecgenPrompt) { const code = await runCopyPrompt(); process.exit(code); } // ── Subcommand dispatch (BEFORE any default-flow side-effects) ── if (args.subcommand !== undefined) { - const KNOWN = new Set(["list", "prompt", "install"]); + const KNOWN = new Set(["list", "install"]); if (!KNOWN.has(args.subcommand)) { const msg = { code: "unknown-subcommand", @@ -496,9 +502,6 @@ async function main(): Promise { case "list": code = runList({ json: args.json, filter: args.filter, version: VERSION }); break; - case "prompt": - code = runPrompt({ printPath: args.promptPath }); - break; case "install": code = await runInstall({ only: args.only ?? "", diff --git a/packages/autoskills/subcommands.ts b/packages/autoskills/subcommands.ts index 338b866e..39c95379 100644 --- a/packages/autoskills/subcommands.ts +++ b/packages/autoskills/subcommands.ts @@ -36,7 +36,6 @@ export function runList(args: RunListArgs): number { } export interface RunPromptArgs { - printPath: boolean; /** Override the prompt file path (for tests). Normally resolved from the package layout. */ promptPath?: string; } @@ -63,7 +62,7 @@ function resolvePromptPath(): string { return candidates[0]; } -export function runPrompt(args: RunPromptArgs): number { +export function runPrompt(args: RunPromptArgs = {}): number { const promptPath = args.promptPath ?? resolvePromptPath(); let content: string; try { @@ -83,11 +82,7 @@ export function runPrompt(args: RunPromptArgs): number { } throw err; } - if (args.printPath) { - write(promptPath + "\n"); - } else { - write(content); - } + write(content); return 0; } @@ -134,7 +129,7 @@ export async function runCopyPrompt(args: RunCopyPromptArgs = {}): Promise { const PROMPT_PATH = resolve(import.meta.dirname!, "..", "prompts", "spec-generator-prompt.md"); it("stdouts the prompt file when it exists", () => { - if (!existsSync(PROMPT_PATH)) { - // File may not exist yet if T15 hasn't run. The other runPrompt test (path mode) will handle that. - return; - } + if (!existsSync(PROMPT_PATH)) return; const expected = readFileSync(PROMPT_PATH, "utf-8"); - const { out, result } = captureStdio(() => runPrompt({ printPath: false })); + const { out, result } = captureStdio(() => runPrompt()); equal(result, 0); equal(out, expected); }); - it("--path prints absolute path of the shipped prompt", () => { - if (!existsSync(PROMPT_PATH)) { - return; // guarded: file-missing path is tested below - } - const { out, result } = captureStdio(() => runPrompt({ printPath: true })); - equal(result, 0); - ok(out.trim().endsWith("prompts/spec-generator-prompt.md")); - }); - it("emits prompt-file-missing JSON envelope (exit 1) when the injected path does not exist", () => { const { err, result } = captureStdio(() => - runPrompt({ printPath: false, promptPath: "/definitely/does/not/exist/spec-generator-prompt.md" }), + runPrompt({ promptPath: "/definitely/does/not/exist/spec-generator-prompt.md" }), ); equal(result, 1); const parsed = JSON.parse(err.trim()); diff --git a/packages/autoskills/tests/subcommands.test.ts b/packages/autoskills/tests/subcommands.test.ts index 4cfc0a8c..1aef1eb3 100644 --- a/packages/autoskills/tests/subcommands.test.ts +++ b/packages/autoskills/tests/subcommands.test.ts @@ -37,20 +37,6 @@ describe("subcommand dispatch", () => { equal(parsed.technologies[0].id, "react"); }); - it("autoskills prompt --path prints an absolute path", () => { - writePackageJson(tmp.path, {}); - const { stdout, status } = run(["prompt", "--path"], tmp.path); - equal(status, 0); - ok(stdout.trim().endsWith("spec-generator-prompt.md")); - }); - - it("autoskills prompt outputs the prompt file contents", () => { - writePackageJson(tmp.path, {}); - const { stdout, status } = run(["prompt"], tmp.path); - equal(status, 0); - ok(stdout.includes("# autoskills — Spec-Doc Generator")); - }); - it("autoskills install --only react-that-does-not-exist --json -> install-unknown-id", () => { writePackageJson(tmp.path, {}); const { stdout, status } = run(["install", "--only", "reakt", "--json"], tmp.path); @@ -117,10 +103,10 @@ describe("subcommand dispatch", () => { ok(stdout.split("\n").filter(l => l.trim()).length < 10, "expected compact single-row output"); }); - it("autoskills --copy-prompt early-exits with success-or-fallback (does not run detection)", () => { + it("autoskills --copy-specgen-prompt early-exits with success-or-fallback (does not run detection)", () => { // No package.json — would normally trigger "no supported technologies detected". - // --copy-prompt must short-circuit before that path. - const { stdout, stderr, status } = run(["--copy-prompt"], tmp.path); + // --copy-specgen-prompt must short-circuit before that path. + const { stdout, stderr, status } = run(["--copy-specgen-prompt"], tmp.path); equal(status, 0); const combined = stdout + stderr; // Either clipboard succeeded OR clipboard failed and the prompt was printed as fallback. @@ -131,10 +117,19 @@ describe("subcommand dispatch", () => { ok(!combined.includes("Detected technologies"), "detection ran but should not have"); }); - it("autoskills --help mentions --copy-prompt", () => { + it("autoskills --show-specgen-prompt prints the prompt and early-exits", () => { + const { stdout, status } = run(["--show-specgen-prompt"], tmp.path); + equal(status, 0); + ok(stdout.includes("# autoskills — Spec-Doc Generator")); + // Must not run detection. + ok(!stdout.includes("Detected technologies"), "detection ran but should not have"); + }); + + it("autoskills --help mentions both --copy-specgen-prompt and --show-specgen-prompt", () => { const { stdout, status } = run(["--help"], tmp.path); equal(status, 0); - ok(stdout.includes("--copy-prompt")); + ok(stdout.includes("--copy-specgen-prompt")); + ok(stdout.includes("--show-specgen-prompt")); }); it("autoskills list --filter react extra-positional: explicit --filter wins, extra is ignored", () => { From 87311718ee368bcdfb07b919afb71147419fb596 Mon Sep 17 00:00:00 2001 From: Carlos Felipe Date: Fri, 24 Apr 2026 02:06:59 -0500 Subject: [PATCH 16/16] refactor: Updated commands for LLM in package README.md --- packages/autoskills/README.md | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/autoskills/README.md b/packages/autoskills/README.md index 2f6550f1..60e99145 100644 --- a/packages/autoskills/README.md +++ b/packages/autoskills/README.md @@ -162,23 +162,18 @@ Tables, bullets, inline comma-separated lines, and numbered lists **only** parse Atomic subcommands let an external LLM CLI (Claude Code, Cursor, Codex) drive autoskills over prose specs that the structural scanner cannot parse. ```bash -# List the full catalog as JSON +# Full catalog as JSON (LLM uses this to map your requirement to canonical tech names) npx autoskills list --json -npx autoskills list --filter react # or: npx autoskills list react -# Print or copy the shipped spec-generator prompt (LLM guidance) -npx autoskills --show-specgen-prompt # stdout the prompt -npx autoskills --copy-specgen-prompt # copy prompt to OS clipboard - -# Install specific skills by id (manual path; the spec-doc flow uses --from-spec instead) -npx autoskills install --only react,tailwind -y -npx autoskills install --only react -a claude-code cursor +# Spec-generator prompt (LLM guidance for writing docs/specs-initial.md) +npx autoskills --show-specgen-prompt # stdout +npx autoskills --copy-specgen-prompt # OS clipboard ``` The shipped prompt drives a **spec-doc workflow**: -1. Run `autoskills --copy-specgen-prompt` (clipboard) or `autoskills --show-specgen-prompt` (stdout — for terminal-only environments or when an LLM with a `bash` tool can fetch it itself). -2. Open your LLM chat. Write your project requirement first ("I want a task manager with Next.js + Tailwind + Supabase"), then paste the prompt below it. +1. Run `autoskills --show-specgen-prompt` (stdout) or `autoskills --copy-specgen-prompt` (clipboard). +2. Open your LLM chat. Describe your project (the problem, not the stack), then attach the prompt below it. 3. The LLM fetches the catalog (`autoskills list --json`), matches techs from your requirement to canonical names, and writes `docs/specs-initial.md` with a `## Tech Stack` section the markdown scanner can parse. 4. The LLM tells you to run `autoskills --from-spec docs/specs-initial.md` in another terminal — it does **not** install anything itself. 5. You run `--from-spec`. autoskills detects + installs deterministically. @@ -188,7 +183,7 @@ The shipped prompt drives a **spec-doc workflow**: **Option A — let the LLM fetch the prompt** (Claude Code, Cursor, Codex — chats with a `bash` tool): ```text -I want a task manager with Next.js, Tailwind CSS, and Supabase. + Run `autoskills --show-specgen-prompt` and follow the instructions it prints. ``` @@ -196,7 +191,7 @@ Run `autoskills --show-specgen-prompt` and follow the instructions it prints. **Option B — paste from clipboard** (any chat, no tools required): ```text -I want a task manager with Next.js, Tailwind CSS, and Supabase. + ```