Skip to content

Commit 748a8fc

Browse files
authored
feat(ux): improve CLI usability across add, remove, compile, and list (#54)
* 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 * 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
1 parent 3ae75a7 commit 748a8fc

11 files changed

Lines changed: 439 additions & 62 deletions

File tree

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,5 @@ coverage/
4343
# Misc
4444
*.tgz
4545
.dwf/.cache/
46-
docs/internal
4746
openspec/
4847
.vercel

docs/commands/remove.mdx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,48 @@
11
---
22
title: "devw remove"
3-
description: "Remove an installed rule"
3+
description: "Remove an installed rule or asset"
44
---
55

66
```bash
7-
devw remove [category/rule]
7+
devw remove [category/name]
88
```
99

10-
Removes a rule that was installed via `devw add` and recompiles.
10+
Removes a rule or asset that was installed via `devw add` and recompiles.
1111

1212
## Interactive Mode
1313

1414
Running `devw remove` without arguments shows installed rules and lets you select which to remove.
1515

16+
> Note: interactive mode only lists rules. To remove an asset, use the direct form.
17+
1618
## Direct Mode
1719

1820
```bash
21+
# Remove a rule
1922
devw remove typescript/strict
23+
24+
# Remove an asset
25+
devw remove command/spec
26+
devw remove template/feature-spec
27+
devw remove hook/auto-format
2028
```
2129

22-
Removes the rule file (`pulled-typescript-strict.yml`) and updates `config.yml`.
30+
Removes the file from `.dwf/rules/` or `.dwf/assets/<type>s/` and updates `config.yml`. Recompiles automatically.
2331

2432
## Examples
2533

2634
```bash
35+
# Interactive — select rules to remove
36+
devw remove
37+
2738
# Remove a specific rule
2839
devw remove typescript/strict
2940

30-
# Interactive — select rules to remove
31-
devw remove
41+
# Remove a slash command
42+
devw remove command/spec
43+
44+
# Remove an editor hook
45+
devw remove hook/auto-format
3246
```
3347

3448
Rules added manually (not via `devw add`) are not affected.

packages/cli/src/commands/add.ts

Lines changed: 158 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Command } from 'commander';
44
import chalk from 'chalk';
55
import { stringify, parse } from 'yaml';
66
import { select, checkbox, confirm } from '@inquirer/prompts';
7-
import { fetchRawContent, fetchContent, listDirectory } from '../utils/github.js';
7+
import { fetchRawContent, fetchContent, listDirectory, listContentDirectory } from '../utils/github.js';
88
import { convert } from '../core/converter.js';
99
import { isAssetType, parseAssetFrontmatter } from '../core/assets.js';
1010
import { fileExists } from '../utils/fs.js';
@@ -65,38 +65,39 @@ export async function fetchRegistry(cwd: string): Promise<CachedRegistry | null>
6565
return null;
6666
}
6767

68-
const categories: CachedRegistry['categories'] = [];
68+
const dirs = topLevel.filter((e) => e.type === 'dir');
6969

70-
for (const entry of topLevel) {
71-
if (entry.type !== 'dir') continue;
72-
73-
try {
74-
const files = await listDirectory(entry.name);
75-
const rules: Array<{ name: string; description: string }> = [];
76-
77-
for (const file of files) {
78-
if (file.type !== 'file') continue;
79-
try {
80-
const content = await fetchRawContent(`${entry.name}/${file.name}`);
81-
const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content);
82-
if (fmMatch?.[1]) {
83-
const fm = parse(fmMatch[1]) as Record<string, unknown>;
84-
const description = typeof fm['description'] === 'string' ? fm['description'] : '';
85-
rules.push({ name: file.name, description });
86-
}
87-
} catch {
88-
rules.push({ name: file.name, description: '' });
89-
}
90-
}
70+
const categoryResults = await Promise.all(
71+
dirs.map(async (entry) => {
72+
try {
73+
const files = await listDirectory(entry.name);
74+
const ruleFiles = files.filter((f) => f.type === 'file');
75+
76+
const rules = await Promise.all(
77+
ruleFiles.map(async (file) => {
78+
try {
79+
const content = await fetchRawContent(`${entry.name}/${file.name}`);
80+
const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content);
81+
if (fmMatch?.[1]) {
82+
const fm = parse(fmMatch[1]) as Record<string, unknown>;
83+
const description = typeof fm['description'] === 'string' ? fm['description'] : '';
84+
return { name: file.name, description };
85+
}
86+
return { name: file.name, description: '' };
87+
} catch {
88+
return { name: file.name, description: '' };
89+
}
90+
}),
91+
);
9192

92-
if (rules.length > 0) {
93-
categories.push({ name: entry.name, rules });
93+
return rules.length > 0 ? { name: entry.name, rules } : null;
94+
} catch {
95+
return null;
9496
}
95-
} catch {
96-
// Skip categories that fail to list
97-
}
98-
}
97+
}),
98+
);
9999

