diff --git a/.changeset/huge-phones-travel.md b/.changeset/huge-phones-travel.md new file mode 100644 index 0000000..d7f8fca --- /dev/null +++ b/.changeset/huge-phones-travel.md @@ -0,0 +1,20 @@ +--- +"dev-workflows": minor +--- + +### `devw add` — interactive registry flow + +- `devw add` without arguments opens an interactive flow: browse categories, multi-select rules, confirm, and install in one session +- `devw add /` installs a rule directly from the GitHub registry +- `devw add --list` shows all available rules from the registry +- Old block format (`devw add typescript-strict`) is detected and shows a migration error + +### `devw remove` — pulled rules + +- `devw remove /` removes a pulled rule and updates config +- `devw remove` without arguments opens an interactive multi-select of installed rules + +### Removed + +- `devw pull` command (absorbed into `devw add`) +- Local blocks system (`blocks/registry`, `blocks/installer`) diff --git a/CLAUDE.md b/CLAUDE.md index b76c5ae..1be4989 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,23 +6,26 @@ CLI tool that compiles developer rules into editor-specific config files (CLAUDE - Monorepo with pnpm workspaces - CLI in `packages/cli/` (TypeScript, commander) -- Rule blocks in `content/blocks/` (YAML) +- Rules in `content/rules/` (Markdown with YAML frontmatter, hosted on GitHub) - Tests with node:test ## Architecture - `content/core/` → tool-agnostic source of truth (workflows, skills, templates) -- `content/blocks/` → precooked rule blocks (YAML) +- `content/rules/` → official rule files (Markdown), fetched from GitHub via `devw add` - `packages/cli/src/bridges/` → per-tool adapters that translate core → native format - Bridges only translate. They do not add new intent or logic. ## Key commands ```bash -pnpm install # install deps -pnpm build # build CLI -pnpm test # run tests -pnpm dev # dev mode +pnpm install # install deps +pnpm build # build CLI +pnpm test # run tests +pnpm dev # dev mode +devw add # interactive: browse and install rules from registry +devw add typescript/strict # direct: install specific rule +devw add --list # list available rules ``` ## Specs (read before implementing) @@ -32,7 +35,7 @@ pnpm dev # dev mode - `docs/internal/CLI_SPEC_v0.2.1.md` → v0.2.1 UX polish specification (COMPLETE) - `docs/internal/DOCS_SPEC.md` → Mintlify documentation spec (COMPLETE) - `docs/internal/WATCH_SPEC.md` → v0.3 watch mode specification (COMPLETE) -- `docs/internal/PULL_SPEC.md` → v0.4 Pull rules specification (IMPLEMENT THIS) +- `docs/internal/PULL_SPEC.md` → v0.4 Pull rules specification (ABSORBED into `devw add`) - `docs/internal/DECISIONS.md` → accepted decisions (source of truth if conflict) - `docs/internal/` is gitignored — internal specs not published diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f01f4b..6335102 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ Examples: > This repo is still in early stage. -Expected setup (will be finalized in Block 4 - CLI): +Expected setup: ```bash pnpm install diff --git a/apps/landing/index.html b/apps/landing/index.html index e3814d9..78e662e 100644 --- a/apps/landing/index.html +++ b/apps/landing/index.html @@ -1754,78 +1754,106 @@

- +
- +
Don't write rules from scratch.

- Install curated rule packs with one command. Stack them — TypeScript + React + Tailwind in seconds. + Browse, select, and install rules interactively. No need to memorize names.

+
+
+
+ Interactive mode +
+
+
$ devw add
+
Choose a category: typescript
+
Select rules to add: strict — Strict TypeScript conventions
+
Install 1 rule? Yes
+
 
+
Added typescript/strict (8 rules)
+
Compiled for claude, cursor
+
+
+
+
+ Direct mode +
+
+
$ devw add typescript/strict
+
 
+
Added typescript/strict (8 rules)
+
Compiled for claude, cursor
+
+
+
+
-
typescript-strict
+
typescript/strict
No any, explicit returns, union types over enums, no non-null assertions.
- devw add typescript-strict -
-
react-conventions
+
javascript/react
Hooks rules, component patterns, naming conventions, prop types.
- devw add react-conventions -
-
nextjs-approuter
+
javascript/nextjs
App Router patterns, RSC boundaries, server actions, metadata API.
- devw add nextjs-approuter -
-
tailwind
+
css/tailwind
Utility-first, no custom CSS, design tokens, responsive patterns.
- devw add tailwind -
-
supabase-rls
+
security/supabase-rls
RLS enforcement on every table, auth patterns, security policies.
- devw add supabase-rls -
-
testing-basics
+
testing/vitest
Test naming, coverage expectations, mock patterns, arrange-act-assert.
- devw add testing-basics - @@ -1838,12 +1866,12 @@

