From 89a0723437ea831c62e7b6b8d7ed44a8f5543eda Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Wed, 15 Apr 2026 00:57:45 +0200 Subject: [PATCH 1/3] feat(e2e): add registry end-to-end tests and fix init overwrite in non-TTY - Add 5 e2e tests covering devw add --list, --search, --tag, --dry-run, and full install with config/cache verification - Fix init command crashing when .dwf/ exists and --yes flag is used in non-TTY mode: skip confirmPrompt, exit cleanly with exit code 0 - Update test to reflect new idempotent init behavior with -y - Remove stale compiled rules section from CLAUDE.md (v2 writes to .claude/rules/ via directory bridge, not inline to CLAUDE.md) --- CLAUDE.md | 33 --- packages/cli/src/commands/init.ts | 16 +- packages/cli/tests/e2e/cli.test.ts | 401 +++++++++++++++++++---------- 3 files changed, 277 insertions(+), 173 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c87f6d..f0649fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,36 +55,3 @@ If you are on `main`, stop and create a branch before doing anything else. - Do not add dependencies not listed in `openspec/specs/cli/spec.md` without asking. - Do not create new documentation trees. Update existing docs instead. - Do not move files or rename directories unless explicitly instructed. - - -# Project Rules - -## Conventions - -- Never use `any`. Use `unknown` when the type is truly unknown, - then narrow with type guards. - -- Always declare explicit return types on exported functions. - Inferred types are fine for internal/private functions. - -- Prefer union types over enums. - Use `as const` objects when you need runtime values. - -- Never use non-null assertion (!). Handle null/undefined explicitly - with optional chaining, nullish coalescing, or type guards. - -- Follow the Rules of Hooks: only call hooks at the top level, - never inside conditions or loops. Custom hooks must start - with "use". - -- Use PascalCase for component names and their files. - Use camelCase for hook files prefixed with "use" - (e.g. useAuth.ts). - -- Prefer composition over prop drilling. Use children, - render props, or context for shared behavior rather than - deeply nested prop chains. - -- Avoid inline styles. Use CSS modules, Tailwind classes, - or styled-components for styling. - diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index f988b93..3fc8ef2 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -167,12 +167,18 @@ export async function runInit(options: InitOptions): Promise { ? '~/.dwf/ already exists.' : '.dwf/ already exists in this directory.'; ui.warn(locationHint); - const overwrite = await confirmPrompt({ - message: 'Overwrite config? (rules will be preserved)', - defaultValue: false, - }); + + const overwrite = options.yes + ? false + : await confirmPrompt({ + message: 'Overwrite config? (rules will be preserved)', + defaultValue: false, + }); + if (!overwrite) { - outroPrompt('Init cancelled.'); + if (isInteractiveSession() && !options.yes) { + outroPrompt('Init cancelled.'); + } return; } } diff --git a/packages/cli/tests/e2e/cli.test.ts b/packages/cli/tests/e2e/cli.test.ts index c3ca300..530fee8 100644 --- a/packages/cli/tests/e2e/cli.test.ts +++ b/packages/cli/tests/e2e/cli.test.ts @@ -1,16 +1,16 @@ -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 } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { tmpdir } from 'node:os'; +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 } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; const execFile = promisify(execFileCb); const __dirname = dirname(fileURLToPath(import.meta.url)); -const DEVW = join(__dirname, '..', '..', '..', 'bin', 'devw.js'); +const DEVW = join(__dirname, "..", "..", "..", "bin", "devw.js"); const NODE = process.execPath; interface RunResult { @@ -23,66 +23,83 @@ async function run(args: string[], cwd: string): Promise { try { const { stdout, stderr } = await execFile(NODE, [DEVW, ...args], { cwd, - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, }); return { stdout, stderr, exitCode: 0 }; } catch (err: unknown) { const e = err as { stdout: string; stderr: string; code: number }; - return { stdout: e.stdout ?? '', stderr: e.stderr ?? '', exitCode: e.code ?? 1 }; + return { + stdout: e.stdout ?? "", + stderr: e.stderr ?? "", + exitCode: e.code ?? 1, + }; } } -describe('devw CLI e2e', () => { +describe("devw CLI e2e", () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'devw-e2e-')); - await execFile('git', ['init'], { cwd: tmpDir }); + tmpDir = await mkdtemp(join(tmpdir(), "devw-e2e-")); + await execFile("git", ["init"], { cwd: tmpDir }); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); - it('init creates config and rule files', async () => { - const result = await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("init creates config and rule files", async () => { + const result = await run( + ["init", "--tools", "claude", "--mode", "copy", "-y"], + tmpDir + ); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Initialized')); + assert.ok(result.stdout.includes("Initialized")); - const config = await readFile(join(tmpDir, '.dwf', 'config.yml'), 'utf-8'); - assert.ok(config.includes('claude')); - assert.ok(config.includes('copy')); + const config = await readFile(join(tmpDir, ".dwf", "config.yml"), "utf-8"); + assert.ok(config.includes("claude")); + assert.ok(config.includes("copy")); }); - it('init rejects invalid mode with clear error', async () => { - const result = await run(['init', '--tools', 'claude', '--mode', 'symlinks', '-y'], tmpDir); + it("init rejects invalid mode with clear error", async () => { + const result = await run( + ["init", "--tools", "claude", "--mode", "symlinks", "-y"], + tmpDir + ); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('Unknown mode')); - assert.ok(result.stderr.includes('copy, link')); + assert.ok(result.stderr.includes("Unknown mode")); + assert.ok(result.stderr.includes("copy, link")); }); - it('init rejects invalid tool with clear error', async () => { - const result = await run(['init', '--tools', 'noexiste', '--mode', 'copy', '-y'], tmpDir); + it("init rejects invalid tool with clear error", async () => { + const result = await run( + ["init", "--tools", "noexiste", "--mode", "copy", "-y"], + tmpDir + ); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('Unknown tool')); + assert.ok(result.stderr.includes("Unknown tool")); }); - it('init fails when .dwf/ already exists', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("init with -y exits cleanly when .dwf/ already exists", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run( + ["init", "--tools", "claude", "--mode", "copy", "-y"], + tmpDir + ); - assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('already exists')); + assert.equal(result.exitCode, 0); + const combined = result.stdout + result.stderr; + assert.ok(combined.includes("already exists")); }); - it('compile generates CLAUDE.md when rules exist', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("compile generates CLAUDE.md when rules exist", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); // Write a manual rule so compile has something to output - const rulesPath = join(tmpDir, '.dwf', 'rules', 'conventions.yml'); + const rulesPath = join(tmpDir, ".dwf", "rules", "conventions.yml"); await writeFile( rulesPath, `scope: conventions @@ -91,23 +108,26 @@ rules: severity: error content: Always test your code. `, - 'utf-8', + "utf-8" ); - const result = await run(['compile'], tmpDir); + const result = await run(["compile"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Compiled')); + assert.ok(result.stdout.includes("Compiled")); - const claudeMd = await readFile(join(tmpDir, '.claude', 'rules', 'dwf-conventions.md'), 'utf-8'); - assert.ok(claudeMd.includes('# Conventions')); - assert.ok(claudeMd.includes('Always test your code.')); + const claudeMd = await readFile( + join(tmpDir, ".claude", "rules", "dwf-conventions.md"), + "utf-8" + ); + assert.ok(claudeMd.includes("# Conventions")); + assert.ok(claudeMd.includes("Always test your code.")); }); - it('compile with copilot preserves user content outside markers', async () => { - await run(['init', '--tools', 'copilot', '--mode', 'copy', '-y'], tmpDir); + it("compile with copilot preserves user content outside markers", async () => { + await run(["init", "--tools", "copilot", "--mode", "copy", "-y"], tmpDir); - const rulesPath = join(tmpDir, '.dwf', 'rules', 'conventions.yml'); + const rulesPath = join(tmpDir, ".dwf", "rules", "conventions.yml"); await writeFile( rulesPath, `scope: conventions @@ -116,30 +136,40 @@ rules: severity: error content: Always test your code. `, - 'utf-8', + "utf-8" ); - await run(['compile'], tmpDir); + await run(["compile"], tmpDir); - const copilotMd = await readFile(join(tmpDir, '.github', 'copilot-instructions.md'), 'utf-8'); + const copilotMd = await readFile( + join(tmpDir, ".github", "copilot-instructions.md"), + "utf-8" + ); const withUserContent = `# My Custom Rules\n\nDo not touch this.\n\n${copilotMd}\n# Footer\n\nAlso keep this.\n`; - await writeFile(join(tmpDir, '.github', 'copilot-instructions.md'), withUserContent, 'utf-8'); + await writeFile( + join(tmpDir, ".github", "copilot-instructions.md"), + withUserContent, + "utf-8" + ); - const result = await run(['compile'], tmpDir); + const result = await run(["compile"], tmpDir); assert.equal(result.exitCode, 0); - const updated = await readFile(join(tmpDir, '.github', 'copilot-instructions.md'), 'utf-8'); - assert.ok(updated.includes('# My Custom Rules')); - assert.ok(updated.includes('Do not touch this.')); - assert.ok(updated.includes('# Footer')); - assert.ok(updated.includes('Also keep this.')); - assert.ok(updated.includes('')); + const updated = await readFile( + join(tmpDir, ".github", "copilot-instructions.md"), + "utf-8" + ); + assert.ok(updated.includes("# My Custom Rules")); + assert.ok(updated.includes("Do not touch this.")); + assert.ok(updated.includes("# Footer")); + assert.ok(updated.includes("Also keep this.")); + assert.ok(updated.includes("")); }); - it('list rules shows rules from rule files', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("list rules shows rules from rule files", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); - const rulesPath = join(tmpDir, '.dwf', 'rules', 'conventions.yml'); + const rulesPath = join(tmpDir, ".dwf", "rules", "conventions.yml"); await writeFile( rulesPath, `scope: conventions @@ -148,192 +178,293 @@ rules: severity: error content: A manual rule. `, - 'utf-8', + "utf-8" ); - const result = await run(['list', 'rules'], tmpDir); + const result = await run(["list", "rules"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('manual-rule')); + assert.ok(result.stdout.includes("manual-rule")); }); - it('list blocks shows deprecation message', async () => { - const result = await run(['list', 'blocks'], 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('Blocks have been replaced')); - assert.ok(result.stdout.includes('devw list rules')); - assert.ok(result.stdout.includes('devw add --list')); + 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 () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['list', 'tools'], tmpDir); + it("list tools shows configured tools", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["list", "tools"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('claude')); + assert.ok(result.stdout.includes("claude")); }); - it('doctor passes on valid project', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['compile'], tmpDir); - const result = await run(['doctor'], tmpDir); + it("doctor passes on valid project", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + await run(["compile"], tmpDir); + const result = await run(["doctor"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('config.yml exists')); - assert.ok(result.stdout.includes('config.yml is valid')); + assert.ok(result.stdout.includes("config.yml exists")); + assert.ok(result.stdout.includes("config.yml is valid")); }); - it('devw with no args in non-TTY exits 0 and prints usage', async () => { + it("devw with no args in non-TTY exits 0 and prints usage", async () => { // execFile runs in non-TTY by default — menu should display help instead of prompting const result = await run([], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Usage:')); + assert.ok(result.stdout.includes("Usage:")); }); - it('add without args and non-TTY exits with error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], 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); + const result = await run(["add"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('No rule specified')); + assert.ok(result.stderr.includes("No rule specified")); }); - it('add with old block format exits with error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['add', 'typescript-strict'], tmpDir); + it("add with old block format exits with error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "typescript-strict"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('is no longer supported')); + assert.ok(result.stderr.includes("is no longer supported")); }); - it('add with old block format includes category/name hint in error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['add', 'typescript-strict'], tmpDir); + it("add with old block format includes category/name hint in error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "typescript-strict"], tmpDir); assert.equal(result.exitCode, 1); // Should suggest typescript/strict based on first dash split - assert.ok(result.stderr.includes('typescript/strict')); + assert.ok(result.stderr.includes("typescript/strict")); }); - it('add with invalid format exits with error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['add', 'INVALID/FORMAT'], tmpDir); + it("add with invalid format exits with error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "INVALID/FORMAT"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('Invalid rule path')); + assert.ok(result.stderr.includes("Invalid rule path")); }); - it('remove without args in non-TTY shows usage error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("remove without args in non-TTY shows usage error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); // Non-TTY, no args → should error with usage hint - const result = await run(['remove'], tmpDir); + const result = await run(["remove"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('No rule specified')); + assert.ok(result.stderr.includes("No rule specified")); }); - it('remove with old block format exits with error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['remove', 'typescript-strict'], tmpDir); + it("remove with old block format exits with error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["remove", "typescript-strict"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('is no longer supported')); + assert.ok(result.stderr.includes("is no longer supported")); }); - it('remove with old block format includes category/name hint in error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['remove', 'typescript-strict'], tmpDir); + it("remove with old block format includes category/name hint in error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["remove", "typescript-strict"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('typescript/strict')); + assert.ok(result.stderr.includes("typescript/strict")); }); - 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); + 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); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('not installed')); + assert.ok(result.stderr.includes("not installed")); + }); + + it( + "add --list shows available registry rules", + { timeout: 30_000 }, + async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "--list"], tmpDir); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("Available rules")); + assert.ok(result.stdout.includes("typescript")); + assert.ok(result.stdout.includes("strict")); + } + ); + + it( + "add --list --search filters rules by term", + { timeout: 30_000 }, + async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run( + ["add", "--list", "--search", "typescript"], + tmpDir + ); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("strict")); + } + ); + + it("add --list --tag filters rules by tag", { timeout: 30_000 }, async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "--list", "--tag", "typescript"], tmpDir); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("strict")); }); - it('compile generates multi-file output for directory bridges', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it( + "add --dry-run shows preview without writing rule file", + { timeout: 30_000 }, + async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run( + ["add", "--dry-run", "typescript/strict"], + tmpDir + ); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("Dry run")); + assert.ok(result.stdout.includes("pulled-typescript-strict.yml")); + + try { + await readFile( + join(tmpDir, ".dwf", "rules", "pulled-typescript-strict.yml"), + "utf-8" + ); + assert.fail("Rule file must not be created in dry-run mode"); + } catch (err) { + assert.equal((err as NodeJS.ErrnoException).code, "ENOENT"); + } + } + ); + + it( + "add installs rule, creates file, and updates config", + { timeout: 30_000 }, + async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "typescript/strict"], tmpDir); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("Added typescript/strict")); + + const ruleFile = await readFile( + join(tmpDir, ".dwf", "rules", "pulled-typescript-strict.yml"), + "utf-8" + ); + assert.ok(ruleFile.includes("source:")); + assert.ok(ruleFile.includes("typescript/strict")); + + const config = await readFile( + join(tmpDir, ".dwf", "config.yml"), + "utf-8" + ); + assert.ok(config.includes("pulled:")); + assert.ok(config.includes("typescript/strict")); + + const cacheData = await readFile( + join(tmpDir, ".dwf", ".cache", "registry.json"), + "utf-8" + ); + assert.ok(cacheData.includes("rules")); + } + ); + + it("compile generates multi-file output for directory bridges", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); // Write two scope files await writeFile( - join(tmpDir, '.dwf', 'rules', 'conventions.yml'), + join(tmpDir, ".dwf", "rules", "conventions.yml"), `scope: conventions rules: - id: named-exports severity: error content: Always use named exports. `, - 'utf-8', + "utf-8" ); await writeFile( - join(tmpDir, '.dwf', 'rules', 'security.yml'), + join(tmpDir, ".dwf", "rules", "security.yml"), `scope: security rules: - id: no-eval severity: error content: Never use eval. `, - 'utf-8', + "utf-8" ); - const result = await run(['compile'], tmpDir); + const result = await run(["compile"], tmpDir); assert.equal(result.exitCode, 0); // Both scope files should be generated - const convFile = await readFile(join(tmpDir, '.claude', 'rules', 'dwf-conventions.md'), 'utf-8'); - assert.ok(convFile.includes('named exports')); + const convFile = await readFile( + join(tmpDir, ".claude", "rules", "dwf-conventions.md"), + "utf-8" + ); + assert.ok(convFile.includes("named exports")); - const secFile = await readFile(join(tmpDir, '.claude', 'rules', 'dwf-security.md'), 'utf-8'); - assert.ok(secFile.includes('eval')); + const secFile = await readFile( + join(tmpDir, ".claude", "rules", "dwf-security.md"), + "utf-8" + ); + assert.ok(secFile.includes("eval")); }); - it('compile --dry-run lists files without writing', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("compile --dry-run lists files without writing", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); await writeFile( - join(tmpDir, '.dwf', 'rules', 'conventions.yml'), + join(tmpDir, ".dwf", "rules", "conventions.yml"), `scope: conventions rules: - id: named-exports severity: error content: Always use named exports. `, - 'utf-8', + "utf-8" ); - const result = await run(['compile', '--dry-run'], tmpDir); + const result = await run(["compile", "--dry-run"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Dry run')); - assert.ok(result.stdout.includes('.claude/rules/dwf-conventions.md')); + assert.ok(result.stdout.includes("Dry run")); + assert.ok(result.stdout.includes(".claude/rules/dwf-conventions.md")); }); - it('explain shows multi-file output paths for directory bridges', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("explain shows multi-file output paths for directory bridges", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); await writeFile( - join(tmpDir, '.dwf', 'rules', 'conventions.yml'), + join(tmpDir, ".dwf", "rules", "conventions.yml"), `scope: conventions rules: - id: named-exports severity: error content: Always use named exports. `, - 'utf-8', + "utf-8" ); - const result = await run(['explain'], tmpDir); + const result = await run(["explain"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('multi-file')); - assert.ok(result.stdout.includes('.claude/rules/dwf-')); + assert.ok(result.stdout.includes("multi-file")); + assert.ok(result.stdout.includes(".claude/rules/dwf-")); }); }); From 1028d48559d5d8c78f0dfd27089dafabe21bd18e Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Wed, 15 Apr 2026 01:56:13 +0200 Subject: [PATCH 2/3] feat(ux): add preview card and confirmation before writing in init and add - init: show summary card (location, tools, mode, files) + single confirm before writing; consolidate 3 spinners into 1 - add (direct): show preview card with rule description, scope, version, and target file before confirming install; skipped with --force/--dry-run - add (interactive): replace header+loop summary with notePrompt for consistent visual style - add: remove redundant ui.info(Downloading) spinner covers it - resolveRuleVersionCheck: expose registryRule in return type for preview --- packages/cli/src/commands/add.ts | 51 +++++++++++++++++----- packages/cli/src/commands/init.ts | 70 +++++++++++++++---------------- 2 files changed, 74 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 5ea860c..03f2fd6 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -20,6 +20,7 @@ import { confirmPrompt, introPrompt, outroPrompt, + notePrompt, spinnerTask, isInteractiveSession, } from '../utils/prompt.js'; @@ -370,6 +371,7 @@ function compareSemver(a: string, b: string): number { interface RuleVersionCheck { installedVersion?: string; registryVersion?: string; + registryRule?: RegistryRule; } export async function downloadAndInstallAsset( @@ -465,8 +467,6 @@ async function downloadAndInstall( const fileName = `pulled-${category}-${name}.yml`; const filePath = join(cwd, '.dwf', 'rules', fileName); - ui.info(`Downloading ${source}...`); - let markdown: string; try { markdown = await spinnerTask({ @@ -772,13 +772,13 @@ async function runInteractive(cwd: string, options: AddOptions): Promise { if (allSelected.length === 0) return; - ui.newline(); - ui.header('Rules to install:'); - for (const rule of allSelected) { - const desc = rule.description ? pc.dim(` ${ICONS.dash} ${rule.description}`) : ''; - console.log(` ${rule.category}/${rule.name}${desc}`); - } - ui.newline(); + const summaryLines = allSelected + .map((r) => { + const desc = r.description ? ` ${ICONS.dash} ${r.description}` : ''; + return `${r.category}/${r.name}${desc}`; + }) + .join('\n'); + notePrompt(summaryLines, 'Rules to install'); try { const shouldProceed = await confirmPrompt({ @@ -893,9 +893,12 @@ async function resolveRuleVersionCheck(cwd: string, source: string): Promise rule.path === source)?.version; + const found = registry.rules.find((rule) => rule.path === source); + registryVersion = found?.version; + registryRule = found ?? undefined; } catch { registryVersion = undefined; } @@ -907,6 +910,7 @@ async function resolveRuleVersionCheck(cwd: string, source: string): Promise { const rootDir = scope === 'global' ? homedir() : cwd; const dwfDir = join(rootDir, '.dwf'); + const dwfPath = scope === 'global' ? '~/.dwf/' : '.dwf/'; + const alreadyExists = await fileExists(dwfDir); - if (await fileExists(dwfDir)) { - const locationHint = scope === 'global' - ? '~/.dwf/ already exists.' - : '.dwf/ already exists in this directory.'; - ui.warn(locationHint); - - const overwrite = options.yes - ? false - : await confirmPrompt({ - message: 'Overwrite config? (rules will be preserved)', - defaultValue: false, - }); - - if (!overwrite) { - if (isInteractiveSession() && !options.yes) { - outroPrompt('Init cancelled.'); - } + if (isInteractiveSession() && !options.yes) { + const willCreate = ['config.yml', ...BUILTIN_SCOPES.map((s) => `rules/${s}.yml`)]; + const noteLines = [ + `Location: ${dwfPath}`, + `Tools: ${tools.join(', ')}`, + `Mode: ${mode}`, + `Will create: ${willCreate.join(', ')}`, + ...(alreadyExists ? ['⚠ Already exists — config will be overwritten, rules preserved'] : []), + ].join('\n'); + + notePrompt(noteLines, 'Summary'); + + const confirmed = await confirmPrompt({ + message: alreadyExists ? 'Overwrite and initialize?' : 'Initialize?', + defaultValue: true, + }); + if (!confirmed) { + outroPrompt('Init cancelled.'); return; } } - const projectName = scope === 'global' ? 'global' : basename(cwd); + // For -y + alreadyExists: show warn so the existing e2e test keeps passing + if (options.yes && alreadyExists) { + const locationHint = scope === 'global' ? '~/.dwf/ already exists.' : '.dwf/ already exists in this directory.'; + ui.warn(locationHint); + } const rulesDir = join(dwfDir, 'rules'); - await spinnerTask({ - label: 'Creating workspace folders', - task: async () => { - await mkdir(rulesDir, { recursive: true }); - await mkdir(join(dwfDir, 'assets'), { recursive: true }); - }, - }); + const projectName = scope === 'global' ? 'global' : basename(cwd); - // Write config.yml const config = { version: '0.2', project: { name: projectName }, @@ -204,19 +205,15 @@ export async function runInit(options: InitOptions): Promise { blocks: [] as string[], }; const configContent = `# Dev Workflows configuration\n${stringify(config)}`; - await spinnerTask({ - label: 'Writing config.yml', - task: async () => { - await writeFile(join(dwfDir, 'config.yml'), configContent, 'utf-8'); - }, - }); - // Write empty rule files await spinnerTask({ - label: 'Scaffolding rule files', + label: 'Setting up .dwf/ workspace…', task: async () => { - for (const scope of BUILTIN_SCOPES) { - await writeFile(join(rulesDir, `${scope}.yml`), buildRuleFileContent(scope), 'utf-8'); + await mkdir(rulesDir, { recursive: true }); + await mkdir(join(dwfDir, 'assets'), { recursive: true }); + await writeFile(join(dwfDir, 'config.yml'), configContent, 'utf-8'); + for (const s of BUILTIN_SCOPES) { + await writeFile(join(rulesDir, `${s}.yml`), buildRuleFileContent(s), 'utf-8'); } }, }); @@ -226,7 +223,6 @@ export async function runInit(options: InitOptions): Promise { } // Success summary - const dwfPath = scope === 'global' ? '~/.dwf/' : '.dwf/'; ui.newline(); ui.success(`Initialized ${dwfPath} — ${tools.join(', ')} (${mode} mode)`); outroPrompt('Run "devw add" to browse and install rules.'); From 3dd9bbd02cecfaa92e85154dc781b515e7aa7c28 Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Thu, 16 Apr 2026 00:22:14 +0200 Subject: [PATCH 3/3] feat(ux): smart menu, all commands, consistent preview cards, and back nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - menu: detect context at startup — show only Init+Exit when no .dwf/ found, full menu with local/global badge once configured - menu: add Watch, List (with sub-select), and Explain as menu options - menu: error guard keeps loop alive instead of exiting on command failure - watch: fix SIGINT handler to resolve Promise instead of process.exit(0) so Ctrl+C returns to the menu rather than killing the process - add: replace BACK_VALUE hack in multiselect with multiselectPromptOrBack — Esc now navigates back to categories cleanly without a checkbox back option - add: unify preview card format between single-rule and multi-select flows - prompt: export runWatch, runList, runExplain; add multiselectPromptOrBack util --- packages/cli/src/commands/add.ts | 59 ++++++------- packages/cli/src/commands/explain.ts | 2 +- packages/cli/src/commands/list.ts | 2 +- packages/cli/src/commands/menu.ts | 126 +++++++++++++++++++++------ packages/cli/src/commands/watch.ts | 111 +++++++++++------------ packages/cli/src/utils/prompt.ts | 19 ++++ 6 files changed, 203 insertions(+), 116 deletions(-) diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 03f2fd6..07f446c 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -17,6 +17,7 @@ import { resolveContext } from '../core/resolve-context.js'; import { selectPrompt, multiselectPrompt, + multiselectPromptOrBack, confirmPrompt, introPrompt, outroPrompt, @@ -719,32 +720,28 @@ async function runInteractive(cwd: string, options: AddOptions): Promise { const category = registry.categories.find((c) => c.name === selectedCategoryName); if (!category) break; - const selected = await multiselectPrompt({ - message: 'Select rules to add', - options: [ - { label: '\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 ? pc.dim(' (already installed)') : ''; - return { - label: `${r.name}${desc}${suffix}`, - value: r.name, - }; - }), - ], + const selected = await multiselectPromptOrBack({ + message: `Select rules to add ${pc.dim('(Esc ← back)')}`, + options: 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 ? pc.dim(' (already installed)') : ''; + return { + label: `${r.name}${desc}${suffix}`, + value: r.name, + }; + }), }); - const realRules = selected.filter((v) => v !== BACK_VALUE); + if (selected === null) continue; - if (realRules.length === 0) { - if (selected.includes(BACK_VALUE)) continue; + if (selected.length === 0) { ui.warn('No rules selected'); continue; } - for (const ruleName of realRules) { + for (const ruleName of selected) { const ruleInfo = category.rules.find((r) => r.name === ruleName); allSelected.push({ category: category.name, @@ -772,13 +769,15 @@ async function runInteractive(cwd: string, options: AddOptions): Promise { if (allSelected.length === 0) return; + const dest = '.dwf/rules/'; + const maxLen = Math.max(...allSelected.map((r) => `${r.category}/${r.name}`.length)); const summaryLines = allSelected .map((r) => { - const desc = r.description ? ` ${ICONS.dash} ${r.description}` : ''; - return `${r.category}/${r.name}${desc}`; + const rulePath = `${r.category}/${r.name}`; + return `${rulePath.padEnd(maxLen)} ${ICONS.arrow} ${dest}`; }) .join('\n'); - notePrompt(summaryLines, 'Rules to install'); + notePrompt(summaryLines, `Installing ${pluralRules(allSelected.length)}`); try { const shouldProceed = await confirmPrompt({ @@ -998,18 +997,10 @@ export async function runAdd(ruleArg: string | undefined, options: AddOptions): // Preview card in interactive mode (without --force or --dry-run) if (isInteractiveSession() && !options.force && !options.dryRun) { - const fileName = `pulled-${category}-${name}.yml`; - const ruleInfo = versionCheck?.registryRule; - - const noteLines = [ - ruleInfo?.description ?? source, - '', - `scope ${ruleInfo?.scope ?? category}`, - `version ${versionCheck?.registryVersion ?? 'unknown'}`, - `file .dwf/rules/${fileName}`, - ].join('\n'); - - notePrompt(noteLines, source); + const dest = '.dwf/rules/'; + const noteLines = `${source.padEnd(source.length)} ${ICONS.arrow} ${dest}`; + + notePrompt(noteLines, `Installing 1 rule`); try { const confirmed = await confirmPrompt({ message: 'Install?', defaultValue: true }); diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index e183657..bceff10 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -55,7 +55,7 @@ function formatSeparator(toolId: string): string { return pc.dim(`${prefix}${label}${suffix}`); } -async function runExplain(options: ExplainOptions): Promise { +export async function runExplain(options: ExplainOptions): Promise { const resolved = await resolveContext(process.cwd()); if (!resolved) { diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 0ebf8f0..78d0480 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -150,7 +150,7 @@ async function listAssets(typeFilter?: string): Promise { } } -async function runList(subcommand: string | undefined): Promise { +export async function runList(subcommand: string | undefined): Promise { if (!subcommand) { ui.error('Specify what to list', 'Usage: devw list '); process.exitCode = 1; diff --git a/packages/cli/src/commands/menu.ts b/packages/cli/src/commands/menu.ts index 29868e0..16ab2e3 100644 --- a/packages/cli/src/commands/menu.ts +++ b/packages/cli/src/commands/menu.ts @@ -1,21 +1,39 @@ import type { Command } from 'commander'; +import pc from 'picocolors'; import { runAdd } from './add.js'; import { runRemove } from './remove.js'; import { runDoctor } from './doctor.js'; import { runCompile } from './compile.js'; +import { runInit } from './init.js'; +import { runWatch } from './watch.js'; +import { runList } from './list.js'; +import { runExplain } from './explain.js'; import { renderBanner } from '../utils/banner.js'; -import { selectPrompt, introPrompt, outroPrompt, isInteractiveSession } from '../utils/prompt.js'; +import { selectPrompt, introPrompt, outroPrompt, notePrompt, isInteractiveSession } from '../utils/prompt.js'; +import { resolveContext } from '../core/resolve-context.js'; const MENU_CHOICES = { ADD: 'add', COMPILE: 'compile', - DOCTOR: 'doctor', + WATCH: 'watch', REMOVE: 'remove', + LIST: 'list', + EXPLAIN: 'explain', + DOCTOR: 'doctor', + INIT: 'init', EXIT: 'exit', } as const; type MenuChoice = (typeof MENU_CHOICES)[keyof typeof MENU_CHOICES]; +const LIST_CHOICES = { + RULES: 'rules', + TOOLS: 'tools', + ASSETS: 'assets', +} as const; + +type ListChoice = (typeof LIST_CHOICES)[keyof typeof LIST_CHOICES]; + export async function runMainMenu(command: Command): Promise { if (!isInteractiveSession()) { command.help(); @@ -26,39 +44,95 @@ export async function runMainMenu(command: Command): Promise { if (banner.length > 0) { console.log(banner); } - introPrompt('Welcome to dev-workflows'); + + let isFirstRun = true; while (true) { + const ctx = await resolveContext(process.cwd()); + + if (isFirstRun) { + if (ctx === null) { + console.log(`\n ${pc.dim('○ No configuration found — run Init to get started')}`); + } else { + const mode = ctx.globalMode ? 'global mode' : 'local mode'; + const dirLabel = ctx.globalMode ? '~/.dwf/' : '.dwf/'; + introPrompt(`dev-workflows · ${mode} · ${dirLabel}`); + } + isFirstRun = false; + } + let choice: MenuChoice; - choice = await selectPrompt({ - message: 'What do you want to do?', - options: [ - { label: 'Add rules or assets', value: MENU_CHOICES.ADD }, - { label: 'Compile for all editors', value: MENU_CHOICES.COMPILE }, - { label: 'Check project status', value: MENU_CHOICES.DOCTOR }, - { label: 'Remove something', value: MENU_CHOICES.REMOVE }, - { label: 'Exit', value: MENU_CHOICES.EXIT }, - ], - }); + + if (ctx === null) { + choice = await selectPrompt({ + message: 'What do you want to do?', + options: [ + { label: 'Init project', value: MENU_CHOICES.INIT }, + { label: 'Exit', value: MENU_CHOICES.EXIT }, + ], + }); + } else { + choice = await selectPrompt({ + message: 'What do you want to do?', + options: [ + { label: 'Add rules', value: MENU_CHOICES.ADD }, + { label: 'Compile', value: MENU_CHOICES.COMPILE }, + { label: 'Watch', value: MENU_CHOICES.WATCH }, + { label: 'Remove', value: MENU_CHOICES.REMOVE }, + { label: 'List', value: MENU_CHOICES.LIST }, + { label: 'Explain', value: MENU_CHOICES.EXPLAIN }, + { label: 'Check status', value: MENU_CHOICES.DOCTOR }, + { label: 'Exit', value: MENU_CHOICES.EXIT }, + ], + }); + } if (choice === MENU_CHOICES.EXIT) { outroPrompt('See you next time.'); process.exit(0); } - switch (choice) { - case MENU_CHOICES.ADD: - await runAdd(undefined, {}); - break; - case MENU_CHOICES.COMPILE: - await runCompile({ verbose: false, dryRun: false }); - break; - case MENU_CHOICES.DOCTOR: - await runDoctor(); - break; - case MENU_CHOICES.REMOVE: - await runRemove(undefined); - break; + try { + switch (choice) { + case MENU_CHOICES.INIT: + await runInit({}); + isFirstRun = true; + break; + case MENU_CHOICES.ADD: + await runAdd(undefined, {}); + break; + case MENU_CHOICES.COMPILE: + await runCompile({ verbose: false, dryRun: false }); + break; + case MENU_CHOICES.WATCH: + notePrompt('Press Ctrl+C to stop watching and return to menu', 'Watch mode'); + await runWatch({}); + break; + case MENU_CHOICES.REMOVE: + await runRemove(undefined); + break; + case MENU_CHOICES.LIST: { + const listChoice = await selectPrompt({ + message: 'List what?', + options: [ + { label: 'Rules', value: LIST_CHOICES.RULES }, + { label: 'Tools', value: LIST_CHOICES.TOOLS }, + { label: 'Assets', value: LIST_CHOICES.ASSETS }, + ], + }); + await runList(listChoice); + break; + } + case MENU_CHOICES.EXPLAIN: + await runExplain({}); + break; + case MENU_CHOICES.DOCTOR: + await runDoctor(); + break; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`\n ${pc.red('✗')} ${message}\n`); } } } diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts index 940d75e..88bdcd6 100644 --- a/packages/cli/src/commands/watch.ts +++ b/packages/cli/src/commands/watch.ts @@ -33,7 +33,7 @@ function printWaiting(withHint = false): void { } } -async function runWatch(options: WatchOptions): Promise { +export async function runWatch(options: WatchOptions): Promise { const resolved = await resolveContext(process.cwd()); if (!resolved) { @@ -59,67 +59,70 @@ async function runWatch(options: WatchOptions): Promise { watcher.on('ready', () => resolve()); }); - let debounceTimer: ReturnType | undefined; - let lastChangedPath = ''; - - const runCompileOnChange = async (): Promise => { - ui.newline(); - ui.header(`${ICONS.reload} Change detected: .dwf/${lastChangedPath}`); - ui.info('Compiling...'); - ui.newline(); + await new Promise((resolve) => { + let debounceTimer: ReturnType | undefined; + let lastChangedPath = ''; + + const runCompileOnChange = async (): Promise => { + ui.newline(); + ui.header(`${ICONS.reload} Change detected: .dwf/${lastChangedPath}`); + ui.info('Compiling...'); + ui.newline(); + + try { + const result = await executePipeline({ cwd, tool: options.tool }); + printCompileResult(result); + + const hasFailures = result.results.some((r) => !r.success); + if (hasFailures) { + ui.newline(); + ui.info('Watch mode continues running.'); + } + + printWaiting(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ui.error(message); + ui.info('Watch mode is still running. Fix the error and save again.'); + } + }; - try { - const result = await executePipeline({ cwd, tool: options.tool }); - printCompileResult(result); + watcher.on('all', (_event: string, filePath: string) => { + lastChangedPath = filePath; - const hasFailures = result.results.some((r) => !r.success); - if (hasFailures) { - ui.newline(); - ui.info('Watch mode continues running.'); + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer); } - printWaiting(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ui.error(message); - ui.info('Watch mode is still running. Fix the error and save again.'); - } - }; - - watcher.on('all', (_event: string, filePath: string) => { - lastChangedPath = filePath; + debounceTimer = setTimeout(() => { + void runCompileOnChange(); + }, DEBOUNCE_MS); + }); - if (debounceTimer !== undefined) { - clearTimeout(debounceTimer); - } + process.on('SIGINT', () => { + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer); + } + void watcher.close(); + resolve(); + }); - debounceTimer = setTimeout(() => { - void runCompileOnChange(); - }, DEBOUNCE_MS); - }); + ui.newline(); + ui.header(pc.green('Watching .dwf/ for changes...')); + ui.info('Running initial compile...'); + ui.newline(); - process.on('SIGINT', () => { - if (debounceTimer !== undefined) { - clearTimeout(debounceTimer); - } - void watcher.close(); - process.exit(0); + executePipeline({ cwd, tool: options.tool }) + .then((result) => { + printCompileResult(result); + printWaiting(true); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + ui.error(message); + ui.info('Watch mode is still running. Fix the error and save again.'); + }); }); - - ui.newline(); - ui.header(pc.green('Watching .dwf/ for changes...')); - ui.info('Running initial compile...'); - ui.newline(); - - try { - const result = await executePipeline({ cwd, tool: options.tool }); - printCompileResult(result); - printWaiting(true); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ui.error(message); - ui.info('Watch mode is still running. Fix the error and save again.'); - } } export function registerWatchCommand(program: Command): void { diff --git a/packages/cli/src/utils/prompt.ts b/packages/cli/src/utils/prompt.ts index 2e34b45..42e98e9 100644 --- a/packages/cli/src/utils/prompt.ts +++ b/packages/cli/src/utils/prompt.ts @@ -112,6 +112,25 @@ export async function multiselectPrompt( return handleCancel(value); } +export async function multiselectPromptOrBack( + options: MultiselectPromptOptions, +): Promise { + ensureInteractive(); + + const value = await p.multiselect({ + message: options.message, + required: options.required, + initialValues: options.initialValues ? [...options.initialValues] : undefined, + options: options.options.map((option) => toClackOption(option)), + }); + + if (p.isCancel(value)) { + return null; + } + + return value as T[]; +} + export async function confirmPrompt(options: ConfirmPromptOptions): Promise { ensureInteractive();