100+
const categories = categoryResults.filter((c): c is NonNullable<typeof c> => c !== null);
100101
const registry: CachedRegistry = { categories };
101102
await cache.set(cwd, 'registry', registry);
102103
return registry;
@@ -137,6 +138,38 @@ async function runList(categoryFilter: string | undefined): Promise<void> {
137138
}
138139

139140
console.log(` ${chalk.dim(`Add a rule: devw add <category>/<rule>`)}`);
141+
142+
// Show available assets if not filtering by category
143+
if (!categoryFilter) {
144+
const assetTypes = ['commands', 'templates', 'hooks', 'presets'] as const;
145+
const assetResults = await Promise.allSettled(
146+
assetTypes.map((dir) => listContentDirectory(dir)),
147+
);
148+
149+
const hasAnyAssets = assetResults.some(
150+
(r) => r.status === 'fulfilled' && r.value.some((e) => e.type === 'file'),
151+
);
152+
153+
if (hasAnyAssets) {
154+
ui.newline();
155+
ui.header('Available assets');
156+
ui.newline();
157+
for (let i = 0; i < assetTypes.length; i++) {
158+
const type = assetTypes[i]!;
159+
const result = assetResults[i]!;
160+
if (result.status !== 'fulfilled') continue;
161+
const names = result.value.filter((e) => e.type === 'file').map((e) => e.name);
162+
if (names.length === 0) continue;
163+
const singular = type.replace(/s$/, '');
164+
console.log(` ${chalk.cyan(`${singular}/`)}`);
165+
for (const name of names) {
166+
console.log(` ${chalk.white(name)}`);
167+
}
168+
ui.newline();
169+
}
170+
console.log(` ${chalk.dim(`Add an asset: devw add command/<name>`)}`);
171+
}
172+
}
140173
}
141174

