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 a2b1085e..d1d65345 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,57 @@ This keeps the package small while avoiding live downloads from third-party skil ## 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 (any extension) +--scan-docs Auto-scan CLAUDE.md / AGENTS.md / README.md in the project +--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 ``` +> `--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 + +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 --show-specgen-prompt # spec-generator prompt to stdout +npx autoskills --copy-specgen-prompt # spec-generator prompt to clipboard +``` + +**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 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 0cff108d..a77a668b 100644 --- a/packages/autoskills/README.md +++ b/packages/autoskills/README.md @@ -42,12 +42,166 @@ 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 install trace and error details | -| `-h`, `--help` | Show help message | +| Flag | Description | +| ----------------------- | --------------------------------------------------------------------------------------------------- | +| `-y`, `--yes` | Skip confirmation prompt, install all detected skills | +| `--dry-run` | Show detected skills without installing | +| `--clear-cache` | Clear downloaded skills cache | +| `--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 | +| `--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 install trace and error details | +| `-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`, 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 +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` (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. + +### Supported formats under stack headings + +**Heading shapes recognized** (h1–h3, case-insensitive, keyword must be exact after stripping decoration): + +```markdown +## Tech Stack +## 2. Tech Stack +## 1) Stack +## 🚀 Tech Stack +## **Dependencies** +## __Dependencies__ +## [Stack] +## Tech Stack: +## Tech Stack (frontend) +``` + +Prose narrative headings like `## Why we chose our Stack` are rejected (decoration is stripped but the keyword itself must be the whole title). + +Valid keywords (after normalization, case-insensitive): `Tech Stack`, `Stack`, `Dependencies`, `Built With`, `Technologies`. + +**Content shapes recognized under the heading:** + +1. **Dash / asterisk / plus bullets** + + ```markdown + ## Tech Stack + - Astro + - Tailwind CSS + ``` + +2. **Numbered bullets** (`1.` or `1)`) + + ```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. + +## 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 +# Full catalog as JSON (LLM uses this to map your requirement to canonical tech names) +npx autoskills list --json + +# 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 --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. + +#### 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 + + +Run `autoskills --show-specgen-prompt` and follow the instructions it prints. +``` + +**Option B — paste from clipboard** (any chat, no tools required): + +```text + + + +``` + +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`. ## Supported Technologies 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/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/installer.ts b/packages/autoskills/installer.ts index ff5d19d9..7dfd05ed 100644 --- a/packages/autoskills/installer.ts +++ b/packages/autoskills/installer.ts @@ -417,6 +417,17 @@ export async function installSkill( const projectDir = opts.projectDir || process.cwd(); const command = `autoskills install ${skillPath}`; + // @internal — test-only escape hatch consumed by spawn-based subcommand tests. + if (process.env.AUTOSKILLS_MOCK_INSTALL === "1") { + return { + success: true, + output: `mock-installed ${skillPath}`, + stderr: "", + exitCode: 0, + command, + }; + } + const fail = (msg: string): InstallResult => ({ success: false, output: msg, diff --git a/packages/autoskills/lib.ts b/packages/autoskills/lib.ts index 535c74eb..cd210d46 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", "README.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/main.ts b/packages/autoskills/main.ts index 77018744..7a25fca7 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, @@ -22,6 +32,8 @@ import { import { printBanner, multiSelect, formatTime } from "./ui.ts"; import { clearAutoskillsCache, installAll, loadRegistry } from "./installer.ts"; import { cleanupClaudeMd } from "./claude.ts"; +import { runList, runPrompt, runInstall, runCopyPrompt } from "./subcommands.ts"; +import { serializeDryRun, serializeError } from "./cli-json.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const VERSION: string = (() => { @@ -44,6 +56,13 @@ process.on("SIGINT", () => { // ── CLI ────────────────────────────────────────────────────── +class ArgError extends Error { + constructor(message: string) { + super(message); + this.name = "ArgError"; + } +} + interface CliArgs { autoYes: boolean; dryRun: boolean; @@ -51,18 +70,81 @@ interface CliArgs { help: boolean; clearCache: boolean; agents: string[]; + fromSpec?: string; + scanDocs: boolean; + copySpecgenPrompt: boolean; + showSpecgenPrompt: boolean; + // subcommand dispatch + subcommand?: string; + json: boolean; + only?: string; + filter?: string; } 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 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"), @@ -70,6 +152,14 @@ function parseArgs(): CliArgs { help: args.includes("--help") || args.includes("-h"), clearCache: args.includes("--clear-cache"), agents, + fromSpec, + scanDocs: args.includes("--scan-docs"), + copySpecgenPrompt: args.includes("--copy-specgen-prompt"), + showSpecgenPrompt: args.includes("--show-specgen-prompt"), + subcommand, + json: args.includes("--json"), + only, + filter, }; } @@ -78,19 +168,30 @@ 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("--clear-cache")} Clear downloaded skills cache - 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("--clear-cache")} Clear downloaded skills cache + npx autoskills ${dim("-a cursor claude-code")} Install for specific IDEs only + npx autoskills ${dim("list")} ${dim("[--json] [--filter ]")} List catalog + 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 - --clear-cache Clear downloaded skills cache - -v, --verbose Show install trace and error details - -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 + --clear-cache Clear downloaded skills cache + -v, --verbose Show install trace and error details + -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 `); } @@ -368,13 +469,70 @@ async function selectSkills(skills: SkillEntry[], autoYes: boolean): Promise { - const { autoYes, dryRun, verbose, help, clearCache, agents } = parseArgs(); + const args = parseArgs(); + const { autoYes, dryRun, verbose, help, clearCache, agents, fromSpec, scanDocs } = args; if (help) { showHelp(); process.exit(0); } + // 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", "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 "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 (clearCache) { const { cacheDir, removed } = clearAutoskillsCache(); log( @@ -386,31 +544,72 @@ async function main(): Promise { process.exit(0); } - await printBanner(VERSION); + if (!args.json) { + await printBanner(VERSION); + } const projectDir = resolve("."); - write(dim(" Scanning project...\r")); - const { detected, isFrontend, combos } = detectTechnologies(projectDir); - write("\x1b[K"); + if (!args.json) { + write(dim(" Scanning project...\r")); + } + const core = detectTechnologies(projectDir); + if (!args.json) { + 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, AGENTS.md, or README.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.")); - 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://autoskills.sh for the latest.")); - log(); + if (!args.json) { + log(yellow(" No skills available for your stack yet.")); + log(dim(" Check https://autoskills.sh for the latest.")); + log(); + } process.exit(0); } @@ -419,6 +618,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.")); @@ -461,6 +676,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/markdown-scanner.ts b/packages/autoskills/markdown-scanner.ts new file mode 100644 index 00000000..5e24e745 --- /dev/null +++ b/packages/autoskills/markdown-scanner.ts @@ -0,0 +1,332 @@ +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 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) { + const required = tech.detect.packages ?? []; + if (required.length && required.some(p => pkgs.includes(p))) ids.push(tech.id); + } + 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; +} + +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", +]); + +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[]; + inlines: string[]; + tables: ParsedTable[]; + evidence: string; +}[] { + const lines = content.split("\n"); + const blocks: { + bullets: string[]; + inlines: string[]; + tables: ParsedTable[]; + evidence: string; + }[] = []; + for (let i = 0; i < lines.length; i++) { + 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; + 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*(?:[-*+]|\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, inlines, tables, 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(); +} + +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 + 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[] = []; + const pushMatch = (techId: string, source: MarkdownMatch["source"], evidence: string) => { + if (seen.has(techId)) return; + seen.add(techId); + 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 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)) { + 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("")); + } + } + 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/package.json b/packages/autoskills/package.json index a4ca5fdf..d1e09b6a 100644 --- a/packages/autoskills/package.json +++ b/packages/autoskills/package.json @@ -26,6 +26,7 @@ "files": [ "index.mjs", "dist/", + "prompts", "skills-registry/index.json" ], "type": "module", 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/skills-map.ts b/packages/autoskills/skills-map.ts index d39c0416..ff0a0fc9 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 { @@ -46,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"], @@ -59,6 +62,7 @@ export const SKILLS_MAP: Technology[] = [ { id: "vue", name: "Vue", + aliases: ["Vue.js"], detect: { packages: ["vue"], }, @@ -88,6 +92,7 @@ export const SKILLS_MAP: Technology[] = [ { id: "svelte", name: "Svelte", + aliases: ["SvelteKit", "Svelte Kit"], detect: { packages: ["svelte", "@sveltejs/kit"], configFiles: ["svelte.config.js"], @@ -125,6 +130,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"], @@ -142,6 +148,7 @@ export const SKILLS_MAP: Technology[] = [ { id: "typescript", name: "TypeScript", + aliases: ["TS"], detect: { packages: ["typescript"], configFiles: ["tsconfig.json"], @@ -578,6 +585,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/subcommands.ts b/packages/autoskills/subcommands.ts new file mode 100644 index 00000000..39c95379 --- /dev/null +++ b/packages/autoskills/subcommands.ts @@ -0,0 +1,279 @@ +import { readFileSync } from "node:fs"; +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, 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. + +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 { + /** Override the prompt file path (for tests). Normally resolved from the package layout. */ + promptPath?: string; +} + +/** + * 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", "spec-generator-prompt.md"), + resolve(SUBCOMMANDS_DIR, "..", "prompts", "spec-generator-prompt.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; + } + write(content); + 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 --show-specgen-prompt | pbcopy')`)); + write(content); + 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/cli-from-spec.test.ts b/packages/autoskills/tests/cli-from-spec.test.ts new file mode 100644 index 00000000..198d0521 --- /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, AGENTS.md, or README.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); + }); +}); 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/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/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/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" + } +} +``` 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/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")); + }); + +}); 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..f884d389 --- /dev/null +++ b/packages/autoskills/tests/load-md-sources.test.ts @@ -0,0 +1,83 @@ +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")); + }); + + 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); + }); +}); 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/markdown-scanner.test.ts b/packages/autoskills/tests/markdown-scanner.test.ts new file mode 100644 index 00000000..3b42563f --- /dev/null +++ b/packages/autoskills/tests/markdown-scanner.test.ts @@ -0,0 +1,341 @@ +import { describe, it } from "node:test"; +import { deepEqual, equal, ok } from "node:assert/strict"; +import { scanMarkdown, normalizeHeadingTitle } 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), []); + }); +}); + +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", () => { + for (const title of ["Stack", "Dependencies", "Built With", "Technologies"]) { + 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("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"); + }); + + 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), []); + }); +}); + +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"); + }); +}); + +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"); + }); +}); 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"]); + }); +}); 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..34bf3ce2 --- /dev/null +++ b/packages/autoskills/tests/subcommands-list-prompt.test.ts @@ -0,0 +1,133 @@ +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", "spec-generator-prompt.md"); + + it("stdouts the prompt file when it exists", () => { + if (!existsSync(PROMPT_PATH)) return; + const expected = readFileSync(PROMPT_PATH, "utf-8"); + const { out, result } = captureStdio(() => runPrompt()); + equal(result, 0); + equal(out, expected); + }); + + it("emits prompt-file-missing JSON envelope (exit 1) when the injected path does not exist", () => { + const { err, result } = captureStdio(() => + runPrompt({ promptPath: "/definitely/does/not/exist/spec-generator-prompt.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")); + }); +}); diff --git a/packages/autoskills/tests/subcommands.test.ts b/packages/autoskills/tests/subcommands.test.ts new file mode 100644 index 00000000..1aef1eb3 --- /dev/null +++ b/packages/autoskills/tests/subcommands.test.ts @@ -0,0 +1,158 @@ +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 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 --copy-specgen-prompt early-exits with success-or-fallback (does not run detection)", () => { + // No package.json — would normally trigger "no supported technologies detected". + // --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. + 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 --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-specgen-prompt")); + ok(stdout.includes("--show-specgen-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); + 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); + }); +});