From 6953fe123957f63e026b77a2714573636f44f609 Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Sat, 7 Mar 2026 12:22:46 +0100 Subject: [PATCH 1/2] feat(ux): improve CLI usability across add, remove, compile, and list - add: parallelize fetchRegistry with Promise.all (faster --list) - add --list: show available assets (commands/templates/hooks/presets) - add interactive: offer rules vs assets at startup - add/remove: block format error now suggests category/name equivalent - remove interactive: shows rules and assets together with separators - compile --verbose: shows asset deployment separately from bridge files - list assets: show output path for each installed asset - assets: warn when template falls back to default output path - docs: update remove.mdx to document asset removal --- .gitignore | 1 - docs/commands/remove.mdx | 26 +++- packages/cli/src/commands/add.ts | 188 ++++++++++++++++++++++----- packages/cli/src/commands/compile.ts | 10 +- packages/cli/src/commands/list.ts | 16 ++- packages/cli/src/commands/remove.ts | 73 ++++++++--- packages/cli/src/core/assets.ts | 4 + packages/cli/tests/e2e/cli.test.ts | 6 +- 8 files changed, 265 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 2752764..e48c965 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,5 @@ coverage/ # Misc *.tgz .dwf/.cache/ -docs/internal openspec/ .vercel diff --git a/docs/commands/remove.mdx b/docs/commands/remove.mdx index e0b17c0..f3d3fc7 100644 --- a/docs/commands/remove.mdx +++ b/docs/commands/remove.mdx @@ -1,34 +1,48 @@ --- title: "devw remove" -description: "Remove an installed rule" +description: "Remove an installed rule or asset" --- ```bash -devw remove [category/rule] +devw remove [category/name] ``` -Removes a rule that was installed via `devw add` and recompiles. +Removes a rule or asset that was installed via `devw add` and recompiles. ## Interactive Mode Running `devw remove` without arguments shows installed rules and lets you select which to remove. +> Note: interactive mode only lists rules. To remove an asset, use the direct form. + ## Direct Mode ```bash +# Remove a rule devw remove typescript/strict + +# Remove an asset +devw remove command/spec +devw remove template/feature-spec +devw remove hook/auto-format ``` -Removes the rule file (`pulled-typescript-strict.yml`) and updates `config.yml`. +Removes the file from `.dwf/rules/` or `.dwf/assets/s/` and updates `config.yml`. Recompiles automatically. ## Examples ```bash +# Interactive — select rules to remove +devw remove + # Remove a specific rule devw remove typescript/strict -# Interactive — select rules to remove -devw remove +# Remove a slash command +devw remove command/spec + +# Remove an editor hook +devw remove hook/auto-format ``` Rules added manually (not via `devw add`) are not affected. diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 996d910..4c0931b 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -4,7 +4,7 @@ import type { Command } from 'commander'; import chalk from 'chalk'; import { stringify, parse } from 'yaml'; import { select, checkbox, confirm } from '@inquirer/prompts'; -import { fetchRawContent, fetchContent, listDirectory } from '../utils/github.js'; +import { fetchRawContent, fetchContent, listDirectory, listContentDirectory } from '../utils/github.js'; import { convert } from '../core/converter.js'; import { isAssetType, parseAssetFrontmatter } from '../core/assets.js'; import { fileExists } from '../utils/fs.js'; @@ -65,38 +65,39 @@ export async function fetchRegistry(cwd: string): Promise return null; } - const categories: CachedRegistry['categories'] = []; + const dirs = topLevel.filter((e) => e.type === 'dir'); - 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: '' }); - } - } + const categoryResults = await Promise.all( + dirs.map(async (entry) => { + try { + const files = await listDirectory(entry.name); + const ruleFiles = files.filter((f) => f.type === 'file'); + + const rules = await Promise.all( + ruleFiles.map(async (file) => { + 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'] : ''; + return { name: file.name, description }; + } + return { name: file.name, description: '' }; + } catch { + return { name: file.name, description: '' }; + } + }), + ); - if (rules.length > 0) { - categories.push({ name: entry.name, rules }); + return rules.length > 0 ? { name: entry.name, rules } : null; + } catch { + return null; } - } catch { - // Skip categories that fail to list - } - } + }), + ); + const categories = categoryResults.filter((c): c is NonNullable => c !== null); const registry: CachedRegistry = { categories }; await cache.set(cwd, 'registry', registry); return registry; @@ -137,6 +138,38 @@ async function runList(categoryFilter: string | undefined): Promise { } console.log(` ${chalk.dim(`Add a rule: devw add /`)}`); + + // Show available assets if not filtering by category + if (!categoryFilter) { + const assetTypes = ['commands', 'templates', 'hooks', 'presets'] as const; + const assetResults = await Promise.allSettled( + assetTypes.map((dir) => listContentDirectory(dir)), + ); + + const hasAnyAssets = assetResults.some( + (r) => r.status === 'fulfilled' && r.value.some((e) => e.type === 'file'), + ); + + if (hasAnyAssets) { + ui.newline(); + ui.header('Available assets'); + ui.newline(); + for (let i = 0; i < assetTypes.length; i++) { + const type = assetTypes[i]!; + const result = assetResults[i]!; + if (result.status !== 'fulfilled') continue; + const names = result.value.filter((e) => e.type === 'file').map((e) => e.name); + if (names.length === 0) continue; + const singular = type.replace(/s$/, ''); + console.log(` ${chalk.cyan(`${singular}/`)}`); + for (const name of names) { + console.log(` ${chalk.white(name)}`); + } + ui.newline(); + } + console.log(` ${chalk.dim(`Add an asset: devw add command/`)}`); + } + } } export function generateYamlOutput( @@ -388,7 +421,94 @@ async function downloadAndInstall( return true; } +async function runInteractiveAsset(cwd: string, options: AddOptions): Promise { + let assetType: AssetType | 'preset'; + try { + assetType = await select({ + message: 'Asset type', + choices: [ + { name: 'command — Slash commands for Claude Code', value: 'command' }, + { name: 'template — Spec and document templates', value: 'template' }, + { name: 'hook — Editor hooks (auto-format, etc.)', value: 'hook' }, + { name: 'preset — Bundle of rules + assets', value: 'preset' }, + ], + }); + } catch { + ui.error('Cancelled'); + return; + } + + ui.info(`Fetching available ${assetType}s from GitHub...`); + + let names: string[]; + try { + const entries = await listContentDirectory(`${assetType}s`); + names = entries.filter((e) => e.type === 'file').map((e) => e.name); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ui.error(`Could not fetch ${assetType} list: ${msg}`); + process.exitCode = 1; + return; + } + + if (names.length === 0) { + ui.warn(`No ${assetType}s available in registry`); + return; + } + + let selected: string[]; + try { + selected = await checkbox({ + message: `Select ${assetType}s to install`, + choices: names.map((name) => ({ name, value: name })), + }); + } catch { + ui.error('Cancelled'); + return; + } + + if (selected.length === 0) { + ui.warn('No assets selected'); + return; + } + + let anyAdded = false; + for (const name of selected) { + if (assetType === 'preset') { + const added = await installPreset(cwd, name, options); + if (added) anyAdded = true; + } else { + const added = await downloadAndInstallAsset(cwd, assetType, name, options); + if (added) anyAdded = true; + } + } + + if (anyAdded && !options.noCompile) { + const { runCompileFromAdd } = await import('./compile.js'); + await runCompileFromAdd(); + } +} + async function runInteractive(cwd: string, options: AddOptions): Promise { + let mode: 'rules' | 'assets'; + try { + mode = await select<'rules' | 'assets'>({ + message: 'What do you want to add?', + choices: [ + { name: 'Rules — Install rules from the registry', value: 'rules' }, + { name: 'Assets — Commands, templates, hooks, presets', value: 'assets' }, + ], + }); + } catch { + ui.error('Cancelled'); + return; + } + + if (mode === 'assets') { + await runInteractiveAsset(cwd, options); + return; + } + const registry = await fetchRegistry(cwd); if (!registry) { process.exitCode = 1; @@ -623,7 +743,15 @@ async function runAdd(ruleArg: string | undefined, options: AddOptions): Promise } if (!ruleArg.includes('/')) { - ui.error('Block format is no longer supported', 'Use: devw add /. Run devw add --list to browse.'); + const dashIdx = ruleArg.indexOf('-'); + const hint = + dashIdx > 0 + ? `devw add ${ruleArg.slice(0, dashIdx)}/${ruleArg.slice(dashIdx + 1)}` + : `devw add /`; + ui.error( + `Block format "${ruleArg}" is no longer supported`, + `Use category/name format — e.g., ${hint}. Run devw add --list to browse.`, + ); process.exitCode = 1; return; } diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 7635ac1..92c0eb8 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -193,7 +193,15 @@ async function runCompile(options: CompileOptions): Promise { ui.newline(); ui.success(`Compiled ${String(result.activeRuleCount)} rules ${ICONS.arrow} ${String(allPaths.length)} file${allPaths.length !== 1 ? 's' : ''} ${ui.timing(result.elapsedMs)}`); ui.newline(); - ui.list(allPaths); + + if (options.verbose && result.assetPaths.length > 0) { + ui.list(writtenPaths); + ui.newline(); + console.log(` ${chalk.dim('Assets deployed:')}`); + ui.list(result.assetPaths); + } else { + ui.list(allPaths); + } } catch (err) { const message = err instanceof Error ? err.message : String(err); ui.error(message); diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 85a649a..c424635 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -89,6 +89,19 @@ async function listTools(): Promise { } } +function getAssetOutputHint(type: string, name: string): string { + switch (type) { + case ASSET_TYPE.Command: + return `.claude/commands/${name}.md`; + case ASSET_TYPE.Template: + return `docs/specs/${name}.md`; + case ASSET_TYPE.Hook: + return `.claude/settings.local.json`; + default: + return ''; + } +} + async function listAssets(typeFilter?: string): Promise { const cwd = process.cwd(); if (!(await ensureConfig(cwd))) return; @@ -110,7 +123,8 @@ async function listAssets(typeFilter?: string): Promise { ui.header(`Installed ${label} (${String(filtered.length)})`); ui.newline(); for (const asset of filtered) { - console.log(` ${chalk.dim(ICONS.bullet)} ${chalk.cyan(asset.type.padEnd(10))} ${chalk.white(asset.name.padEnd(20))} ${chalk.dim(`v${asset.version}`)}`); + const outputHint = getAssetOutputHint(asset.type, asset.name); + console.log(` ${chalk.dim(ICONS.bullet)} ${chalk.cyan(asset.type.padEnd(10))} ${chalk.white(asset.name.padEnd(20))} ${chalk.dim(`v${asset.version}`)} ${chalk.dim(ICONS.arrow)} ${chalk.dim(outputHint)}`); } } diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index 957d7eb..a0a891d 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -2,7 +2,7 @@ import { join } from 'node:path'; import { readFile, writeFile, unlink } from 'node:fs/promises'; import type { Command } from 'commander'; import { parse, stringify } from 'yaml'; -import { checkbox, confirm } from '@inquirer/prompts'; +import { checkbox, confirm, Separator } from '@inquirer/prompts'; import { readConfig } from '../core/parser.js'; import { fileExists } from '../utils/fs.js'; import { isAssetType, removeAsset } from '../core/assets.js'; @@ -63,32 +63,57 @@ async function runRemove(ruleArg: string | undefined): Promise { const config = await readConfig(cwd); if (!ruleArg) { - if (config.pulled.length === 0) { - ui.warn('No rules installed'); + const hasRules = config.pulled.length > 0; + const hasAssets = config.assets.length > 0; + + if (!hasRules && !hasAssets) { + ui.warn('Nothing installed to remove'); return; } - let selectedRules: string[]; + type RemoveChoice = { kind: 'rule'; path: string } | { kind: 'asset'; type: string; name: string }; + + const choices: (RemoveChoice | Separator)[] = []; + + if (hasRules) { + choices.push(new Separator('── Rules ──')); + for (const p of config.pulled) { + choices.push({ kind: 'rule', path: p.path } as RemoveChoice); + } + } + + if (hasAssets) { + choices.push(new Separator('── Assets ──')); + for (const a of config.assets) { + choices.push({ kind: 'asset', type: a.type, name: a.name } as RemoveChoice); + } + } + + let selected: RemoveChoice[]; try { - selectedRules = await checkbox({ - message: 'Which rules to remove?', - choices: config.pulled.map((p) => ({ - name: `${p.path} (v${p.version})`, - value: p.path, - })), + selected = await checkbox({ + message: 'Select items to remove', + choices: choices.map((c) => { + if (c instanceof Separator) return c; + if (c.kind === 'rule') { + const entry = config.pulled.find((p) => p.path === c.path); + return { name: `${c.path} (v${entry?.version ?? '?'})`, value: c }; + } + return { name: `${c.type}/${c.name}`, value: c }; + }), }); } catch { return; } - if (selectedRules.length === 0) { - ui.warn('No rules selected'); + if (selected.length === 0) { + ui.warn('Nothing selected'); return; } try { const shouldProceed = await confirm({ - message: `Remove ${String(selectedRules.length)} rule(s)?`, + message: `Remove ${String(selected.length)} item(s)?`, default: true, }); if (!shouldProceed) { @@ -99,9 +124,15 @@ async function runRemove(ruleArg: string | undefined): Promise { return; } - for (const path of selectedRules) { - await removeRule(cwd, path); - ui.success(`Removed ${path}`); + for (const item of selected) { + if (item.kind === 'rule') { + await removeRule(cwd, item.path); + ui.success(`Removed ${item.path}`); + } else { + await removeAsset(cwd, item.type as Parameters[1], item.name); + await removeAssetEntry(cwd, item.type, item.name); + ui.success(`Removed ${item.type}/${item.name}`); + } } const { runCompileFromAdd } = await import('./compile.js'); @@ -110,7 +141,15 @@ async function runRemove(ruleArg: string | undefined): Promise { } if (!ruleArg.includes('/')) { - ui.error('Block format is no longer supported', 'Use: devw remove /'); + const dashIdx = ruleArg.indexOf('-'); + const hint = + dashIdx > 0 + ? `devw remove ${ruleArg.slice(0, dashIdx)}/${ruleArg.slice(dashIdx + 1)}` + : `devw remove /`; + ui.error( + `Block format "${ruleArg}" is no longer supported`, + `Use category/name format — e.g., ${hint}`, + ); process.exitCode = 1; return; } diff --git a/packages/cli/src/core/assets.ts b/packages/cli/src/core/assets.ts index eb4858c..844310e 100644 --- a/packages/cli/src/core/assets.ts +++ b/packages/cli/src/core/assets.ts @@ -5,6 +5,7 @@ import type { AssetType, ProjectConfig } from '../bridges/types.js'; import { ASSET_TYPE } from '../bridges/types.js'; import { fileExists } from '../utils/fs.js'; import { mergeSettingsFile, type JsonValue } from './settings-merge.js'; +import * as ui from '../utils/ui.js'; const ASSET_TYPE_VALUES = new Set(Object.values(ASSET_TYPE)); @@ -139,6 +140,9 @@ export async function deployTemplates(cwd: string, _config: ProjectConfig): Prom const content = await readFile(join(templatesDir, file), 'utf-8'); const { frontmatter, body } = parseAssetFrontmatter(content); const outputPath = frontmatter.output_path ?? 'docs/specs'; + if (!frontmatter.output_path) { + ui.warn(`Template "${file}" has no output_path — deploying to default: ${outputPath}/`); + } const outputDir = join(cwd, outputPath); await mkdir(outputDir, { recursive: true }); await writeFile(join(outputDir, file), body.trimStart(), 'utf-8'); diff --git a/packages/cli/tests/e2e/cli.test.ts b/packages/cli/tests/e2e/cli.test.ts index a1ca14e..e87a5ca 100644 --- a/packages/cli/tests/e2e/cli.test.ts +++ b/packages/cli/tests/e2e/cli.test.ts @@ -196,7 +196,7 @@ rules: const result = await run(['add', 'typescript-strict'], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('Block format is no longer supported')); + assert.ok(result.stderr.includes('is no longer supported')); }); it('add with invalid format exits with error', async () => { @@ -213,7 +213,7 @@ rules: const result = await run(['remove'], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('No rules installed')); + assert.ok(result.stdout.includes('Nothing installed to remove')); }); it('remove with old block format exits with error', async () => { @@ -221,7 +221,7 @@ rules: const result = await run(['remove', 'typescript-strict'], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('Block format is no longer supported')); + assert.ok(result.stderr.includes('is no longer supported')); }); it('remove non-installed rule exits with error', async () => { From e4ee7cfdde5637955830a15bfbde07c7ff9bfa4e Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Sat, 7 Mar 2026 12:28:24 +0100 Subject: [PATCH 2/2] test: add coverage for UX improvements (add, remove, assets, e2e) - add.test.ts: tests for updateConfigAssets (preserve, replace, multi-type) - remove.test.ts: tests for mixed pulled+assets config state - assets.test.ts: tests for deployTemplates fallback and custom output_path - e2e/cli.test.ts: tests for block format hint in add/remove error messages --- packages/cli/tests/commands/add.test.ts | 84 +++++++++++++++++++++- packages/cli/tests/commands/remove.test.ts | 43 ++++++++++- packages/cli/tests/core/assets.test.ts | 33 +++++++++ packages/cli/tests/e2e/cli.test.ts | 17 +++++ 4 files changed, 174 insertions(+), 3 deletions(-) diff --git a/packages/cli/tests/commands/add.test.ts b/packages/cli/tests/commands/add.test.ts index 2b8d869..80759db 100644 --- a/packages/cli/tests/commands/add.test.ts +++ b/packages/cli/tests/commands/add.test.ts @@ -4,9 +4,9 @@ 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, pluralRules, BACK_VALUE } from '../../src/commands/add.js'; +import { validateInput, generateYamlOutput, updateConfig, updateConfigAssets, pluralRules, BACK_VALUE } from '../../src/commands/add.js'; import { convert } from '../../src/core/converter.js'; -import type { PulledEntry } from '../../src/bridges/types.js'; +import type { PulledEntry, AssetEntry } from '../../src/bridges/types.js'; const MOCK_MARKDOWN = `--- name: strict @@ -222,6 +222,86 @@ tags: [] }); }); + describe('updateConfigAssets', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'dwf-add-assets-test-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('adds asset entry to config', async () => { + await createProject(tempDir); + + const entry: AssetEntry = { + type: 'command', + name: 'spec', + version: '0.1.0', + installed_at: '2026-02-11T00:00:00Z', + }; + + await updateConfigAssets(tempDir, entry); + + const raw = await readFile(join(tempDir, '.dwf', 'config.yml'), 'utf-8'); + const doc = parse(raw) as Record; + const assets = doc['assets'] as AssetEntry[]; + assert.ok(Array.isArray(assets)); + assert.equal(assets.length, 1); + assert.equal(assets[0]?.type, 'command'); + assert.equal(assets[0]?.name, 'spec'); + }); + + it('replaces asset with same type+name (no duplication)', async () => { + await createProject(tempDir, `assets: + - type: command + name: spec + version: "0.1.0" + installed_at: "2026-01-01T00:00:00Z"`); + + const updated: AssetEntry = { + type: 'command', + name: 'spec', + version: '0.2.0', + installed_at: '2026-02-11T00:00:00Z', + }; + + await updateConfigAssets(tempDir, updated); + + const raw = await readFile(join(tempDir, '.dwf', 'config.yml'), 'utf-8'); + const doc = parse(raw) as Record; + const assets = doc['assets'] as AssetEntry[]; + assert.equal(assets.length, 1); + assert.equal(assets[0]?.version, '0.2.0'); + }); + + it('preserves existing assets when adding a different one', async () => { + await createProject(tempDir, `assets: + - type: command + name: spec + version: "0.1.0" + installed_at: "2026-01-01T00:00:00Z"`); + + const newEntry: AssetEntry = { + type: 'hook', + name: 'auto-format', + version: '0.1.0', + installed_at: '2026-02-11T00:00:00Z', + }; + + await updateConfigAssets(tempDir, newEntry); + + const raw = await readFile(join(tempDir, '.dwf', 'config.yml'), 'utf-8'); + const doc = parse(raw) as Record; + const assets = doc['assets'] as AssetEntry[]; + assert.equal(assets.length, 2); + assert.ok(assets.some((a) => a.type === 'command' && a.name === 'spec')); + assert.ok(assets.some((a) => a.type === 'hook' && a.name === 'auto-format')); + }); + }); + describe('pluralRules', () => { it('returns singular for 1', () => { assert.equal(pluralRules(1), '1 rule'); diff --git a/packages/cli/tests/commands/remove.test.ts b/packages/cli/tests/commands/remove.test.ts index 67f15a0..e07d3f0 100644 --- a/packages/cli/tests/commands/remove.test.ts +++ b/packages/cli/tests/commands/remove.test.ts @@ -6,11 +6,12 @@ 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'; +import type { PulledEntry, AssetEntry } from '../../src/bridges/types.js'; async function createProjectWithPulled( dir: string, pulled: PulledEntry[] = [], + assets: AssetEntry[] = [], ): Promise { await mkdir(join(dir, '.dwf', 'rules'), { recursive: true }); @@ -26,6 +27,10 @@ async function createProjectWithPulled( config['pulled'] = pulled; } + if (assets.length > 0) { + config['assets'] = assets; + } + await writeFile( join(dir, '.dwf', 'config.yml'), stringify(config, { lineWidth: 0 }), @@ -125,6 +130,42 @@ describe('remove command', () => { assert.deepEqual(config.pulled, []); }); + it('removing asset entry from config leaves other assets intact', async () => { + const assets: AssetEntry[] = [ + { type: 'command', name: 'spec', version: '0.1.0', installed_at: '2026-01-01T00:00:00Z' }, + { type: 'hook', name: 'auto-format', version: '0.1.0', installed_at: '2026-01-01T00:00:00Z' }, + ]; + await createProjectWithPulled(tempDir, [], assets); + + const configPath = join(tempDir, '.dwf', 'config.yml'); + const raw = await readFile(configPath, 'utf-8'); + const doc = parse(raw) as Record; + const existing = Array.isArray(doc['assets']) ? (doc['assets'] as AssetEntry[]) : []; + doc['assets'] = existing.filter((a) => !(a.type === 'command' && a.name === 'spec')); + await writeFile(configPath, stringify(doc, { lineWidth: 0 }), 'utf-8'); + + const config = await readConfig(tempDir); + assert.equal(config.assets.length, 1); + assert.equal(config.assets[0]?.type, 'hook'); + assert.equal(config.assets[0]?.name, 'auto-format'); + }); + + it('project with both pulled rules and assets loads correctly', async () => { + const pulled: PulledEntry[] = [ + { path: 'typescript/strict', version: '0.1.0', pulled_at: '2026-01-01T00:00:00Z' }, + ]; + const assets: AssetEntry[] = [ + { type: 'command', name: 'spec', version: '0.1.0', installed_at: '2026-01-01T00:00:00Z' }, + ]; + await createProjectWithPulled(tempDir, pulled, assets); + + const config = await readConfig(tempDir); + assert.equal(config.pulled.length, 1); + assert.equal(config.assets.length, 1); + assert.equal(config.pulled[0]?.path, 'typescript/strict'); + assert.equal(config.assets[0]?.name, 'spec'); + }); + it('validates rule path format', async () => { const { validateInput } = await import('../../src/commands/add.js'); diff --git a/packages/cli/tests/core/assets.test.ts b/packages/cli/tests/core/assets.test.ts index 4a15ce7..d2c02e5 100644 --- a/packages/cli/tests/core/assets.test.ts +++ b/packages/cli/tests/core/assets.test.ts @@ -172,6 +172,39 @@ describe('deployTemplates', () => { assert.ok(output.includes('# Feature Spec')); assert.ok(!output.includes('---')); }); + + it('deploys to docs/specs when output_path is missing (fallback)', async () => { + const templatesDir = join(tmpDir, '.dwf', 'assets', 'templates'); + await mkdir(templatesDir, { recursive: true }); + + await writeFile( + join(templatesDir, 'my-template.md'), + '---\nname: my-template\ndescription: No output path\n---\n# My Template', + ); + + const result = await deployTemplates(tmpDir, CONFIG); + assert.equal(result.deployed.length, 1); + assert.equal(result.deployed[0], 'docs/specs/my-template.md'); + + const output = await readFile(join(tmpDir, 'docs', 'specs', 'my-template.md'), 'utf-8'); + assert.ok(output.includes('# My Template')); + }); + + it('deploys template to custom output_path from frontmatter', async () => { + const templatesDir = join(tmpDir, '.dwf', 'assets', 'templates'); + await mkdir(templatesDir, { recursive: true }); + + await writeFile( + join(templatesDir, 'arch.md'), + '---\nname: arch\ndescription: Architecture doc\noutput_path: docs/architecture\n---\n# Architecture', + ); + + const result = await deployTemplates(tmpDir, CONFIG); + assert.equal(result.deployed[0], 'docs/architecture/arch.md'); + + const output = await readFile(join(tmpDir, 'docs', 'architecture', 'arch.md'), 'utf-8'); + assert.ok(output.includes('# Architecture')); + }); }); describe('deployHooks', () => { diff --git a/packages/cli/tests/e2e/cli.test.ts b/packages/cli/tests/e2e/cli.test.ts index e87a5ca..bbaba68 100644 --- a/packages/cli/tests/e2e/cli.test.ts +++ b/packages/cli/tests/e2e/cli.test.ts @@ -199,6 +199,15 @@ rules: 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); + + assert.equal(result.exitCode, 1); + // Should suggest typescript/strict based on first dash split + 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); @@ -224,6 +233,14 @@ rules: 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); + + assert.equal(result.exitCode, 1); + 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);