142175
export function generateYamlOutput(
@@ -388,7 +421,94 @@ async function downloadAndInstall(
388421
return true;
389422
}
390423

424+
async function runInteractiveAsset(cwd: string, options: AddOptions): Promise<void> {
425+
let assetType: AssetType | 'preset';
426+
try {
427+
assetType = await select<AssetType | 'preset'>({
428+
message: 'Asset type',
429+
choices: [
430+
{ name: 'command — Slash commands for Claude Code', value: 'command' },
431+
{ name: 'template — Spec and document templates', value: 'template' },
432+
{ name: 'hook — Editor hooks (auto-format, etc.)', value: 'hook' },
433+
{ name: 'preset — Bundle of rules + assets', value: 'preset' },
434+
],
435+
});
436+
} catch {
437+
ui.error('Cancelled');
438+
return;
439+
}
440+
441+
ui.info(`Fetching available ${assetType}s from GitHub...`);
442+
443+
let names: string[];
444+
try {
445+
const entries = await listContentDirectory(`${assetType}s`);
446+
names = entries.filter((e) => e.type === 'file').map((e) => e.name);
447+
} catch (err) {
448+
const msg = err instanceof Error ? err.message : String(err);
449+
ui.error(`Could not fetch ${assetType} list: ${msg}`);
450+
process.exitCode = 1;
451+
return;
452+
}
453+
454+
if (names.length === 0) {
455+
ui.warn(`No ${assetType}s available in registry`);
456+
return;
457+
}
458+
459+
let selected: string[];
460+
try {
461+
selected = await checkbox<string>({
462+
message: `Select ${assetType}s to install`,
463+
choices: names.map((name) => ({ name, value: name })),
464+
});
465+
} catch {
466+
ui.error('Cancelled');
467+
return;
468+
}
469+
470+
if (selected.length === 0) {
471+
ui.warn('No assets selected');
472+
return;
473+
}
474+
475+
let anyAdded = false;
476+
for (const name of selected) {
477+
if (assetType === 'preset') {
478+
const added = await installPreset(cwd, name, options);
479+
if (added) anyAdded = true;
480+
} else {
481+
const added = await downloadAndInstallAsset(cwd, assetType, name, options);
482+
if (added) anyAdded = true;
483+
}
484+
}
485+
486+
if (anyAdded && !options.noCompile) {
487+
const { runCompileFromAdd } = await import('./compile.js');
488+
await runCompileFromAdd();
489+
}
490+
}
491+
391492
async function runInteractive(cwd: string, options: AddOptions): Promise<void> {
493+
let mode: 'rules' | 'assets';
494+
try {
495+
mode = await select<'rules' | 'assets'>({
496+
message: 'What do you want to add?',
497+
choices: [
498+
{ name: 'Rules — Install rules from the registry', value: 'rules' },
499+
{ name: 'Assets — Commands, templates, hooks, presets', value: 'assets' },
500+
],
501+
});
502+
} catch {
503+
ui.error('Cancelled');
504+
return;
505+
}
506+
507+
if (mode === 'assets') {
508+
await runInteractiveAsset(cwd, options);
509+
return;
510+
}
511+
392512
const registry = await fetchRegistry(cwd);
393513
if (!registry) {
394514
process.exitCode = 1;
@@ -623,7 +743,15 @@ async function runAdd(ruleArg: string | undefined, options: AddOptions): Promise
623743
}
624744

625745
if (!ruleArg.includes('/')) {
626-
ui.error('Block format is no longer supported', 'Use: devw add <category>/<rule>. Run devw add --list to browse.');
746+
const dashIdx = ruleArg.indexOf('-');
747+
const hint =
748+
dashIdx > 0
749+
? `devw add ${ruleArg.slice(0, dashIdx)}/${ruleArg.slice(dashIdx + 1)}`
750+
: `devw add <category>/<rule>`;
751+
ui.error(
752+
`Block format "${ruleArg}" is no longer supported`,
753+
`Use category/name format — e.g., ${hint}. Run devw add --list to browse.`,
754+
);
627755
process.exitCode = 1;
628756
return;
629757
}

packages/cli/src/commands/compile.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,15 @@ async function runCompile(options: CompileOptions): Promise<void> {
193193
ui.newline();
194194
ui.success(`Compiled ${String(result.activeRuleCount)} rules ${ICONS.arrow} ${String(allPaths.length)} file${allPaths.length !== 1 ? 's' : ''} ${ui.timing(result.elapsedMs)}`);
195195
ui.newline();
196-
ui.list(allPaths);
196+
197+
if (options.verbose && result.assetPaths.length > 0) {
198+
ui.list(writtenPaths);
199+
ui.newline();
200+
console.log(` ${chalk.dim('Assets deployed:')}`);
201+
ui.list(result.assetPaths);
202+
} else {
203+
ui.list(allPaths);
204+
}
197205
} catch (err) {
198206
const message = err instanceof Error ? err.message : String(err);
199207
ui.error(message);

packages/cli/src/commands/list.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ async function listTools(): Promise<void> {
8989
}
9090
}
9191

92+
function getAssetOutputHint(type: string, name: string): string {
93+
switch (type) {
94+
case ASSET_TYPE.Command:
95+
return `.claude/commands/${name}.md`;
96+
case ASSET_TYPE.Template:
97+
return `docs/specs/${name}.md`;
98+
case ASSET_TYPE.Hook:
99+
return `.claude/settings.local.json`;
100+
default:
101+
return '';
102+
}
103+
}
104+
92105
async function listAssets(typeFilter?: string): Promise<void> {
93106
const cwd = process.cwd();
94107
if (!(await ensureConfig(cwd))) return;
@@ -110,7 +123,8 @@ async function listAssets(typeFilter?: string): Promise<void> {
110123
ui.header(`Installed ${label} (${String(filtered.length)})`);
111124
ui.newline();
112125
for (const asset of filtered) {
113-
console.log(` ${chalk.dim(ICONS.bullet)} ${chalk.cyan(asset.type.padEnd(10))} ${chalk.white(asset.name.padEnd(20))} ${chalk.dim(`v${asset.version}`)}`);
126+
const outputHint = getAssetOutputHint(asset.type, asset.name);
127+
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)}`);
114128
}
115129
}
116130

0 commit comments

Comments
 (0)