Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 0 additions & 33 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- BEGIN dev-workflows -->
# 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.
<!-- END dev-workflows -->
80 changes: 51 additions & 29 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import { resolveContext } from '../core/resolve-context.js';
import {
selectPrompt,
multiselectPrompt,
multiselectPromptOrBack,
confirmPrompt,
introPrompt,
outroPrompt,
notePrompt,
spinnerTask,
isInteractiveSession,
} from '../utils/prompt.js';
Expand Down Expand Up @@ -370,6 +372,7 @@ function compareSemver(a: string, b: string): number {
interface RuleVersionCheck {
installedVersion?: string;
registryVersion?: string;
registryRule?: RegistryRule;
}

export async function downloadAndInstallAsset(
Expand Down Expand Up @@ -465,8 +468,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({
Expand Down Expand Up @@ -719,32 +720,28 @@ async function runInteractive(cwd: string, options: AddOptions): Promise<void> {
const category = registry.categories.find((c) => c.name === selectedCategoryName);
if (!category) break;

const selected = await multiselectPrompt<string>({
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<string>({
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,
Expand Down Expand Up @@ -772,13 +769,15 @@ async function runInteractive(cwd: string, options: AddOptions): Promise<void> {

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 dest = '.dwf/rules/';
const maxLen = Math.max(...allSelected.map((r) => `${r.category}/${r.name}`.length));
const summaryLines = allSelected
.map((r) => {
const rulePath = `${r.category}/${r.name}`;
return `${rulePath.padEnd(maxLen)} ${ICONS.arrow} ${dest}`;
})
.join('\n');
notePrompt(summaryLines, `Installing ${pluralRules(allSelected.length)}`);

try {
const shouldProceed = await confirmPrompt({
Expand Down Expand Up @@ -893,9 +892,12 @@ async function resolveRuleVersionCheck(cwd: string, source: string): Promise<Rul
}

let registryVersion: string | undefined;
let registryRule: RegistryRule | undefined;
try {
const registry = await fetchRegistryManifest(cwd);
registryVersion = registry.rules.find((rule) => rule.path === source)?.version;
const found = registry.rules.find((rule) => rule.path === source);
registryVersion = found?.version;
registryRule = found ?? undefined;
} catch {
registryVersion = undefined;
}
Expand All @@ -907,6 +909,7 @@ async function resolveRuleVersionCheck(cwd: string, source: string): Promise<Rul
return {
installedVersion,
registryVersion,
registryRule,
};
}

Expand Down Expand Up @@ -991,6 +994,25 @@ export async function runAdd(ruleArg: string | undefined, options: AddOptions):

const source = `${category}/${name}`;
const versionCheck = await resolveRuleVersionCheck(cwd, source);

// Preview card in interactive mode (without --force or --dry-run)
if (isInteractiveSession() && !options.force && !options.dryRun) {
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 });
if (!confirmed) {
outroPrompt('Cancelled.');
return;
}
} catch {
return;
}
}

const added = await downloadAndInstall(cwd, category, name, options, versionCheck);

if (added && !options.noCompile) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function formatSeparator(toolId: string): string {
return pc.dim(`${prefix}${label}${suffix}`);
}

async function runExplain(options: ExplainOptions): Promise<void> {
export async function runExplain(options: ExplainOptions): Promise<void> {
const resolved = await resolveContext(process.cwd());

if (!resolved) {
Expand Down
60 changes: 31 additions & 29 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
confirmPrompt,
introPrompt,
outroPrompt,
notePrompt,
spinnerTask,
isInteractiveSession,
} from '../utils/prompt.js';
Expand Down Expand Up @@ -161,34 +162,40 @@ export async function runInit(options: InitOptions): Promise<void> {

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 = await confirmPrompt({
message: 'Overwrite config? (rules will be preserved)',
defaultValue: false,
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 (!overwrite) {
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 },
Expand All @@ -198,19 +205,15 @@ export async function runInit(options: InitOptions): Promise<void> {
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');
}
},
});
Expand All @@ -220,7 +223,6 @@ export async function runInit(options: InitOptions): Promise<void> {
}

// 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.');
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ async function listAssets(typeFilter?: string): Promise<void> {
}
}

async function runList(subcommand: string | undefined): Promise<void> {
export async function runList(subcommand: string | undefined): Promise<void> {
if (!subcommand) {
ui.error('Specify what to list', 'Usage: devw list <rules|tools|assets|commands|templates|hooks>');
process.exitCode = 1;
Expand Down
Loading
Loading