-
137
+
243
Tests passing
6
-
Rule blocks
+
Registry rules
5
diff --git a/apps/landing/scripts/copy.js b/apps/landing/scripts/copy.js index 65b3a1d..e25c797 100644 --- a/apps/landing/scripts/copy.js +++ b/apps/landing/scripts/copy.js @@ -12,7 +12,7 @@ function copyInstall(el) { function copyBlock(btn, text) { navigator.clipboard.writeText(text); - trackEvent('copy_command', { command: text, location: 'blocks' }); + trackEvent('copy_command', { command: text, location: 'registry' }); btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1500); } diff --git a/apps/landing/scripts/terminal.js b/apps/landing/scripts/terminal.js index 4149b6d..0008b5e 100644 --- a/apps/landing/scripts/terminal.js +++ b/apps/landing/scripts/terminal.js @@ -11,21 +11,20 @@ const TERM_TABS = { { html: ' \u2713 Created .dwf/config.yml', delay: 350 }, { html: ' \u2713 Created .dwf/rules/ (5 scope files)', delay: 250 }, { html: '', delay: 300 }, - { html: ' Ready. Add rules or install a block to get started.', delay: 400 }, + { html: ' Ready. Run devw add to browse the registry.', delay: 400 }, ] }, add: { - comment: '# Install a prebuilt rule block', + comment: '# Add rules from the registry', lines: [ - { html: '$ devw add typescript-strict', delay: 0 }, + { html: '$ devw add', delay: 0 }, { html: '', delay: 400 }, - { html: ' \u2713 Added ts-strict-no-any to conventions.yml', delay: 350 }, - { html: ' \u2713 Added ts-strict-explicit-returns to conventions.yml', delay: 250 }, - { html: ' \u2713 Added ts-strict-no-enums to conventions.yml', delay: 250 }, - { html: ' \u2713 Added ts-strict-no-non-null to conventions.yml', delay: 250 }, + { html: ' \u2714 Choose a category: typescript', delay: 500 }, + { html: ' \u2714 Select rules to add: strict \u2014 Strict TypeScript conventions', delay: 500 }, + { html: ' \u2714 Install 1 rule? Yes', delay: 400 }, { html: '', delay: 300 }, - { html: ' Block registered in config.yml', delay: 200 }, - { html: ' 4 rules added. Run devw compile to apply.', delay: 350 }, + { html: ' \u2713 Added typescript/strict (8 rules)', delay: 350 }, + { html: ' \u2713 Compiled for claude, cursor', delay: 250 }, ] }, compile: { @@ -66,7 +65,7 @@ const TERM_TABS = { { html: '', delay: 200 }, { html: ' \u2713 config.yml valid', delay: 300 }, { html: ' \u2713 rules.yml valid', delay: 250 }, - { html: ' \u2713 2 blocks installed ok', delay: 250 }, + { html: ' \u2713 2 pulled rules ok', delay: 250 }, { html: ' \u26a0 CLAUDE.md stale (recompile needed)', delay: 350 }, { html: ' \u2713 .cursor/rules up to date', delay: 250 }, { html: ' \u2713 GEMINI.md up to date', delay: 250 }, @@ -75,7 +74,7 @@ const TERM_TABS = { ] }, list: { - comment: '# List rules, blocks, or tools', + comment: '# List rules or tools', lines: [ { html: '$ devw list rules', delay: 0 }, { html: '', delay: 400 }, @@ -92,19 +91,12 @@ const TERM_TABS = { ] }, remove: { - comment: '# Remove a rule block', + comment: '# Remove a pulled rule', lines: [ - { html: '$ devw remove typescript-strict', delay: 0 }, + { html: '$ devw remove typescript/strict', delay: 0 }, { html: '', delay: 400 }, - { html: ' Removing block typescript-strict...', delay: 500 }, - { html: '', delay: 200 }, - { html: ' \u2713 Removed ts-strict-no-any from conventions.yml', delay: 250 }, - { html: ' \u2713 Removed ts-strict-explicit-returns from conventions.yml', delay: 200 }, - { html: ' \u2713 Removed ts-strict-no-enums from conventions.yml', delay: 200 }, - { html: ' \u2713 Removed ts-strict-no-non-null from conventions.yml', delay: 200 }, - { html: '', delay: 300 }, - { html: ' Block unregistered from config.yml', delay: 200 }, - { html: ' Done. 4 rules removed. Run devw compile to update outputs.', delay: 350 }, + { html: ' \u2713 Removed typescript/strict', delay: 350 }, + { html: ' \u2713 Compiled for claude, cursor', delay: 250 }, ] } }; diff --git a/apps/landing/styles/blocks.css b/apps/landing/styles/blocks.css index 4662def..fd30243 100644 --- a/apps/landing/styles/blocks.css +++ b/apps/landing/styles/blocks.css @@ -1,5 +1,5 @@ /* ═══════════════════════════════════════ - BLOCKS + REGISTRY / BLOCKS ═══════════════════════════════════════ */ .blocks-section { padding-bottom: 120px; @@ -7,6 +7,47 @@ .blocks-header { text-align: center; margin-bottom: 48px; } .blocks-header .section-sub { margin: 0 auto; } +/* ── Registry demo ── */ +.registry-demo { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 40px; +} +.registry-panel { + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-code); + overflow: hidden; +} +.registry-bar { + padding: 10px 16px; + background: var(--bg-raised); + border-bottom: 1px solid var(--border-subtle); +} +.registry-label { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} +.registry-body { + padding: 16px 20px; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.9; + color: var(--text-secondary); +} +.reg-line .term-prompt { color: var(--accent); } +.reg-line .term-cmd { color: var(--text); font-weight: 600; } +.reg-line .term-success { color: var(--accent); } +.reg-line .term-val { color: #8b9fcc; } +.reg-line .term-muted { color: var(--text-muted); } +.reg-line .term-file { color: #ffaa44; } + +/* ── Rule cards grid ── */ .blocks-grid { display: grid; grid-template-columns: repeat(3, 1fr); @@ -95,6 +136,7 @@ RESPONSIVE ═══════════════════════════════════════ */ @media (max-width: 900px) { + .registry-demo { grid-template-columns: 1fr; } .blocks-grid { grid-template-columns: 1fr 1fr; } } @media (max-width: 600px) { diff --git a/content/rules/README.md b/content/rules/README.md index 2aeb35c..8ec46af 100644 --- a/content/rules/README.md +++ b/content/rules/README.md @@ -1,32 +1,35 @@ # Official Rules -Rules for AI coding agents, distributed via `devw pull`. +Rules for AI coding agents, distributed via `devw add`. ## Available Rules | Rule | Category | Description | Command | |------|----------|-------------|---------| -| `typescript/strict` | TypeScript | Strict TypeScript conventions | `devw pull typescript/strict` | -| `javascript/react` | JavaScript | React conventions and best practices | `devw pull javascript/react` | -| `javascript/nextjs` | JavaScript | Next.js App Router patterns and RSC | `devw pull javascript/nextjs` | -| `css/tailwind` | CSS | Utility-first Tailwind conventions | `devw pull css/tailwind` | -| `testing/vitest` | Testing | Vitest testing patterns | `devw pull testing/vitest` | -| `security/supabase-rls` | Security | Supabase RLS enforcement | `devw pull security/supabase-rls` | +| `typescript/strict` | TypeScript | Strict TypeScript conventions | `devw add typescript/strict` | +| `javascript/react` | JavaScript | React conventions and best practices | `devw add javascript/react` | +| `javascript/nextjs` | JavaScript | Next.js App Router patterns and RSC | `devw add javascript/nextjs` | +| `css/tailwind` | CSS | Utility-first Tailwind conventions | `devw add css/tailwind` | +| `testing/vitest` | Testing | Vitest testing patterns | `devw add testing/vitest` | +| `security/supabase-rls` | Security | Supabase RLS enforcement | `devw add security/supabase-rls` | ## Usage ```bash # List all available rules -devw pull --list +devw add --list -# Pull a specific rule -devw pull typescript/strict +# Add a specific rule +devw add typescript/strict + +# Interactive mode — browse categories and select +devw add # Preview without writing -devw pull typescript/strict --dry-run +devw add typescript/strict --dry-run # Force overwrite -devw pull typescript/strict --force +devw add typescript/strict --force ``` ## Rule Format diff --git a/docs/commands/add.mdx b/docs/commands/add.mdx index c6ab18a..eb87814 100644 --- a/docs/commands/add.mdx +++ b/docs/commands/add.mdx @@ -1,48 +1,71 @@ --- title: "devw add" -description: "Install a prebuilt rule block" +description: "Add rules from the dev-workflows registry" --- ```bash -devw add +devw add [category/rule] ``` -Installs a prebuilt rule block into your `.dwf/rules/` files and auto-compiles. +Adds rules from the official dev-workflows registry. Supports both interactive and direct modes. -## Behavior +## Interactive Mode -1. Loads the block from the internal registry -2. Merges rules into the corresponding scope files -3. Adds the block ID to `config.yml > blocks[]` -4. Runs `devw compile` automatically +Running `devw add` without arguments launches an interactive flow: + +1. Browse categories from the registry +2. Select rules to install (with back navigation) +3. Optionally add rules from multiple categories +4. Review a summary and confirm +5. Rules are downloaded, converted to YAML, and compiled + +Rules already installed are shown with an `(already installed)` indicator. + +## Direct Mode + +```bash +devw add typescript/strict +``` + +Downloads and installs a specific rule by path. If the rule already exists at the same version, reports "Already up to date". ## Flags | Flag | Description | |------|-------------| -| `--list` | List all available blocks | +| `--list [category]` | List available rules from the registry | | `--no-compile` | Skip auto-compile after adding | +| `--force` | Overwrite existing rules without asking | +| `--dry-run` | Show output without writing files | ## Examples ```bash -# List available blocks +# Interactive — browse and select rules +devw add + +# Install a specific rule +devw add typescript/strict + +# List all available rules devw add --list -# Install a block -devw add typescript-strict +# Preview what would be written +devw add javascript/react --dry-run -# Install without auto-compiling -devw add react-conventions --no-compile +# Force overwrite without confirmation +devw add css/tailwind --force ``` -## Available Blocks +## Available Rules + +Rules are organized by category. Run `devw add --list` for the latest list. -| Block | Scope | Description | -|-------|-------|-------------| -| `typescript-strict` | conventions | No any, explicit returns, union types over enums | -| `react-conventions` | conventions | Hooks rules, component patterns, naming | -| `nextjs-approuter` | architecture | App Router, RSC, server actions | -| `tailwind` | conventions | Utility-first, no custom CSS, design tokens | -| `testing-basics` | testing | Test naming, coverage, mocks | -| `supabase-rls` | security | RLS enforcement, auth patterns | +| Rule | Category | Description | +|------|----------|-------------| +| `typescript/strict` | TypeScript | Strict TypeScript conventions | +| `javascript/react` | JavaScript | React conventions and best practices | +| `javascript/nextjs` | JavaScript | Next.js App Router patterns | +| `css/tailwind` | CSS | Utility-first Tailwind conventions | +| `testing/vitest` | Testing | Vitest testing patterns | +| `security/supabase-rls` | Security | Supabase RLS enforcement | diff --git a/docs/commands/list.mdx b/docs/commands/list.mdx index 67542f0..8d23502 100644 --- a/docs/commands/list.mdx +++ b/docs/commands/list.mdx @@ -13,14 +13,13 @@ Shows information about your current configuration. | Type | Description | |------|-------------| -| `rules` | All active rules with scope and severity | -| `blocks` | Installed block IDs | -| `tools` | Configured tools | +| `rules` | All active rules with scope, severity, and source (pulled vs manual) | +| `blocks` | Deprecated — shows migration message | +| `tools` | Configured tools with output paths | ## Examples ```bash devw list rules -devw list blocks devw list tools ``` diff --git a/docs/commands/remove.mdx b/docs/commands/remove.mdx index d28a423..e0b17c0 100644 --- a/docs/commands/remove.mdx +++ b/docs/commands/remove.mdx @@ -1,18 +1,34 @@ --- title: "devw remove" -description: "Remove an installed rule block" +description: "Remove an installed rule" --- ```bash -devw remove +devw remove [category/rule] ``` -Removes all rules installed by a specific block and updates `config.yml`. +Removes a rule that was installed via `devw add` and recompiles. -## Example +## Interactive Mode + +Running `devw remove` without arguments shows installed rules and lets you select which to remove. + +## Direct Mode + +```bash +devw remove typescript/strict +``` + +Removes the rule file (`pulled-typescript-strict.yml`) and updates `config.yml`. + +## Examples ```bash -devw remove typescript-strict +# Remove a specific rule +devw remove typescript/strict + +# Interactive — select rules to remove +devw remove ``` -Rules added manually (without `sourceBlock`) are never affected. +Rules added manually (not via `devw add`) are not affected. diff --git a/docs/concepts/blocks.mdx b/docs/concepts/blocks.mdx index 854cb7a..504b7aa 100644 --- a/docs/concepts/blocks.mdx +++ b/docs/concepts/blocks.mdx @@ -1,78 +1,40 @@ --- -title: "Rule Blocks" -description: "Prebuilt rule collections you can install with one command" +title: "Rule Blocks (Legacy)" +description: "Blocks have been replaced by the rules registry" --- -Blocks are curated collections of rules that you can install instead of writing from scratch. Each block targets a specific technology or concern. + + Blocks have been replaced by pulled rules from the GitHub registry. Use `devw add /` instead. + -## Install a Block +## Migration -```bash -devw add typescript-strict -``` - -This merges the block's rules into your `.dwf/rules/` files by scope and runs `devw compile` automatically. - -## Available Blocks - -### typescript-strict - -Strict TypeScript conventions for professional codebases. - -**Rules:** -- `ts-strict-no-any` — Never use `any`. Use `unknown` + type guards. -- `ts-strict-explicit-returns` — Explicit return types on exported functions. -- `ts-strict-no-enums` — Prefer union types over enums. Use `as const`. -- `ts-strict-no-non-null-assertion` — Never use `!`. Handle null explicitly. - -### react-conventions - -React component and hooks patterns. +The block system (`devw add typescript-strict`) has been replaced by the rules registry (`devw add typescript/strict`). -**Rules:** -- `react-fc-declaration` — Use function declarations for components. -- `react-hooks-rules` — Follow Rules of Hooks. Custom hooks start with `use`. -- `react-prop-types` — Use TypeScript interfaces for props, not PropTypes. -- `react-no-inline-styles` — No inline styles. Use Tailwind or CSS modules. +### What changed -### nextjs-approuter +| Before (blocks) | After (rules registry) | +|-----------------|----------------------| +| `devw add typescript-strict` | `devw add typescript/strict` | +| `devw add --list` (local blocks) | `devw add --list` (from GitHub) | +| `devw remove typescript-strict` | `devw remove typescript/strict` | +| `devw list blocks` | `devw list rules` | +| Rules embedded in npm package | Rules fetched from GitHub | -Next.js App Router patterns and best practices. +### How to migrate -**Rules:** -- `next-server-components` — Default to Server Components. Add `'use client'` only when needed. -- `next-server-actions` — Use Server Actions for mutations. Validate inputs with Zod. -- `next-route-handlers` — Use route handlers for API endpoints. Export named HTTP methods. -- `next-metadata` — Export `metadata` or `generateMetadata` in layout/page files. +1. Remove old blocks: `devw remove ` (if still in config) +2. Add the equivalent rule: `devw add /` +3. Run `devw compile` to regenerate output files -### tailwind +## Browse available rules -Utility-first CSS with Tailwind. - -**Rules:** -- `tw-utility-first` — Use Tailwind utilities. Avoid custom CSS. -- `tw-no-arbitrary` — Minimize arbitrary values. Extend theme config instead. -- `tw-component-extraction` — Extract repeated utility patterns into components, not CSS. - -### testing-basics - -Testing fundamentals and conventions. - -**Rules:** -- `test-naming` — Test names describe behavior: "should [verb] when [condition]". -- `test-arrange-act-assert` — Follow AAA pattern in every test. -- `test-no-implementation` — Test behavior, not implementation details. -- `test-mock-boundaries` — Only mock external boundaries (API, DB, filesystem). - -### supabase-rls - -Supabase Row Level Security patterns. - -**Rules:** -- `supa-rls-required` — Every table must have RLS policies before merging. -- `supa-auth-context` — Use `auth.uid()` in RLS policies. Never trust client-side filters alone. -- `supa-service-role` — Never expose the service role key to the client. +```bash +# List all available rules from the registry +devw add --list -## Block Source +# Interactive mode — browse categories and select +devw add +``` -Blocks are distributed inside the npm package in `content/blocks/`. Each block is a YAML file with an `id`, `name`, `description`, `version`, and a list of `rules` with scope assignments. +See [devw add](/commands/add) for full documentation. diff --git a/docs/index.mdx b/docs/index.mdx index 4720a9f..1835299 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -15,7 +15,7 @@ If you use Claude Code, Cursor, and Gemini CLI, you maintain the same rules in t ```bash npx dev-workflows init # create .dwf/ with config -npx dev-workflows add typescript-strict # install a rule block +npx dev-workflows add typescript/strict # install a rule from the registry npx dev-workflows compile # generate CLAUDE.md, .cursor/rules, GEMINI.md ``` @@ -33,27 +33,27 @@ Define rules once in YAML, compile to each editor's native format. -## Prebuilt Rule Blocks +## Rules Registry -Start with battle-tested presets instead of writing from scratch: +Start with battle-tested rules instead of writing from scratch. Browse with `devw add --list` or run `devw add` for interactive selection. - + No any, explicit returns, union types over enums - + Hooks rules, component patterns, naming - + App Router, RSC, server actions - + Utility-first, no custom CSS, design tokens - + Test naming, coverage, mocks - + RLS enforcement, auth patterns diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 79add13..f4d5c3c 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -19,11 +19,11 @@ description: "Install dev-workflows and compile your first rules in 60 seconds" The CLI detects existing AI tools in your project (CLAUDE.md, .cursor/, GEMINI.md) and pre-selects them. - + ```bash - npx dev-workflows add typescript-strict + npx dev-workflows add typescript/strict ``` - Installs prebuilt rules into your `.dwf/rules/` files. Run `devw add --list` to see all available blocks. + Installs rules from the GitHub registry into your `.dwf/rules/` directory. Run `devw add --list` to see all available rules, or `devw add` for interactive selection. diff --git a/packages/cli/package.json b/packages/cli/package.json index 216366e..54d224e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,7 +42,7 @@ "access": "public" }, "scripts": { - "prebuild": "rm -rf content/blocks && mkdir -p content && cp -r ../../content/blocks content/blocks && cp ../../README.md README.md", + "prebuild": "cp ../../README.md README.md", "prepublishOnly": "pnpm build", "build": "tsc", "dev": "tsc --watch", diff --git a/packages/cli/src/blocks/installer.ts b/packages/cli/src/blocks/installer.ts deleted file mode 100644 index 37cc34d..0000000 --- a/packages/cli/src/blocks/installer.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; -import { parse, stringify } from 'yaml'; -import type { BlockDefinition } from './registry.js'; - -interface RawRuleEntry { - id: string; - severity?: string; - content: string; - tags?: string[]; - enabled?: boolean; - sourceBlock?: string; -} - -interface RawRuleFile { - scope: string; - rules: RawRuleEntry[]; -} - -async function readRuleFile(filePath: string): Promise { - try { - const raw = await readFile(filePath, 'utf-8'); - const parsed: unknown = parse(raw); - if (!parsed || typeof parsed !== 'object') return null; - const doc = parsed as Record; - return { - scope: typeof doc['scope'] === 'string' ? doc['scope'] : '', - rules: Array.isArray(doc['rules']) ? (doc['rules'] as RawRuleEntry[]) : [], - }; - } catch { - return null; - } -} - -function writeRuleFile(doc: RawRuleFile): string { - return stringify(doc, { lineWidth: 0 }); -} - -export async function installBlock(cwd: string, block: BlockDefinition): Promise { - const rulesDir = join(cwd, '.dwf', 'rules'); - await mkdir(rulesDir, { recursive: true }); - - // Group block rules by scope - const byScope = new Map(); - for (const rule of block.rules) { - const existing = byScope.get(rule.scope); - if (existing) { - existing.push(rule); - } else { - byScope.set(rule.scope, [rule]); - } - } - - let rulesAdded = 0; - - for (const [scope, blockRules] of byScope) { - const filePath = join(rulesDir, `${scope}.yml`); - let doc = await readRuleFile(filePath); - - if (!doc) { - doc = { scope, rules: [] }; - } - - // Remove any existing rules from this block to avoid duplicates on re-add - doc.rules = doc.rules.filter((r) => r.sourceBlock !== block.id); - - // Append new rules - for (const rule of blockRules) { - doc.rules.push({ - id: rule.id, - severity: rule.severity, - content: rule.content, - sourceBlock: block.id, - }); - rulesAdded++; - } - - await writeFile(filePath, writeRuleFile(doc), 'utf-8'); - } - - // Update config.yml blocks array - await addBlockToConfig(cwd, block.id); - - return rulesAdded; -} - -export async function uninstallBlock(cwd: string, blockId: string): Promise { - const rulesDir = join(cwd, '.dwf', 'rules'); - let entries: string[]; - try { - const { readdir } = await import('node:fs/promises'); - entries = await readdir(rulesDir); - } catch { - return 0; - } - - const ymlFiles = entries.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')); - let rulesRemoved = 0; - - for (const file of ymlFiles) { - const filePath = join(rulesDir, file); - const doc = await readRuleFile(filePath); - if (!doc) continue; - - const before = doc.rules.length; - doc.rules = doc.rules.filter((r) => r.sourceBlock !== blockId); - const removed = before - doc.rules.length; - - if (removed > 0) { - rulesRemoved += removed; - await writeFile(filePath, writeRuleFile(doc), 'utf-8'); - } - } - - // Update config.yml blocks array - await removeBlockFromConfig(cwd, blockId); - - return rulesRemoved; -} - -async function readRawConfig(cwd: string): Promise> { - const configPath = join(cwd, '.dwf', 'config.yml'); - const raw = await readFile(configPath, 'utf-8'); - const parsed: unknown = parse(raw); - if (!parsed || typeof parsed !== 'object') { - throw new Error('Invalid config.yml'); - } - return parsed as Record; -} - -async function writeRawConfig(cwd: string, doc: Record): Promise { - const configPath = join(cwd, '.dwf', 'config.yml'); - await writeFile(configPath, stringify(doc, { lineWidth: 0 }), 'utf-8'); -} - -async function addBlockToConfig(cwd: string, blockId: string): Promise { - const doc = await readRawConfig(cwd); - const blocks = Array.isArray(doc['blocks']) ? (doc['blocks'] as string[]) : []; - - if (!blocks.includes(blockId)) { - blocks.push(blockId); - } - doc['blocks'] = blocks; - - await writeRawConfig(cwd, doc); -} - -async function removeBlockFromConfig(cwd: string, blockId: string): Promise { - const doc = await readRawConfig(cwd); - const blocks = Array.isArray(doc['blocks']) ? (doc['blocks'] as string[]) : []; - - doc['blocks'] = blocks.filter((b) => b !== blockId); - - await writeRawConfig(cwd, doc); -} diff --git a/packages/cli/src/blocks/registry.ts b/packages/cli/src/blocks/registry.ts deleted file mode 100644 index 78f14e1..0000000 --- a/packages/cli/src/blocks/registry.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { readFile, readdir } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse } from 'yaml'; -import type { Rule } from '../bridges/types.js'; - -export interface BlockDefinition { - id: string; - name: string; - description: string; - version: string; - rules: BlockRule[]; -} - -export interface BlockRule { - id: string; - scope: string; - severity: 'error' | 'warning' | 'info'; - content: string; -} - -interface RawBlockRule { - id?: string; - scope?: string; - severity?: string; - content?: string; -} - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function defaultBlocksDir(): string { - // From dist/blocks/ → packages/cli/ (2 up), then content/blocks - return join(__dirname, '..', '..', 'content', 'blocks'); -} - -function normalizeBlockRule(raw: RawBlockRule): BlockRule | null { - if (!raw.id || !raw.scope || !raw.content) return null; - - const severity = raw.severity ?? 'error'; - if (severity !== 'error' && severity !== 'warning' && severity !== 'info') return null; - - return { - id: raw.id, - scope: raw.scope, - severity, - content: raw.content.trimEnd(), - }; -} - -function parseBlock(raw: string): BlockDefinition | null { - const parsed: unknown = parse(raw); - if (!parsed || typeof parsed !== 'object') return null; - - const doc = parsed as Record; - const id = typeof doc['id'] === 'string' ? doc['id'] : null; - const name = typeof doc['name'] === 'string' ? doc['name'] : null; - const description = typeof doc['description'] === 'string' ? doc['description'] : ''; - const version = typeof doc['version'] === 'string' ? doc['version'] : '0.1.0'; - - if (!id || !name) return null; - - const rulesRaw = doc['rules']; - if (!Array.isArray(rulesRaw)) return null; - - const rules: BlockRule[] = []; - for (const rawRule of rulesRaw) { - if (!rawRule || typeof rawRule !== 'object') continue; - const rule = normalizeBlockRule(rawRule as RawBlockRule); - if (rule) rules.push(rule); - } - - return { id, name, description, version, rules }; -} - -export async function loadAllBlocks(blocksDir?: string): Promise { - const dir = blocksDir ?? defaultBlocksDir(); - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return []; - } - - const ymlFiles = entries.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')); - const blocks: BlockDefinition[] = []; - - for (const file of ymlFiles) { - try { - const raw = await readFile(join(dir, file), 'utf-8'); - const block = parseBlock(raw); - if (block) blocks.push(block); - } catch { - // Skip files that can't be read or parsed - } - } - - return blocks; -} - -export async function loadBlock(blockId: string, blocksDir?: string): Promise { - const all = await loadAllBlocks(blocksDir); - return all.find((b) => b.id === blockId) ?? null; -} - -export function blockRulesToRules(block: BlockDefinition): Rule[] { - return block.rules.map((r) => ({ - id: r.id, - scope: r.scope, - severity: r.severity, - content: r.content, - enabled: true, - sourceBlock: block.id, - })); -} diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index e21b420..30ac77d 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -1,79 +1,427 @@ import { join } from 'node:path'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; import type { Command } from 'commander'; import chalk from 'chalk'; -import { select } from '@inquirer/prompts'; -import { loadAllBlocks, loadBlock } from '../blocks/registry.js'; -import { installBlock } from '../blocks/installer.js'; +import { stringify, parse } from 'yaml'; +import { select, checkbox, confirm } from '@inquirer/prompts'; +import { fetchRawContent, listDirectory } from '../utils/github.js'; +import { convert } from '../core/converter.js'; import { fileExists } from '../utils/fs.js'; import { readConfig } from '../core/parser.js'; +import * as cache from '../utils/cache.js'; import * as ui from '../utils/ui.js'; import { ICONS } from '../utils/ui.js'; +import type { PulledEntry } from '../bridges/types.js'; + +const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export const BACK_VALUE = '__back__'; + +export function pluralRules(count: number): string { + return count === 1 ? '1 rule' : `${String(count)} rules`; +} export interface AddOptions { list?: boolean; noCompile?: boolean; + force?: boolean; + dryRun?: boolean; +} + +export function validateInput(input: string): { category: string; name: string } | null { + const parts = input.split('/'); + if (parts.length !== 2) return null; + + const category = parts[0]; + const name = parts[1]; + if (!category || !name) return null; + if (!KEBAB_RE.test(category) || !KEBAB_RE.test(name)) return null; + + return { category, name }; +} + +interface CachedRegistry { + categories: Array<{ + name: string; + rules: Array<{ name: string; description: string }>; + }>; +} + +export async function fetchRegistry(cwd: string): Promise { + const cached = await cache.getFromDisk(cwd, 'registry'); + + if (cached) return cached; + + ui.info('Fetching available rules from GitHub...'); + ui.newline(); + + let topLevel; + try { + topLevel = await listDirectory(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ui.error(`Could not fetch rule registry: ${msg}`); + return null; + } + + const categories: CachedRegistry['categories'] = []; + + for (const entry of topLevel) { + if (entry.type !== 'dir') continue; + + try { + const files = await listDirectory(entry.name); + const rules: Array<{ name: string; description: string }> = []; + + for (const file of files) { + if (file.type !== 'file') continue; + try { + const content = await fetchRawContent(`${entry.name}/${file.name}`); + const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content); + if (fmMatch?.[1]) { + const fm = parse(fmMatch[1]) as Record; + const description = typeof fm['description'] === 'string' ? fm['description'] : ''; + rules.push({ name: file.name, description }); + } + } catch { + rules.push({ name: file.name, description: '' }); + } + } + + if (rules.length > 0) { + categories.push({ name: entry.name, rules }); + } + } catch { + // Skip categories that fail to list + } + } + + const registry: CachedRegistry = { categories }; + await cache.set(cwd, 'registry', registry); + return registry; } -async function listAvailableBlocks(): Promise { - const blocks = await loadAllBlocks(); +async function runList(categoryFilter: string | undefined): Promise { + const cwd = process.cwd(); + const registry = await fetchRegistry(cwd); - if (blocks.length === 0) { - ui.warn('No blocks available'); + if (!registry) { + process.exitCode = 1; return; } - // Try to load installed blocks from config - let installedBlocks: string[] = []; - try { - const config = await readConfig(process.cwd()); - installedBlocks = config.blocks; - } catch { - // No config — that's fine, just don't show installed indicator + const displayCategories = categoryFilter + ? registry.categories.filter((c) => c.name === categoryFilter) + : registry.categories; + + if (displayCategories.length === 0) { + if (categoryFilter) { + ui.warn(`Category "${categoryFilter}" not found`); + } else { + ui.warn('No rules available'); + } + return; } - ui.header('Available blocks'); + ui.header('Available rules'); ui.newline(); - for (const block of blocks) { - const installed = installedBlocks.includes(block.id) ? chalk.green(` ${ICONS.success}`) + chalk.dim(' installed') : ''; - console.log(`${chalk.cyan(` ${block.id}`)} ${chalk.dim(ICONS.dash)} ${block.description}${installed}`); - console.log(` ${chalk.dim(`${String(block.rules.length)} rules ${ICONS.dot} v${block.version}`)}`); + + for (const category of displayCategories) { + console.log(` ${chalk.cyan(`${category.name}/`)}`); + for (const rule of category.rules) { + const desc = rule.description ? chalk.dim(` ${rule.description}`) : ''; + console.log(` ${chalk.white(rule.name.padEnd(20))}${desc}`); + } ui.newline(); } + + console.log(` ${chalk.dim(`Add a rule: devw add /`)}`); +} + +export function generateYamlOutput( + category: string, + name: string, + result: ReturnType, + pulledAt: string, +): string { + const source = `${category}/${name}`; + const githubUrl = `https://github.com/gpolanco/dev-workflows/blob/main/content/rules/${source}.md`; + + const header = [ + `# Pulled from: ${source} (v${result.version})`, + `# Source: ${githubUrl}`, + `# Do not edit manually — changes will be overwritten on next pull.`, + '', + ].join('\n'); + + const doc = { + source: { + registry: 'dev-workflows', + path: source, + version: result.version, + pulled_at: pulledAt, + }, + scope: result.scope, + rules: result.rules.map((r) => ({ + id: r.id, + severity: r.severity, + content: r.content, + tags: r.tags, + source: r.source, + })), + }; + + return header + stringify(doc, { lineWidth: 0 }); } -async function selectBlock(installedBlocks: string[]): Promise { - const blocks = await loadAllBlocks(); - if (blocks.length === 0) { - ui.warn('No blocks available'); - return undefined; +export async function updateConfig(cwd: string, entry: PulledEntry): Promise { + const configPath = join(cwd, '.dwf', 'config.yml'); + const raw = await readFile(configPath, 'utf-8'); + const doc = parse(raw) as Record; + + const pulled = Array.isArray(doc['pulled']) ? (doc['pulled'] as PulledEntry[]) : []; + + const existingIdx = pulled.findIndex((p) => p.path === entry.path); + if (existingIdx >= 0) { + pulled[existingIdx] = entry; + } else { + pulled.push(entry); } - const available = blocks.filter((b) => !installedBlocks.includes(b.id)); - if (available.length === 0) { - ui.success('All blocks already installed'); - return undefined; + doc['pulled'] = pulled; + await writeFile(configPath, stringify(doc, { lineWidth: 0 }), 'utf-8'); +} + +async function downloadAndInstall( + cwd: string, + category: string, + name: string, + options: AddOptions, +): Promise { + const source = `${category}/${name}`; + const fileName = `pulled-${category}-${name}.yml`; + const filePath = join(cwd, '.dwf', 'rules', fileName); + + ui.info(`Downloading ${source}...`); + + let markdown: string; + try { + markdown = await fetchRawContent(source); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ui.error(msg); + process.exitCode = 1; + return false; } + let result: ReturnType; try { - return await select({ - message: 'Which block to install?', - choices: blocks.map((b) => { - const installed = installedBlocks.includes(b.id); - return { - name: installed ? `${b.id} ${ICONS.success} installed` : `${b.id} ${ICONS.dash} ${b.description}`, - value: b.id, - disabled: installed, - }; - }), + result = convert(markdown, category, name); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ui.error(`Conversion failed: ${msg}`); + process.exitCode = 1; + return false; + } + + if (await fileExists(filePath)) { + try { + const existingRaw = await readFile(filePath, 'utf-8'); + const existingDoc = parse(existingRaw) as Record; + const existingSource = existingDoc['source'] as Record | undefined; + const existingVersion = typeof existingSource?.['version'] === 'string' ? existingSource['version'] : ''; + + if (existingVersion === result.version) { + ui.success(`Already up to date (${source} v${result.version})`); + return false; + } + + if (!options.force) { + ui.newline(); + ui.info(`${source} already exists locally (v${existingVersion} ${ICONS.arrow} v${result.version})`); + try { + const shouldOverwrite = await confirm({ + message: 'Overwrite with new version?', + default: true, + }); + if (!shouldOverwrite) { + ui.error('Cancelled'); + return false; + } + } catch { + ui.error('Cancelled'); + return false; + } + } + } catch { + // Can't parse existing file — overwrite + } + } + + const pulledAt = new Date().toISOString(); + const yamlOutput = generateYamlOutput(category, name, result, pulledAt); + + if (options.dryRun) { + ui.newline(); + ui.header('Dry run — would write:'); + ui.newline(); + console.log(chalk.dim(` ${fileName}`)); + ui.newline(); + console.log(yamlOutput); + return false; + } + + await mkdir(join(cwd, '.dwf', 'rules'), { recursive: true }); + await writeFile(filePath, yamlOutput, 'utf-8'); + + const entry: PulledEntry = { + path: source, + version: result.version, + pulled_at: pulledAt, + }; + await updateConfig(cwd, entry); + + ui.success(`Added ${source} (${pluralRules(result.rules.length)})`); + return true; +} + +async function runInteractive(cwd: string, options: AddOptions): Promise { + const registry = await fetchRegistry(cwd); + if (!registry) { + process.exitCode = 1; + return; + } + + if (registry.categories.length === 0) { + ui.warn('No rules available'); + return; + } + + let installedPaths: Set; + try { + const config = await readConfig(cwd); + installedPaths = new Set(config.pulled.map((p) => p.path)); + } catch { + installedPaths = new Set(); + } + + const allSelected: Array<{ category: string; name: string; description: string }> = []; + const processedCategories = new Set(); + + try { + for (;;) { + const availableCategories = registry.categories.filter( + (c) => !processedCategories.has(c.name), + ); + if (availableCategories.length === 0) break; + + const selectedCategoryName = await select({ + message: 'Choose a category', + choices: availableCategories.map((c) => { + const allInstalled = c.rules.every((r) => + installedPaths.has(`${c.name}/${r.name}`), + ); + const label = `${c.name} (${pluralRules(c.rules.length)})`; + return { + name: allInstalled ? `${label} ${chalk.dim('(all installed)')}` : label, + value: c.name, + }; + }), + }); + + const category = registry.categories.find((c) => c.name === selectedCategoryName); + if (!category) break; + + const selected = await checkbox({ + message: 'Select rules to add', + choices: [ + { name: '\u2190 Back to categories', value: BACK_VALUE }, + ...category.rules.map((r) => { + const path = `${category.name}/${r.name}`; + const installed = installedPaths.has(path); + const desc = r.description ? ` ${ICONS.dash} ${r.description}` : ''; + const suffix = installed ? chalk.dim(' (already installed)') : ''; + return { + name: `${r.name}${desc}${suffix}`, + value: r.name, + }; + }), + ], + }); + + const realRules = selected.filter((v) => v !== BACK_VALUE); + + if (realRules.length === 0) { + if (selected.includes(BACK_VALUE)) continue; + ui.warn('No rules selected'); + continue; + } + + for (const ruleName of realRules) { + const ruleInfo = category.rules.find((r) => r.name === ruleName); + allSelected.push({ + category: category.name, + name: ruleName, + description: ruleInfo?.description ?? '', + }); + } + processedCategories.add(category.name); + + const remaining = registry.categories.filter( + (c) => !processedCategories.has(c.name), + ); + if (remaining.length === 0) break; + + const addMore = await confirm({ + message: 'Add rules from another category?', + default: true, + }); + if (!addMore) break; + } + } catch { + ui.error('Cancelled'); + return; + } + + if (allSelected.length === 0) return; + + ui.newline(); + ui.header('Rules to install:'); + for (const rule of allSelected) { + const desc = rule.description ? chalk.dim(` ${ICONS.dash} ${rule.description}`) : ''; + console.log(` ${rule.category}/${rule.name}${desc}`); + } + ui.newline(); + + try { + const shouldProceed = await confirm({ + message: `Install ${pluralRules(allSelected.length)}?`, + default: true, }); + if (!shouldProceed) { + ui.error('Cancelled'); + return; + } } catch { - return undefined; + ui.error('Cancelled'); + return; + } + + let anyAdded = false; + for (const rule of allSelected) { + const added = await downloadAndInstall(cwd, rule.category, rule.name, options); + if (added) anyAdded = true; + } + + if (anyAdded && !options.noCompile) { + const { runCompileFromAdd } = await import('./compile.js'); + await runCompileFromAdd(); } } -async function runAdd(blockId: string | undefined, options: AddOptions): Promise { +async function runAdd(ruleArg: string | undefined, options: AddOptions): Promise { if (options.list) { - await listAvailableBlocks(); + await runList(ruleArg); return; } @@ -85,25 +433,37 @@ async function runAdd(blockId: string | undefined, options: AddOptions): Promise return; } - if (!blockId) { - const config = await readConfig(cwd); - const selected = await selectBlock(config.blocks); - if (!selected) return; - blockId = selected; + if (!ruleArg) { + if (!process.stdout.isTTY || !process.stdin.isTTY) { + ui.error('No rule specified', 'Usage: devw add /'); + process.exitCode = 1; + return; + } + + await runInteractive(cwd, options); + return; + } + + if (!ruleArg.includes('/')) { + ui.error('Block format is no longer supported', 'Use: devw add /. Run devw add --list to browse.'); + process.exitCode = 1; + return; } - const block = await loadBlock(blockId); - if (!block) { - ui.error(`Block "${blockId}" not found`, 'Run devw add --list to see available blocks'); + const parsed = validateInput(ruleArg); + if (!parsed) { + ui.error( + `Invalid rule path "${ruleArg}"`, + 'Format: / — both must be kebab-case (e.g., typescript/strict)', + ); process.exitCode = 1; return; } - const rulesAdded = await installBlock(cwd, block); - ui.success(`Added ${block.id} (${String(rulesAdded)} rules)`); + const { category, name } = parsed; + const added = await downloadAndInstall(cwd, category, name, options); - if (!options.noCompile) { - // Dynamic import to avoid circular dependency + if (added && !options.noCompile) { const { runCompileFromAdd } = await import('./compile.js'); await runCompileFromAdd(); } @@ -112,9 +472,11 @@ async function runAdd(blockId: string | undefined, options: AddOptions): Promise export function registerAddCommand(program: Command): void { program .command('add') - .argument('[block]', 'Block ID to install') - .description('Install a prebuilt rule block') - .option('--list', 'List all available blocks') + .argument('[rule]', 'Rule path: /') + .description('Add rules from the dev-workflows registry') + .option('--list', 'List available rules') .option('--no-compile', 'Skip auto-compile after adding') - .action((blockId: string | undefined, options: AddOptions) => runAdd(blockId, options)); + .option('--force', 'Overwrite without asking') + .option('--dry-run', 'Show output without writing files') + .action((rule: string | undefined, options: AddOptions) => runAdd(rule, options)); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a1483c2..3c19934 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -165,8 +165,8 @@ async function runInit(options: InitOptions): Promise { ui.newline(); ui.header("What's next"); ui.newline(); - console.log(` 1. Browse available rule blocks ${chalk.cyan('devw add --list')}`); - console.log(` 2. Install a block ${chalk.cyan('devw add typescript-strict')}`); + console.log(` 1. Browse available rules ${chalk.cyan('devw add --list')}`); + console.log(` 2. Add a rule ${chalk.cyan('devw add /')}`); console.log(` 3. Or write your own rules in ${chalk.cyan('.dwf/rules/')}`); console.log(` 4. When ready, compile ${chalk.cyan('devw compile')}`); } diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 6e8d94c..01f5bc4 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -59,20 +59,9 @@ async function listRules(): Promise { } async function listBlocks(): Promise { - const cwd = process.cwd(); - if (!(await ensureConfig(cwd))) return; - - const config = await readConfig(cwd); - - if (config.blocks.length === 0) { - ui.warn('No blocks installed'); - ui.info('Run devw add --list to see available blocks'); - return; - } - - ui.header(`Installed blocks (${String(config.blocks.length)})`); - ui.newline(); - ui.list(config.blocks); + ui.warn('Blocks have been replaced by pulled rules'); + ui.info('Run devw list rules to see installed rules'); + ui.info('Run devw add --list to browse available rules'); } async function listTools(): Promise { diff --git a/packages/cli/src/commands/pull.ts b/packages/cli/src/commands/pull.ts deleted file mode 100644 index 8b47d7d..0000000 --- a/packages/cli/src/commands/pull.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { join } from 'node:path'; -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import type { Command } from 'commander'; -import chalk from 'chalk'; -import { stringify, parse } from 'yaml'; -import { confirm } from '@inquirer/prompts'; -import { fetchRawContent, listDirectory } from '../utils/github.js'; -import { convert } from '../core/converter.js'; -import { fileExists } from '../utils/fs.js'; -import * as cache from '../utils/cache.js'; -import * as ui from '../utils/ui.js'; -import { ICONS } from '../utils/ui.js'; -import type { PulledEntry } from '../bridges/types.js'; - -const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -export interface PullOptions { - list?: boolean; - noCompile?: boolean; - force?: boolean; - dryRun?: boolean; -} - -export function validateInput(input: string): { category: string; name: string } | null { - const parts = input.split('/'); - if (parts.length !== 2) return null; - - const category = parts[0]; - const name = parts[1]; - if (!category || !name) return null; - if (!KEBAB_RE.test(category) || !KEBAB_RE.test(name)) return null; - - return { category, name }; -} - -interface CachedRegistry { - categories: Array<{ - name: string; - rules: Array<{ name: string; description: string }>; - }>; -} - -async function runList(categoryFilter: string | undefined): Promise { - const cwd = process.cwd(); - - // Try cache first - const cached = await cache.getFromDisk(cwd, 'registry'); - - let registry: CachedRegistry; - - if (cached) { - registry = cached; - } else { - ui.info('Fetching available rules from GitHub...'); - ui.newline(); - - let topLevel; - try { - topLevel = await listDirectory(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - ui.error(`Could not fetch rule registry: ${msg}`); - process.exitCode = 1; - return; - } - - const categories: CachedRegistry['categories'] = []; - - for (const entry of topLevel) { - if (entry.type !== 'dir') continue; - - try { - const files = await listDirectory(entry.name); - const rules: Array<{ name: string; description: string }> = []; - - for (const file of files) { - if (file.type !== 'file') continue; - try { - const content = await fetchRawContent(`${entry.name}/${file.name}`); - const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content); - if (fmMatch?.[1]) { - const fm = parse(fmMatch[1]) as Record; - const description = typeof fm['description'] === 'string' ? fm['description'] : ''; - rules.push({ name: file.name, description }); - } - } catch { - rules.push({ name: file.name, description: '' }); - } - } - - if (rules.length > 0) { - categories.push({ name: entry.name, rules }); - } - } catch { - // Skip categories that fail to list - } - } - - registry = { categories }; - await cache.set(cwd, 'registry', registry); - } - - // Filter if category specified - const displayCategories = categoryFilter - ? registry.categories.filter((c) => c.name === categoryFilter) - : registry.categories; - - if (displayCategories.length === 0) { - if (categoryFilter) { - ui.warn(`Category "${categoryFilter}" not found`); - } else { - ui.warn('No rules available'); - } - return; - } - - ui.header('Available rules'); - ui.newline(); - - for (const category of displayCategories) { - console.log(` ${chalk.cyan(`${category.name}/`)}`); - for (const rule of category.rules) { - const desc = rule.description ? chalk.dim(` ${rule.description}`) : ''; - console.log(` ${chalk.white(rule.name.padEnd(20))}${desc}`); - } - ui.newline(); - } - - console.log(` ${chalk.dim(`Pull a rule: devw pull /`)}`); -} - -export function generateYamlOutput( - category: string, - name: string, - result: ReturnType, - pulledAt: string, -): string { - const source = `${category}/${name}`; - const githubUrl = `https://github.com/gpolanco/dev-workflows/blob/main/content/rules/${source}.md`; - - const header = [ - `# Pulled from: ${source} (v${result.version})`, - `# Source: ${githubUrl}`, - `# Do not edit manually — changes will be overwritten on next pull.`, - '', - ].join('\n'); - - const doc = { - source: { - registry: 'dev-workflows', - path: source, - version: result.version, - pulled_at: pulledAt, - }, - scope: result.scope, - rules: result.rules.map((r) => ({ - id: r.id, - severity: r.severity, - content: r.content, - tags: r.tags, - source: r.source, - })), - }; - - return header + stringify(doc, { lineWidth: 0 }); -} - -export async function updateConfig(cwd: string, entry: PulledEntry): Promise { - const configPath = join(cwd, '.dwf', 'config.yml'); - const raw = await readFile(configPath, 'utf-8'); - const doc = parse(raw) as Record; - - const pulled = Array.isArray(doc['pulled']) ? (doc['pulled'] as PulledEntry[]) : []; - - const existingIdx = pulled.findIndex((p) => p.path === entry.path); - if (existingIdx >= 0) { - pulled[existingIdx] = entry; - } else { - pulled.push(entry); - } - - doc['pulled'] = pulled; - await writeFile(configPath, stringify(doc, { lineWidth: 0 }), 'utf-8'); -} - -async function runPull(ruleArg: string | undefined, options: PullOptions): Promise { - if (options.list) { - await runList(ruleArg); - return; - } - - if (!ruleArg) { - ui.error('Specify a rule to pull', 'Usage: devw pull /'); - process.exitCode = 1; - return; - } - - const cwd = process.cwd(); - - // Validate .dwf/ exists - if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { - ui.error('.dwf/config.yml not found', 'Run devw init to initialize the project'); - process.exitCode = 1; - return; - } - - // Validate input format - const parsed = validateInput(ruleArg); - if (!parsed) { - ui.error( - `Invalid rule path "${ruleArg}"`, - 'Format: / — both must be kebab-case (e.g., typescript/strict)', - ); - process.exitCode = 1; - return; - } - - const { category, name } = parsed; - const source = `${category}/${name}`; - const fileName = `pulled-${category}-${name}.yml`; - const filePath = join(cwd, '.dwf', 'rules', fileName); - - // Download markdown - ui.info(`Downloading ${source}...`); - - let markdown: string; - try { - markdown = await fetchRawContent(source); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - ui.error(msg); - process.exitCode = 1; - return; - } - - // Convert - let result: ReturnType; - try { - result = convert(markdown, category, name); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - ui.error(`Conversion failed: ${msg}`); - process.exitCode = 1; - return; - } - - // Check existing file - if (await fileExists(filePath)) { - try { - const existingRaw = await readFile(filePath, 'utf-8'); - const existingDoc = parse(existingRaw) as Record; - const existingSource = existingDoc['source'] as Record | undefined; - const existingVersion = typeof existingSource?.['version'] === 'string' ? existingSource['version'] : ''; - - if (existingVersion === result.version) { - ui.success(`Already up to date (${source} v${result.version})`); - return; - } - - if (!options.force) { - ui.newline(); - ui.info(`${source} already exists locally (v${existingVersion} ${ICONS.arrow} v${result.version})`); - try { - const shouldOverwrite = await confirm({ - message: 'Overwrite with new version?', - default: true, - }); - if (!shouldOverwrite) { - ui.info('Pull cancelled'); - return; - } - } catch { - ui.info('Pull cancelled'); - return; - } - } - } catch { - // Can't parse existing file — overwrite - } - } - - const pulledAt = new Date().toISOString(); - const yamlOutput = generateYamlOutput(category, name, result, pulledAt); - - // Dry run - if (options.dryRun) { - ui.newline(); - ui.header('Dry run — would write:'); - ui.newline(); - console.log(chalk.dim(` ${fileName}`)); - ui.newline(); - console.log(yamlOutput); - return; - } - - // Write file - await mkdir(join(cwd, '.dwf', 'rules'), { recursive: true }); - await writeFile(filePath, yamlOutput, 'utf-8'); - - // Update config - const entry: PulledEntry = { - path: source, - version: result.version, - pulled_at: pulledAt, - }; - await updateConfig(cwd, entry); - - ui.success(`Pulled ${source} (${String(result.rules.length)} rules)`); - - // Auto-compile - if (!options.noCompile) { - const { runCompileFromAdd } = await import('./compile.js'); - await runCompileFromAdd(); - } -} - -export function registerPullCommand(program: Command): void { - program - .command('pull') - .argument('[rule]', 'Rule path: / (e.g., typescript/strict)') - .description('Pull rules from the official dev-workflows registry') - .option('--list', 'List available rules') - .option('--no-compile', 'Skip auto-compile after pull') - .option('--force', 'Overwrite without asking') - .option('--dry-run', 'Show output without writing files') - .action((rule: string | undefined, options: PullOptions) => runPull(rule, options)); -} diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index f1e91e2..9ebaca4 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -1,26 +1,48 @@ import { join } from 'node:path'; +import { readFile, writeFile, unlink } from 'node:fs/promises'; import type { Command } from 'commander'; -import { select } from '@inquirer/prompts'; +import { parse, stringify } from 'yaml'; +import { checkbox, confirm } from '@inquirer/prompts'; import { readConfig } from '../core/parser.js'; -import { uninstallBlock } from '../blocks/installer.js'; import { fileExists } from '../utils/fs.js'; +import { validateInput } from './add.js'; import * as ui from '../utils/ui.js'; +import type { PulledEntry } from '../bridges/types.js'; -function ensureConfig(cwd: string): Promise { - return fileExists(join(cwd, '.dwf', 'config.yml')); +async function removePulledEntry(cwd: string, path: string): Promise { + const configPath = join(cwd, '.dwf', 'config.yml'); + const raw = await readFile(configPath, 'utf-8'); + const doc = parse(raw) as Record; + + const pulled = Array.isArray(doc['pulled']) ? (doc['pulled'] as PulledEntry[]) : []; + doc['pulled'] = pulled.filter((p) => p.path !== path); + + await writeFile(configPath, stringify(doc, { lineWidth: 0 }), 'utf-8'); } -async function selectBlock(blocks: string[]): Promise { - return select({ - message: 'Which block to remove?', - choices: blocks.map((id) => ({ name: id, value: id })), - }); +async function removeRule(cwd: string, path: string): Promise { + const parts = path.split('/'); + if (parts.length !== 2) return false; + + const category = parts[0]; + const name = parts[1]; + if (!category || !name) return false; + + const fileName = `pulled-${category}-${name}.yml`; + const filePath = join(cwd, '.dwf', 'rules', fileName); + + if (await fileExists(filePath)) { + await unlink(filePath); + } + + await removePulledEntry(cwd, path); + return true; } -async function runRemove(blockId?: string): Promise { +async function runRemove(ruleArg: string | undefined): Promise { const cwd = process.cwd(); - if (!(await ensureConfig(cwd))) { + if (!(await fileExists(join(cwd, '.dwf', 'config.yml')))) { ui.error('.dwf/config.yml not found', 'Run devw init to initialize the project'); process.exitCode = 1; return; @@ -28,26 +50,84 @@ async function runRemove(blockId?: string): Promise { const config = await readConfig(cwd); - if (!blockId) { - if (config.blocks.length === 0) { - ui.warn('No blocks installed'); + if (!ruleArg) { + if (config.pulled.length === 0) { + ui.warn('No rules installed'); + return; + } + + let selectedRules: string[]; + try { + selectedRules = await checkbox({ + message: 'Which rules to remove?', + choices: config.pulled.map((p) => ({ + name: `${p.path} (v${p.version})`, + value: p.path, + })), + }); + } catch { + return; + } + + if (selectedRules.length === 0) { + ui.warn('No rules selected'); return; } + try { - blockId = await selectBlock(config.blocks); + const shouldProceed = await confirm({ + message: `Remove ${String(selectedRules.length)} rule(s)?`, + default: true, + }); + if (!shouldProceed) { + ui.info('Remove cancelled'); + return; + } } catch { return; } + + for (const path of selectedRules) { + await removeRule(cwd, path); + ui.success(`Removed ${path}`); + } + + const { runCompileFromAdd } = await import('./compile.js'); + await runCompileFromAdd(); + return; + } + + if (!ruleArg.includes('/')) { + ui.error('Block format is no longer supported', 'Use: devw remove /'); + process.exitCode = 1; + return; + } + + const parsed = validateInput(ruleArg); + if (!parsed) { + ui.error( + `Invalid rule path "${ruleArg}"`, + 'Format: / — both must be kebab-case (e.g., typescript/strict)', + ); + process.exitCode = 1; + return; } - if (!config.blocks.includes(blockId)) { - ui.error(`Block "${blockId}" is not installed`, `Installed blocks: ${config.blocks.length > 0 ? config.blocks.join(', ') : '(none)'}`); + const source = `${parsed.category}/${parsed.name}`; + const installed = config.pulled.find((p) => p.path === source); + if (!installed) { + ui.error( + `Rule "${source}" is not installed`, + config.pulled.length > 0 + ? `Installed rules: ${config.pulled.map((p) => p.path).join(', ')}` + : 'No rules installed', + ); process.exitCode = 1; return; } - const rulesRemoved = await uninstallBlock(cwd, blockId); - ui.success(`Removed ${blockId} (${String(rulesRemoved)} rules)`); + await removeRule(cwd, source); + ui.success(`Removed ${source}`); const { runCompileFromAdd } = await import('./compile.js'); await runCompileFromAdd(); @@ -56,7 +136,7 @@ async function runRemove(blockId?: string): Promise { export function registerRemoveCommand(program: Command): void { program .command('remove') - .argument('[block]', 'Block ID to remove') - .description('Remove an installed rule block') - .action((blockId?: string) => runRemove(blockId)); + .argument('[rule]', 'Rule path: /') + .description('Remove an installed rule') + .action((rule?: string) => runRemove(rule)); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 755d4b9..f688cdd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,7 +8,6 @@ import { registerRemoveCommand } from './commands/remove.js'; import { registerListCommand } from './commands/list.js'; import { registerExplainCommand } from './commands/explain.js'; import { registerWatchCommand } from './commands/watch.js'; -import { registerPullCommand } from './commands/pull.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version: string }; @@ -28,7 +27,6 @@ registerRemoveCommand(program); registerListCommand(program); registerExplainCommand(program); registerWatchCommand(program); -registerPullCommand(program); program.parse(); diff --git a/packages/cli/tests/blocks/installer.test.ts b/packages/cli/tests/blocks/installer.test.ts deleted file mode 100644 index 06f04c7..0000000 --- a/packages/cli/tests/blocks/installer.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { parse } from 'yaml'; -import { installBlock, uninstallBlock } from '../../src/blocks/installer.js'; -import type { BlockDefinition } from '../../src/blocks/registry.js'; - -const VALID_CONFIG = `version: "0.1" -project: - name: "test-project" -tools: - - claude -mode: copy -blocks: [] -`; - -function makeBlock(overrides: Partial = {}): BlockDefinition { - return { - id: 'test-block', - name: 'Test Block', - description: 'A test block', - version: '1.0.0', - rules: [ - { id: 'rule-a', scope: 'conventions', severity: 'error', content: 'Rule A.' }, - { id: 'rule-b', scope: 'security', severity: 'warning', content: 'Rule B.' }, - ], - ...overrides, - }; -} - -describe('installer', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'devw-installer-')); - await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); - await writeFile(join(tmpDir, '.dwf', 'config.yml'), VALID_CONFIG); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - describe('installBlock', () => { - it('creates scope files and adds rules', async () => { - const block = makeBlock(); - const count = await installBlock(tmpDir, block); - - assert.equal(count, 2); - - const convRaw = await readFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), 'utf-8'); - const convDoc = parse(convRaw) as Record; - const convRules = convDoc['rules'] as Array>; - - assert.equal(convRules.length, 1); - assert.equal(convRules[0]?.['id'], 'rule-a'); - assert.equal(convRules[0]?.['sourceBlock'], 'test-block'); - - const secRaw = await readFile(join(tmpDir, '.dwf', 'rules', 'security.yml'), 'utf-8'); - const secDoc = parse(secRaw) as Record; - const secRules = secDoc['rules'] as Array>; - - assert.equal(secRules.length, 1); - assert.equal(secRules[0]?.['id'], 'rule-b'); - }); - - it('appends to existing scope files without duplicating', async () => { - const existingContent = `scope: conventions -rules: - - id: existing-rule - severity: error - content: Existing rule. -`; - await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), existingContent); - - const block = makeBlock({ - rules: [{ id: 'new-rule', scope: 'conventions', severity: 'warning', content: 'New rule.' }], - }); - await installBlock(tmpDir, block); - - const raw = await readFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), 'utf-8'); - const doc = parse(raw) as Record; - const rules = doc['rules'] as Array>; - - assert.equal(rules.length, 2); - assert.equal(rules[0]?.['id'], 'existing-rule'); - assert.equal(rules[1]?.['id'], 'new-rule'); - }); - - it('replaces existing block rules on re-add', async () => { - const block = makeBlock({ - rules: [{ id: 'rule-a', scope: 'conventions', severity: 'error', content: 'Original.' }], - }); - await installBlock(tmpDir, block); - - const updatedBlock = makeBlock({ - rules: [{ id: 'rule-a', scope: 'conventions', severity: 'error', content: 'Updated.' }], - }); - await installBlock(tmpDir, updatedBlock); - - const raw = await readFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), 'utf-8'); - const doc = parse(raw) as Record; - const rules = doc['rules'] as Array>; - - assert.equal(rules.length, 1); - assert.equal(rules[0]?.['content'], 'Updated.'); - }); - - it('adds block ID to config.yml blocks array', async () => { - const block = makeBlock(); - await installBlock(tmpDir, block); - - const raw = await readFile(join(tmpDir, '.dwf', 'config.yml'), 'utf-8'); - const doc = parse(raw) as Record; - const blocks = doc['blocks'] as string[]; - - assert.ok(blocks.includes('test-block')); - }); - - it('does not duplicate block ID in config on re-add', async () => { - const block = makeBlock(); - await installBlock(tmpDir, block); - await installBlock(tmpDir, block); - - const raw = await readFile(join(tmpDir, '.dwf', 'config.yml'), 'utf-8'); - const doc = parse(raw) as Record; - const blocks = doc['blocks'] as string[]; - - assert.equal(blocks.filter((b) => b === 'test-block').length, 1); - }); - }); - - describe('uninstallBlock', () => { - it('removes rules with matching sourceBlock', async () => { - const block = makeBlock({ - rules: [{ id: 'rule-a', scope: 'conventions', severity: 'error', content: 'Block rule.' }], - }); - await installBlock(tmpDir, block); - - const removed = await uninstallBlock(tmpDir, 'test-block'); - assert.equal(removed, 1); - - const raw = await readFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), 'utf-8'); - const doc = parse(raw) as Record; - const rules = doc['rules'] as Array>; - - assert.equal(rules.length, 0); - }); - - it('preserves rules from other blocks', async () => { - const blockA = makeBlock({ - id: 'block-a', - rules: [{ id: 'rule-a', scope: 'conventions', severity: 'error', content: 'From A.' }], - }); - const blockB = makeBlock({ - id: 'block-b', - rules: [{ id: 'rule-b', scope: 'conventions', severity: 'error', content: 'From B.' }], - }); - await installBlock(tmpDir, blockA); - await installBlock(tmpDir, blockB); - - await uninstallBlock(tmpDir, 'block-a'); - - const raw = await readFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), 'utf-8'); - const doc = parse(raw) as Record; - const rules = doc['rules'] as Array>; - - assert.equal(rules.length, 1); - assert.equal(rules[0]?.['id'], 'rule-b'); - }); - - it('removes block ID from config.yml', async () => { - const block = makeBlock(); - await installBlock(tmpDir, block); - await uninstallBlock(tmpDir, 'test-block'); - - const raw = await readFile(join(tmpDir, '.dwf', 'config.yml'), 'utf-8'); - const doc = parse(raw) as Record; - const blocks = doc['blocks'] as string[]; - - assert.ok(!blocks.includes('test-block')); - }); - - it('returns 0 when no rules match', async () => { - const removed = await uninstallBlock(tmpDir, 'nonexistent'); - assert.equal(removed, 0); - }); - }); -}); diff --git a/packages/cli/tests/blocks/registry.test.ts b/packages/cli/tests/blocks/registry.test.ts deleted file mode 100644 index 3f12d97..0000000 --- a/packages/cli/tests/blocks/registry.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { loadAllBlocks, loadBlock, blockRulesToRules } from '../../src/blocks/registry.js'; - -const SAMPLE_BLOCK = `id: test-block -name: "Test Block" -description: "A test block" -version: "1.0.0" - -rules: - - id: test-rule-a - scope: conventions - severity: error - content: | - Rule A content. - - - id: test-rule-b - scope: security - severity: warning - content: | - Rule B content. -`; - -const ANOTHER_BLOCK = `id: another-block -name: "Another Block" -description: "Another test block" -version: "0.2.0" - -rules: - - id: another-rule - scope: testing - severity: info - content: Some content. -`; - -describe('registry', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'devw-registry-')); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - describe('loadAllBlocks', () => { - it('loads all block files from a directory', async () => { - await writeFile(join(tmpDir, 'test-block.yml'), SAMPLE_BLOCK); - await writeFile(join(tmpDir, 'another-block.yml'), ANOTHER_BLOCK); - - const blocks = await loadAllBlocks(tmpDir); - assert.equal(blocks.length, 2); - - const ids = blocks.map((b) => b.id).sort(); - assert.deepEqual(ids, ['another-block', 'test-block']); - }); - - it('returns empty array for missing directory', async () => { - const blocks = await loadAllBlocks(join(tmpDir, 'nonexistent')); - assert.equal(blocks.length, 0); - }); - - it('skips invalid YAML files', async () => { - await writeFile(join(tmpDir, 'good.yml'), SAMPLE_BLOCK); - await writeFile(join(tmpDir, 'bad.yml'), ':\n invalid: [broken'); - - const blocks = await loadAllBlocks(tmpDir); - assert.equal(blocks.length, 1); - assert.equal(blocks[0]?.id, 'test-block'); - }); - - it('parses block metadata correctly', async () => { - await writeFile(join(tmpDir, 'test.yml'), SAMPLE_BLOCK); - - const blocks = await loadAllBlocks(tmpDir); - const block = blocks[0]!; - - assert.equal(block.id, 'test-block'); - assert.equal(block.name, 'Test Block'); - assert.equal(block.description, 'A test block'); - assert.equal(block.version, '1.0.0'); - assert.equal(block.rules.length, 2); - }); - - it('parses block rules with correct fields', async () => { - await writeFile(join(tmpDir, 'test.yml'), SAMPLE_BLOCK); - - const blocks = await loadAllBlocks(tmpDir); - const rule = blocks[0]!.rules[0]!; - - assert.equal(rule.id, 'test-rule-a'); - assert.equal(rule.scope, 'conventions'); - assert.equal(rule.severity, 'error'); - assert.ok(rule.content.includes('Rule A')); - }); - }); - - describe('loadBlock', () => { - it('loads a specific block by ID', async () => { - await writeFile(join(tmpDir, 'test-block.yml'), SAMPLE_BLOCK); - await writeFile(join(tmpDir, 'another-block.yml'), ANOTHER_BLOCK); - - const block = await loadBlock('test-block', tmpDir); - assert.ok(block); - assert.equal(block.id, 'test-block'); - }); - - it('returns null for non-existent block', async () => { - await writeFile(join(tmpDir, 'test-block.yml'), SAMPLE_BLOCK); - - const block = await loadBlock('nonexistent', tmpDir); - assert.equal(block, null); - }); - }); - - describe('blockRulesToRules', () => { - it('converts block rules to Rule objects with sourceBlock', async () => { - await writeFile(join(tmpDir, 'test.yml'), SAMPLE_BLOCK); - - const blocks = await loadAllBlocks(tmpDir); - const block = blocks[0]!; - const rules = blockRulesToRules(block); - - assert.equal(rules.length, 2); - assert.equal(rules[0]?.sourceBlock, 'test-block'); - assert.equal(rules[0]?.enabled, true); - assert.equal(rules[0]?.id, 'test-rule-a'); - }); - }); -}); diff --git a/packages/cli/tests/commands/pull.edge.test.ts b/packages/cli/tests/commands/add.test.ts similarity index 66% rename from packages/cli/tests/commands/pull.edge.test.ts rename to packages/cli/tests/commands/add.test.ts index 8fb0997..2b8d869 100644 --- a/packages/cli/tests/commands/pull.edge.test.ts +++ b/packages/cli/tests/commands/add.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { parse } from 'yaml'; -import { validateInput, generateYamlOutput, updateConfig } from '../../src/commands/pull.js'; +import { validateInput, generateYamlOutput, updateConfig, pluralRules, BACK_VALUE } from '../../src/commands/add.js'; import { convert } from '../../src/core/converter.js'; import type { PulledEntry } from '../../src/bridges/types.js'; @@ -37,7 +37,7 @@ ${extraYaml}`, ); } -describe('pull edge cases', () => { +describe('add command', () => { describe('validateInput', () => { it('returns null for empty string', () => { assert.equal(validateInput(''), null); @@ -79,17 +79,80 @@ describe('pull edge cases', () => { assert.equal(validateInput('type script/rule'), null); }); + it('returns valid result for correct input', () => { + const result = validateInput('typescript/strict'); + assert.deepEqual(result, { category: 'typescript', name: 'strict' }); + }); + it('returns valid result for segments with only numbers', () => { const result = validateInput('123/456'); assert.deepEqual(result, { category: '123', name: '456' }); }); + + it('returns valid result for kebab-case', () => { + const result = validateInput('my-category/my-rule'); + assert.deepEqual(result, { category: 'my-category', name: 'my-rule' }); + }); + }); + + describe('generateYamlOutput', () => { + it('header contains path, version, and GitHub URL', () => { + const result = convert(MOCK_MARKDOWN, 'typescript', 'strict'); + const output = generateYamlOutput('typescript', 'strict', result, '2026-02-11T00:00:00Z'); + + assert.ok(output.includes('typescript/strict')); + assert.ok(output.includes('v0.2.0')); + assert.ok(output.includes('https://github.com/gpolanco/dev-workflows/blob/main/content/rules/typescript/strict.md')); + }); + + it('roundtrip YAML: generate → parse → validate fields', () => { + const result = convert(MOCK_MARKDOWN, 'typescript', 'strict'); + const output = generateYamlOutput('typescript', 'strict', result, '2026-02-11T00:00:00Z'); + + const yamlBody = output.split('\n').filter((l) => !l.startsWith('#')).join('\n'); + const doc = parse(yamlBody) as Record; + + const source = doc['source'] as Record; + assert.equal(source['registry'], 'dev-workflows'); + assert.equal(source['path'], 'typescript/strict'); + assert.equal(source['version'], '0.2.0'); + assert.equal(source['pulled_at'], '2026-02-11T00:00:00Z'); + + const rules = doc['rules'] as Array>; + assert.ok(rules.length > 0); + assert.equal(rules[0]?.['severity'], 'error'); + assert.equal(rules[0]?.['source'], 'typescript/strict'); + }); + + it('special characters in content survive YAML roundtrip', () => { + const specialMd = `--- +name: special +description: "Special chars test" +version: "0.1.0" +scope: conventions +tags: [] +--- + +- Use backticks for \`code\`, colons: like this, and "quotes" in content. +`; + const result = convert(specialMd, 'test', 'special'); + const output = generateYamlOutput('test', 'special', result, '2026-02-11T00:00:00Z'); + + const yamlBody = output.split('\n').filter((l) => !l.startsWith('#')).join('\n'); + const doc = parse(yamlBody) as Record; + const rules = doc['rules'] as Array>; + const content = rules[0]?.['content'] as string; + assert.ok(content.includes('`code`')); + assert.ok(content.includes('colons:')); + assert.ok(content.includes('"quotes"')); + }); }); describe('updateConfig', () => { let tempDir: string; beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'dwf-pull-edge-')); + tempDir = await mkdtemp(join(tmpdir(), 'dwf-add-test-')); }); afterEach(async () => { @@ -159,57 +222,89 @@ describe('pull edge cases', () => { }); }); - describe('generateYamlOutput', () => { - it('header contains path, version, and GitHub URL', () => { - const result = convert(MOCK_MARKDOWN, 'typescript', 'strict'); - const output = generateYamlOutput('typescript', 'strict', result, '2026-02-11T00:00:00Z'); + describe('pluralRules', () => { + it('returns singular for 1', () => { + assert.equal(pluralRules(1), '1 rule'); + }); - assert.ok(output.includes('typescript/strict')); - assert.ok(output.includes('v0.2.0')); - assert.ok(output.includes('https://github.com/gpolanco/dev-workflows/blob/main/content/rules/typescript/strict.md')); + it('returns plural for 0', () => { + assert.equal(pluralRules(0), '0 rules'); }); - it('roundtrip YAML: generate → parse → validate fields', () => { - const result = convert(MOCK_MARKDOWN, 'typescript', 'strict'); - const output = generateYamlOutput('typescript', 'strict', result, '2026-02-11T00:00:00Z'); + it('returns plural for 2', () => { + assert.equal(pluralRules(2), '2 rules'); + }); - // Strip comment header lines before parsing - const yamlBody = output.split('\n').filter((l) => !l.startsWith('#')).join('\n'); - const doc = parse(yamlBody) as Record; + it('returns plural for large numbers', () => { + assert.equal(pluralRules(42), '42 rules'); + }); + }); - const source = doc['source'] as Record; - assert.equal(source['registry'], 'dev-workflows'); - assert.equal(source['path'], 'typescript/strict'); - assert.equal(source['version'], '0.2.0'); - assert.equal(source['pulled_at'], '2026-02-11T00:00:00Z'); + describe('BACK_VALUE', () => { + it('equals __back__', () => { + assert.equal(BACK_VALUE, '__back__'); + }); - const rules = doc['rules'] as Array>; - assert.ok(rules.length > 0); - assert.equal(rules[0]?.['severity'], 'error'); - assert.equal(rules[0]?.['source'], 'typescript/strict'); + it('is filtered out from real rule selections', () => { + const selected = [BACK_VALUE, 'react', 'nextjs']; + const realRules = selected.filter((v) => v !== BACK_VALUE); + assert.deepEqual(realRules, ['react', 'nextjs']); }); - it('special characters in content survive YAML roundtrip', () => { - const specialMd = `--- -name: special -description: "Special chars test" -version: "0.1.0" -scope: conventions -tags: [] ---- + it('detects back-only selection', () => { + const selected = [BACK_VALUE]; + const realRules = selected.filter((v) => v !== BACK_VALUE); + assert.equal(realRules.length, 0); + assert.ok(selected.includes(BACK_VALUE)); + }); + }); -- Use backticks for \`code\`, colons: like this, and "quotes" in content. -`; - const result = convert(specialMd, 'test', 'special'); - const output = generateYamlOutput('test', 'special', result, '2026-02-11T00:00:00Z'); + describe('installed rule detection', () => { + it('identifies installed paths from pulled entries', () => { + const pulled: PulledEntry[] = [ + { path: 'javascript/react', version: '0.1.0', pulled_at: '2026-01-01T00:00:00Z' }, + { path: 'typescript/strict', version: '0.2.0', pulled_at: '2026-01-01T00:00:00Z' }, + ]; + const installedPaths = new Set(pulled.map((p) => p.path)); + + assert.ok(installedPaths.has('javascript/react')); + assert.ok(installedPaths.has('typescript/strict')); + assert.ok(!installedPaths.has('css/tailwind')); + }); - const yamlBody = output.split('\n').filter((l) => !l.startsWith('#')).join('\n'); - const doc = parse(yamlBody) as Record; - const rules = doc['rules'] as Array>; - const content = rules[0]?.['content'] as string; - assert.ok(content.includes('`code`')); - assert.ok(content.includes('colons:')); - assert.ok(content.includes('"quotes"')); + it('detects all-installed category', () => { + const pulled: PulledEntry[] = [ + { path: 'javascript/react', version: '0.1.0', pulled_at: '2026-01-01T00:00:00Z' }, + { path: 'javascript/nextjs', version: '0.1.0', pulled_at: '2026-01-01T00:00:00Z' }, + ]; + const installedPaths = new Set(pulled.map((p) => p.path)); + + const categoryRules = [ + { name: 'react', description: 'React conventions' }, + { name: 'nextjs', description: 'Next.js patterns' }, + ]; + + const allInstalled = categoryRules.every((r) => + installedPaths.has(`javascript/${r.name}`), + ); + assert.ok(allInstalled); + }); + + it('detects partially-installed category', () => { + const pulled: PulledEntry[] = [ + { path: 'javascript/react', version: '0.1.0', pulled_at: '2026-01-01T00:00:00Z' }, + ]; + const installedPaths = new Set(pulled.map((p) => p.path)); + + const categoryRules = [ + { name: 'react', description: 'React conventions' }, + { name: 'nextjs', description: 'Next.js patterns' }, + ]; + + const allInstalled = categoryRules.every((r) => + installedPaths.has(`javascript/${r.name}`), + ); + assert.ok(!allInstalled); }); }); }); diff --git a/packages/cli/tests/commands/pull.test.ts b/packages/cli/tests/commands/pull.test.ts deleted file mode 100644 index b687f9f..0000000 --- a/packages/cli/tests/commands/pull.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { parse, stringify } from 'yaml'; -import { fileExists } from '../../src/utils/fs.js'; -import { convert } from '../../src/core/converter.js'; -import { readConfig } from '../../src/core/parser.js'; -import { readRules } from '../../src/core/parser.js'; -import type { PulledEntry } from '../../src/bridges/types.js'; - -const MOCK_MARKDOWN = `--- -name: strict -description: "Strict TypeScript conventions" -version: "0.1.0" -scope: conventions -tags: [typescript, strict] ---- - -## Type Safety - -- Never use any. Use unknown instead. - Narrow with type guards. - -- Always declare explicit return types. -`; - -async function createProject(dir: string): Promise { - await mkdir(join(dir, '.dwf', 'rules'), { recursive: true }); - await writeFile( - join(dir, '.dwf', 'config.yml'), - `version: "0.1" -project: - name: test-project -tools: - - claude -mode: copy -blocks: [] -`, - 'utf-8', - ); -} - -describe('pull command integration', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'dwf-pull-test-')); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - describe('converter integration', () => { - it('converts markdown to correct YAML structure', () => { - const result = convert(MOCK_MARKDOWN, 'typescript', 'strict'); - - assert.equal(result.name, 'strict'); - assert.equal(result.version, '0.1.0'); - assert.equal(result.scope, 'conventions'); - assert.equal(result.rules.length, 2); - - const first = result.rules[0]; - const second = result.rules[1]; - assert.ok(first); - assert.ok(second); - assert.equal(first.id, 'pulled-typescript-strict-type-safety-0'); - assert.equal(second.id, 'pulled-typescript-strict-type-safety-1'); - assert.ok(first.content.includes('Never use any')); - assert.ok(first.content.includes('Narrow with type guards')); - }); - }); - - describe('input validation', () => { - it('validates kebab-case correctly', () => { - const validKebab = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - assert.equal(validKebab.test('typescript'), true); - assert.equal(validKebab.test('TypeScript'), false); - assert.equal(validKebab.test('my-rule'), true); - assert.equal(validKebab.test('my_rule'), false); - assert.equal(validKebab.test(''), false); - }); - - it('rejects non-kebab-case segments', () => { - const validKebab = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - assert.equal(validKebab.test('TypeScript'), false); - assert.equal(validKebab.test('my_rule'), false); - assert.equal(validKebab.test('MY-RULE'), false); - assert.equal(validKebab.test('rule.name'), false); - }); - }); - - describe('config update', () => { - it('adds pulled entry to config', async () => { - await createProject(tempDir); - - const configPath = join(tempDir, '.dwf', 'config.yml'); - const raw = await readFile(configPath, 'utf-8'); - const doc = parse(raw) as Record; - - const pulled: PulledEntry[] = Array.isArray(doc['pulled']) ? doc['pulled'] as PulledEntry[] : []; - pulled.push({ - path: 'typescript/strict', - version: '0.1.0', - pulled_at: '2026-02-11T00:00:00Z', - }); - doc['pulled'] = pulled; - - await writeFile(configPath, stringify(doc, { lineWidth: 0 }), 'utf-8'); - - const updated = parse(await readFile(configPath, 'utf-8')) as Record; - const updatedPulled = updated['pulled'] as PulledEntry[]; - assert.ok(updatedPulled); - assert.equal(updatedPulled.length, 1); - const entry = updatedPulled[0]; - assert.ok(entry); - assert.equal(entry.path, 'typescript/strict'); - }); - }); - - describe('pulled file generation', () => { - it('generates valid YAML with source metadata', async () => { - await createProject(tempDir); - - const result = convert(MOCK_MARKDOWN, 'typescript', 'strict'); - - const doc = { - source: { - registry: 'dev-workflows', - path: 'typescript/strict', - version: result.version, - pulled_at: '2026-02-11T00:00:00Z', - }, - scope: result.scope, - rules: result.rules.map((r) => ({ - id: r.id, - severity: r.severity, - content: r.content, - tags: r.tags, - source: r.source, - })), - }; - - const yaml = stringify(doc, { lineWidth: 0 }); - const filePath = join(tempDir, '.dwf', 'rules', 'pulled-typescript-strict.yml'); - await writeFile(filePath, yaml, 'utf-8'); - - assert.ok(await fileExists(filePath)); - - const parsed = parse(await readFile(filePath, 'utf-8')) as Record; - const source = parsed['source'] as Record; - assert.ok(source); - assert.equal(source['registry'], 'dev-workflows'); - assert.equal(source['path'], 'typescript/strict'); - assert.equal(source['version'], '0.1.0'); - - const rules = parsed['rules'] as Array>; - assert.ok(rules); - assert.equal(rules.length, 2); - const firstRule = rules[0]; - assert.ok(firstRule); - assert.equal(firstRule['id'], 'pulled-typescript-strict-type-safety-0'); - assert.equal(firstRule['source'], 'typescript/strict'); - }); - }); - - describe('already up to date', () => { - it('detects same version as already up to date', async () => { - await createProject(tempDir); - - const existingDoc = { - source: { - registry: 'dev-workflows', - path: 'typescript/strict', - version: '0.1.0', - pulled_at: '2026-02-10T00:00:00Z', - }, - scope: 'conventions', - rules: [], - }; - - const filePath = join(tempDir, '.dwf', 'rules', 'pulled-typescript-strict.yml'); - await writeFile(filePath, stringify(existingDoc, { lineWidth: 0 }), 'utf-8'); - - const existing = parse(await readFile(filePath, 'utf-8')) as Record; - const existingSource = existing['source'] as Record; - assert.ok(existingSource); - assert.equal(existingSource['version'], '0.1.0'); - - const result = convert(MOCK_MARKDOWN, 'typescript', 'strict'); - assert.equal(result.version, existingSource['version']); - }); - }); - - describe('dry run', () => { - it('does not write files in dry run mode', async () => { - await createProject(tempDir); - - const filePath = join(tempDir, '.dwf', 'rules', 'pulled-typescript-strict.yml'); - assert.equal(await fileExists(filePath), false); - }); - }); - - describe('parser reads pulled config', () => { - it('reads pulled entries from config.yml', async () => { - await createProject(tempDir); - - const configPath = join(tempDir, '.dwf', 'config.yml'); - await writeFile( - configPath, - `version: "0.1" -project: - name: test-project -tools: - - claude -mode: copy -blocks: [] -pulled: - - path: "typescript/strict" - version: "0.1.0" - pulled_at: "2026-02-11T00:00:00Z" -`, - 'utf-8', - ); - - const config = await readConfig(tempDir); - assert.equal(config.pulled.length, 1); - const entry = config.pulled[0]; - assert.ok(entry); - assert.equal(entry.path, 'typescript/strict'); - assert.equal(entry.version, '0.1.0'); - }); - - it('defaults to empty pulled array when field is missing', async () => { - await createProject(tempDir); - - const config = await readConfig(tempDir); - assert.deepEqual(config.pulled, []); - }); - }); - - describe('readRules reads pulled files with source', () => { - it('loads rules with source field from pulled files', async () => { - await createProject(tempDir); - - const pulledDoc = { - scope: 'conventions', - rules: [ - { - id: 'pulled-typescript-strict-0', - severity: 'error', - content: 'Test rule content', - tags: ['typescript'], - source: 'typescript/strict', - }, - ], - }; - - await writeFile( - join(tempDir, '.dwf', 'rules', 'pulled-typescript-strict.yml'), - stringify(pulledDoc, { lineWidth: 0 }), - 'utf-8', - ); - - const rules = await readRules(tempDir); - assert.equal(rules.length, 1); - const first = rules[0]; - assert.ok(first); - assert.equal(first.source, 'typescript/strict'); - assert.equal(first.id, 'pulled-typescript-strict-0'); - }); - }); -}); diff --git a/packages/cli/tests/commands/remove.test.ts b/packages/cli/tests/commands/remove.test.ts new file mode 100644 index 0000000..67f15a0 --- /dev/null +++ b/packages/cli/tests/commands/remove.test.ts @@ -0,0 +1,150 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { parse, stringify } from 'yaml'; +import { fileExists } from '../../src/utils/fs.js'; +import { readConfig } from '../../src/core/parser.js'; +import type { PulledEntry } from '../../src/bridges/types.js'; + +async function createProjectWithPulled( + dir: string, + pulled: PulledEntry[] = [], +): Promise { + await mkdir(join(dir, '.dwf', 'rules'), { recursive: true }); + + const config: Record = { + version: '0.1', + project: { name: 'test-project' }, + tools: ['claude'], + mode: 'copy', + blocks: [], + }; + + if (pulled.length > 0) { + config['pulled'] = pulled; + } + + await writeFile( + join(dir, '.dwf', 'config.yml'), + stringify(config, { lineWidth: 0 }), + 'utf-8', + ); + + for (const entry of pulled) { + const parts = entry.path.split('/'); + if (parts.length !== 2) continue; + const [category, name] = parts; + const fileName = `pulled-${category}-${name}.yml`; + const ruleDoc = { + source: { + registry: 'dev-workflows', + path: entry.path, + version: entry.version, + pulled_at: entry.pulled_at, + }, + scope: 'conventions', + rules: [ + { + id: `pulled-${category}-${name}-0`, + severity: 'error', + content: `Rule from ${entry.path}`, + tags: [], + source: entry.path, + }, + ], + }; + await writeFile( + join(dir, '.dwf', 'rules', fileName), + stringify(ruleDoc, { lineWidth: 0 }), + 'utf-8', + ); + } +} + +describe('remove command', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'dwf-remove-test-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('removes rule file for existing pulled entry', async () => { + const pulled: PulledEntry[] = [ + { path: 'typescript/strict', version: '0.1.0', pulled_at: '2026-02-11T00:00:00Z' }, + ]; + await createProjectWithPulled(tempDir, pulled); + + const filePath = join(tempDir, '.dwf', 'rules', 'pulled-typescript-strict.yml'); + assert.ok(await fileExists(filePath), 'rule file should exist before remove'); + + // Simulate removeRule logic + const { unlink } = await import('node:fs/promises'); + await unlink(filePath); + + // Update config + const configPath = join(tempDir, '.dwf', 'config.yml'); + const raw = await readFile(configPath, 'utf-8'); + const doc = parse(raw) as Record; + const existingPulled = Array.isArray(doc['pulled']) ? (doc['pulled'] as PulledEntry[]) : []; + doc['pulled'] = existingPulled.filter((p) => p.path !== 'typescript/strict'); + await writeFile(configPath, stringify(doc, { lineWidth: 0 }), 'utf-8'); + + assert.equal(await fileExists(filePath), false, 'rule file should be deleted'); + const config = await readConfig(tempDir); + assert.equal(config.pulled.length, 0); + }); + + it('config shows no pulled entries after removal', async () => { + const pulled: PulledEntry[] = [ + { path: 'typescript/strict', version: '0.1.0', pulled_at: '2026-02-11T00:00:00Z' }, + { path: 'react/hooks', version: '0.1.0', pulled_at: '2026-02-11T00:00:00Z' }, + ]; + await createProjectWithPulled(tempDir, pulled); + + const configPath = join(tempDir, '.dwf', 'config.yml'); + const raw = await readFile(configPath, 'utf-8'); + const doc = parse(raw) as Record; + const existingPulled = Array.isArray(doc['pulled']) ? (doc['pulled'] as PulledEntry[]) : []; + doc['pulled'] = existingPulled.filter((p) => p.path !== 'typescript/strict'); + await writeFile(configPath, stringify(doc, { lineWidth: 0 }), 'utf-8'); + + const config = await readConfig(tempDir); + assert.equal(config.pulled.length, 1); + assert.equal(config.pulled[0]?.path, 'react/hooks'); + }); + + it('empty pulled array when no rules installed', async () => { + await createProjectWithPulled(tempDir); + const config = await readConfig(tempDir); + assert.deepEqual(config.pulled, []); + }); + + it('validates rule path format', async () => { + const { validateInput } = await import('../../src/commands/add.js'); + + assert.equal(validateInput('typescript-strict'), null, 'old block format should fail'); + assert.equal(validateInput('TypeScript/Strict'), null, 'uppercase should fail'); + assert.deepEqual(validateInput('typescript/strict'), { category: 'typescript', name: 'strict' }); + }); + + it('rule file does not exist after deletion', async () => { + const pulled: PulledEntry[] = [ + { path: 'testing/basics', version: '0.1.0', pulled_at: '2026-02-11T00:00:00Z' }, + ]; + await createProjectWithPulled(tempDir, pulled); + + const filePath = join(tempDir, '.dwf', 'rules', 'pulled-testing-basics.yml'); + assert.ok(await fileExists(filePath)); + + const { unlink } = await import('node:fs/promises'); + await unlink(filePath); + + assert.equal(await fileExists(filePath), false); + }); +}); diff --git a/packages/cli/tests/e2e/cli.test.ts b/packages/cli/tests/e2e/cli.test.ts index 2bfb852..a1ca14e 100644 --- a/packages/cli/tests/e2e/cli.test.ts +++ b/packages/cli/tests/e2e/cli.test.ts @@ -2,7 +2,7 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { execFile as execFileCb } from 'node:child_process'; import { promisify } from 'node:util'; -import { mkdtemp, rm, readFile, writeFile, access } from 'node:fs/promises'; +import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; @@ -32,15 +32,6 @@ async function run(args: string[], cwd: string): Promise { } } -async function fileExists(filePath: string): Promise { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - describe('devw CLI e2e', () => { let tmpDir: string; @@ -59,13 +50,6 @@ describe('devw CLI e2e', () => { assert.equal(result.exitCode, 0); assert.ok(result.stdout.includes('Initialized')); - assert.ok(await fileExists(join(tmpDir, '.dwf', 'config.yml'))); - - const scopes = ['architecture', 'conventions', 'security', 'workflow', 'testing']; - for (const scope of scopes) { - assert.ok(await fileExists(join(tmpDir, '.dwf', 'rules', `${scope}.yml`))); - } - const config = await readFile(join(tmpDir, '.dwf', 'config.yml'), 'utf-8'); assert.ok(config.includes('claude')); assert.ok(config.includes('copy')); @@ -77,7 +61,6 @@ describe('devw CLI e2e', () => { assert.equal(result.exitCode, 1); assert.ok(result.stderr.includes('Unknown mode')); assert.ok(result.stderr.includes('copy, link')); - assert.ok(!result.stderr.includes('at '), 'should not show stack trace'); }); it('init rejects invalid tool with clear error', async () => { @@ -85,18 +68,6 @@ describe('devw CLI e2e', () => { assert.equal(result.exitCode, 1); assert.ok(result.stderr.includes('Unknown tool')); - assert.ok(result.stderr.includes('noexiste')); - assert.ok(!result.stderr.includes('at '), 'should not show stack trace'); - }); - - it('init accepts link mode', async () => { - const result = await run(['init', '--tools', 'claude', '--mode', 'link', '-y'], tmpDir); - - assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Initialized')); - - const config = await readFile(join(tmpDir, '.dwf', 'config.yml'), 'utf-8'); - assert.ok(config.includes('link')); }); it('init fails when .dwf/ already exists', async () => { @@ -107,23 +78,22 @@ describe('devw CLI e2e', () => { assert.ok(result.stderr.includes('already exists')); }); - it('add typescript-strict merges rules and updates config', async () => { + it('compile generates CLAUDE.md with markers when rules exist', async () => { await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['add', 'typescript-strict', '--no-compile'], tmpDir); - assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Added')); + // Write a manual rule so compile has something to output + const rulesPath = join(tmpDir, '.dwf', 'rules', 'conventions.yml'); + await writeFile( + rulesPath, + `scope: conventions +rules: + - id: test-rule + severity: error + content: Always test your code. +`, + 'utf-8', + ); - const conventions = await readFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), 'utf-8'); - assert.ok(conventions.includes('ts-strict-no-any')); - - const config = await readFile(join(tmpDir, '.dwf', 'config.yml'), 'utf-8'); - assert.ok(config.includes('typescript-strict')); - }); - - it('compile generates CLAUDE.md with markers', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'typescript-strict', '--no-compile'], tmpDir); const result = await run(['compile'], tmpDir); assert.equal(result.exitCode, 0); @@ -132,22 +102,29 @@ describe('devw CLI e2e', () => { const claudeMd = await readFile(join(tmpDir, 'CLAUDE.md'), 'utf-8'); assert.ok(claudeMd.includes('')); assert.ok(claudeMd.includes('')); - assert.ok(claudeMd.includes('# Project Rules')); }); it('compile preserves user content outside markers', async () => { await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'typescript-strict', '--no-compile'], tmpDir); - // First compile to create CLAUDE.md with markers + const rulesPath = join(tmpDir, '.dwf', 'rules', 'conventions.yml'); + await writeFile( + rulesPath, + `scope: conventions +rules: + - id: test-rule + severity: error + content: Always test your code. +`, + 'utf-8', + ); + await run(['compile'], tmpDir); - // Add user content before and after the markers const claudeMd = await readFile(join(tmpDir, 'CLAUDE.md'), 'utf-8'); const withUserContent = `# My Custom Rules\n\nDo not touch this.\n\n${claudeMd}\n# Footer\n\nAlso keep this.\n`; await writeFile(join(tmpDir, 'CLAUDE.md'), withUserContent, 'utf-8'); - // Recompile const result = await run(['compile'], tmpDir); assert.equal(result.exitCode, 0); @@ -157,48 +134,35 @@ describe('devw CLI e2e', () => { assert.ok(updated.includes('# Footer')); assert.ok(updated.includes('Also keep this.')); assert.ok(updated.includes('')); - assert.ok(updated.includes('# Project Rules')); }); - it('compile --dry-run preserves user content outside markers', async () => { + it('list rules shows rules from rule files', async () => { await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'typescript-strict', '--no-compile'], tmpDir); - // First compile to create CLAUDE.md with markers - await run(['compile'], tmpDir); + const rulesPath = join(tmpDir, '.dwf', 'rules', 'conventions.yml'); + await writeFile( + rulesPath, + `scope: conventions +rules: + - id: manual-rule + severity: error + content: A manual rule. +`, + 'utf-8', + ); - // Add user content before and after the markers - const claudeMd = await readFile(join(tmpDir, 'CLAUDE.md'), 'utf-8'); - const withUserContent = `# My Custom Rules\n\nDo not touch this.\n\n${claudeMd}\n# Footer\n\nAlso keep this.\n`; - await writeFile(join(tmpDir, 'CLAUDE.md'), withUserContent, 'utf-8'); - - // Dry-run should show merged content (not just raw rules) - const result = await run(['compile', '--dry-run'], tmpDir); - assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('# My Custom Rules'), 'dry-run should include user content before markers'); - assert.ok(result.stdout.includes('Do not touch this.'), 'dry-run should include user content before markers'); - assert.ok(result.stdout.includes('# Footer'), 'dry-run should include user content after markers'); - assert.ok(result.stdout.includes('')); - assert.ok(result.stdout.includes('# Project Rules')); - }); - - it('list rules shows added rules', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'typescript-strict', '--no-compile'], tmpDir); const result = await run(['list', 'rules'], tmpDir); - assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('ts-strict-no-any')); - assert.ok(result.stdout.includes('ts-strict-explicit-returns')); + assert.ok(result.stdout.includes('manual-rule')); }); - it('list blocks shows installed blocks', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'typescript-strict', '--no-compile'], tmpDir); + it('list blocks shows deprecation message', async () => { const result = await run(['list', 'blocks'], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('typescript-strict')); + assert.ok(result.stdout.includes('Blocks have been replaced')); + assert.ok(result.stdout.includes('devw list rules')); + assert.ok(result.stdout.includes('devw add --list')); }); it('list tools shows configured tools', async () => { @@ -211,7 +175,6 @@ describe('devw CLI e2e', () => { it('doctor passes on valid project', async () => { await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'typescript-strict'], tmpDir); const result = await run(['doctor'], tmpDir); assert.equal(result.exitCode, 0); @@ -219,83 +182,53 @@ describe('devw CLI e2e', () => { assert.ok(result.stdout.includes('config.yml is valid')); }); - it('add --list shows all available blocks', async () => { - const result = await run(['add', '--list'], tmpDir); + it('add without args and non-TTY exits with error', async () => { + await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + // execFile runs in non-TTY mode by default + const result = await run(['add'], tmpDir); - assert.equal(result.exitCode, 0); - const expected = [ - 'typescript-strict', - 'react-conventions', - 'nextjs-approuter', - 'tailwind', - 'testing-basics', - 'supabase-rls', - ]; - for (const block of expected) { - assert.ok(result.stdout.includes(block), `should list block "${block}"`); - } + assert.equal(result.exitCode, 1); + assert.ok(result.stderr.includes('No rule specified')); }); - it('remove typescript-strict removes rules and updates config', async () => { + it('add with old block format exits with error', async () => { await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'typescript-strict', '--no-compile'], tmpDir); + const result = await run(['add', 'typescript-strict'], tmpDir); - const result = await run(['remove', 'typescript-strict'], tmpDir); - assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Removed')); - - const conventions = await readFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), 'utf-8'); - assert.ok(!conventions.includes('ts-strict-no-any')); - - const config = await readFile(join(tmpDir, '.dwf', 'config.yml'), 'utf-8'); - assert.ok(!config.includes('typescript-strict')); + assert.equal(result.exitCode, 1); + assert.ok(result.stderr.includes('Block format is no longer supported')); }); - it('remove recompiles output files so removed rules disappear', async () => { + it('add with invalid format exits with error', async () => { await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'supabase-rls'], tmpDir); - await run(['add', 'typescript-strict'], tmpDir); + const result = await run(['add', 'INVALID/FORMAT'], tmpDir); - const before = await readFile(join(tmpDir, 'CLAUDE.md'), 'utf-8'); - assert.ok(before.includes('Every new table must have RLS policies'), 'CLAUDE.md should contain supabase rules before remove'); - assert.ok(before.includes('explicit return types'), 'CLAUDE.md should contain typescript-strict rules'); - - const result = await run(['remove', 'supabase-rls'], tmpDir); - assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Compiled'), 'remove should trigger recompile'); - - const after = await readFile(join(tmpDir, 'CLAUDE.md'), 'utf-8'); - assert.ok(!after.includes('Every new table must have RLS policies'), 'CLAUDE.md should not contain supabase rules after remove'); - assert.ok(after.includes('explicit return types'), 'CLAUDE.md should still contain typescript-strict rules'); + assert.equal(result.exitCode, 1); + assert.ok(result.stderr.includes('Invalid rule path')); }); - it('remove last block deletes output file when no user content', async () => { + it('remove without pulled rules shows warning', async () => { await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'supabase-rls'], tmpDir); + // Non-TTY, no args, no pulled → should warn + const result = await run(['remove'], tmpDir); - assert.ok(await fileExists(join(tmpDir, 'CLAUDE.md')), 'CLAUDE.md should exist before remove'); - - const result = await run(['remove', 'supabase-rls'], tmpDir); assert.equal(result.exitCode, 0); - - assert.ok(!(await fileExists(join(tmpDir, 'CLAUDE.md'))), 'CLAUDE.md should be deleted when no user content remains'); + assert.ok(result.stdout.includes('No rules installed')); }); - it('remove last block preserves user content outside markers', async () => { + it('remove with old block format exits with error', async () => { await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['add', 'supabase-rls'], tmpDir); + const result = await run(['remove', 'typescript-strict'], tmpDir); - // Add user content around the markers - const claudeMd = await readFile(join(tmpDir, 'CLAUDE.md'), 'utf-8'); - await writeFile(join(tmpDir, 'CLAUDE.md'), `# My Notes\n\nKeep this.\n\n${claudeMd}`, 'utf-8'); + assert.equal(result.exitCode, 1); + assert.ok(result.stderr.includes('Block format is no longer supported')); + }); - const result = await run(['remove', 'supabase-rls'], tmpDir); - assert.equal(result.exitCode, 0); + it('remove non-installed rule exits with error', async () => { + await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + const result = await run(['remove', 'typescript/strict'], tmpDir); - const after = await readFile(join(tmpDir, 'CLAUDE.md'), 'utf-8'); - assert.ok(after.includes('# My Notes'), 'user content should be preserved'); - assert.ok(after.includes('Keep this.'), 'user content should be preserved'); - assert.ok(!after.includes(''), 'markers should be removed'); - assert.ok(!after.includes('RLS policies'), 'rules should be removed'); + assert.equal(result.exitCode, 1); + assert.ok(result.stderr.includes('not installed')); }); }); diff --git a/packages/cli/tests/ui/output.test.ts b/packages/cli/tests/ui/output.test.ts index 79d3beb..6b88b9c 100644 --- a/packages/cli/tests/ui/output.test.ts +++ b/packages/cli/tests/ui/output.test.ts @@ -41,16 +41,6 @@ mode: copy blocks: [] `; -const CONFIG_WITH_BLOCK = (tools: string[], blocks: string[]): string => `version: "0.1" -project: - name: "test-project" -tools: -${tools.map((t) => ` - ${t}`).join('\n')} -mode: copy -blocks: -${blocks.map((b) => ` - ${b}`).join('\n')} -`; - const RULES_CONVENTIONS = `scope: conventions rules: - id: ts-strict-no-any @@ -155,36 +145,7 @@ describe('output format: doctor', () => { }); }); -describe('output format: add --list', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'devw-output-')); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('shows blocks with middle dot separator', async () => { - const result = await run(['add', '--list'], tmpDir); - - assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('\u00B7'), 'should have middle dot separator'); - assert.ok(result.stdout.includes('rules'), 'should show rule count'); - }); - - it('shows installed indicator when block is installed', async () => { - await mkdir(join(tmpDir, '.dwf', 'rules'), { recursive: true }); - await writeFile(join(tmpDir, '.dwf', 'config.yml'), CONFIG_WITH_BLOCK(['claude'], ['typescript-strict'])); - await writeFile(join(tmpDir, '.dwf', 'rules', 'conventions.yml'), RULES_CONVENTIONS); - - const result = await run(['add', '--list'], tmpDir); - - assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('installed'), 'should show installed indicator'); - }); -}); +// add --list tests removed: now fetches from GitHub, requires network mocks describe('output format: list rules', () => { let tmpDir: string;