Skip to content
Merged
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
61 changes: 15 additions & 46 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import { windsurfBridge } from '../bridges/windsurf.js';
import { copilotBridge } from '../bridges/copilot.js';
import { mergeMarkedContent, removeMarkedBlock } from '../core/markers.js';
import { cleanStaleFiles } from '../core/scope-filename.js';
import { detectLegacyFiles, migrateLegacyFiles } from '../core/cleanup.js';
import { buildCanonicalOutputs, writeCanonical } from '../core/canonical.js';
import { detectLegacyFiles, migrateLegacyFiles, detectCanonicalDir, removeCanonicalDir } from '../core/cleanup.js';
import { fileExists } from '../utils/fs.js';
import { resolveContext } from '../core/resolve-context.js';
import * as ui from '../utils/ui.js';
Expand Down Expand Up @@ -53,8 +52,6 @@ export interface CompileResult {
globalRuleCount: number;
projectRuleCount: number;
overriddenRuleIds: string[];
canonicalFileCount: number;
canonicalError?: string;
assetPaths: string[];
elapsedMs: number;
staleResults: StaleFileResult[];
Expand Down Expand Up @@ -168,6 +165,15 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
const actions = await migrateLegacyFiles(context.outputRoot, legacyFiles);
migration.actions = actions;
}

// Clean up old canonical directory if it exists
const canonicalDir = await detectCanonicalDir(context.outputRoot);
if (canonicalDir) {
const removed = await removeCanonicalDir(context.outputRoot);
if (removed) {
migration.actions.push('Removed legacy .agents/rules/devw/ canonical directory');
}
}
}

const activeRules = rules.filter((r) => r.enabled);
Expand Down Expand Up @@ -298,35 +304,6 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
}
}

// Canonical output intentionally always runs, even when --tool filters bridges.
// This keeps `.agents/rules/devw` as the source-of-truth for doctor checks and distribution.
const canonicalOutputs = buildCanonicalOutputs(rules);
let canonicalPaths: string[] = [];
let canonicalError: string | undefined;
if (write) {
try {
canonicalPaths = await writeCanonical(context.outputRoot, canonicalOutputs);
for (const relativePath of canonicalPaths) {
results.push({ bridgeId: 'canonical', outputPath: relativePath, success: true });
}
} catch (err) {
canonicalError = err instanceof Error ? err.message : String(err);
const errorPaths = [...canonicalOutputs.keys()];
if (errorPaths.length > 0) {
for (const relativePath of errorPaths) {
results.push({ bridgeId: 'canonical', outputPath: relativePath, success: false, error: canonicalError });
}
} else {
results.push({ bridgeId: 'canonical', outputPath: '.agents/rules/devw', success: false, error: canonicalError });
}
}
} else {
for (const [relativePath, content] of canonicalOutputs) {
canonicalPaths.push(relativePath);
results.push({ bridgeId: 'canonical', outputPath: relativePath, success: true, content });
}
}

let assetPaths: string[] = [];
if (write) {
const hash = computeRulesHash(activeRules);
Expand All @@ -343,8 +320,6 @@ export async function executePipeline(options: PipelineOptions): Promise<Compile
globalRuleCount: globalRules.length,
projectRuleCount: projectRules.length,
overriddenRuleIds,
canonicalFileCount: canonicalPaths.length,
canonicalError,
assetPaths,
elapsedMs,
staleResults,
Expand Down Expand Up @@ -403,22 +378,13 @@ export async function runCompile(options: CompileOptions): Promise<void> {
const fileCount = result.results.filter((r) => r.success).length;
ui.newline();
ui.info(
`Would generate ${String(fileCount)} file${fileCount !== 1 ? 's' : ''} (${String(result.canonicalFileCount)} canonical) from ${String(result.activeRuleCount)} rules`,
`Would generate ${String(fileCount)} file${fileCount !== 1 ? 's' : ''} from ${String(result.activeRuleCount)} rules`,
);
return;
}

const result = await executePipeline({ cwd, tool: options.tool });

if (options.tool) {
ui.info('Note: canonical output is always refreshed in .agents/rules/devw');
}

if (result.canonicalError) {
ui.warn(`Canonical write failed: ${result.canonicalError}`);
ui.warn('Tool-specific outputs were still written');
}

const summaryTable = renderTable(
['bridge', 'generated', 'failed'],
toCompileSummaryRows(result),
Expand All @@ -438,8 +404,11 @@ export async function runCompile(options: CompileOptions): Promise<void> {
const allPaths = [...writtenPaths, ...result.assetPaths];

ui.newline();
if (result.activeRuleCount === 0) {
ui.warn('No rules found. Run "devw add" to install rules from the registry.');
return;
}
ui.success(`Compiled ${String(result.activeRuleCount)} rules ${ICONS.arrow} ${String(allPaths.length)} file${allPaths.length !== 1 ? 's' : ''} ${ui.timing(result.elapsedMs)}`);
ui.info(`Canonical files: ${String(result.canonicalFileCount)}`);
ui.log(summaryTable);
if (options.verbose && result.overriddenRuleIds.length > 0) {
ui.info(`Project overrides (${String(result.overriddenRuleIds.length)}): ${result.overriddenRuleIds.join(', ')}`);
Expand Down
127 changes: 2 additions & 125 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { lstat, readFile, readdir } from 'node:fs/promises';
import { basename, join, relative } from 'node:path';
import { join, relative } from 'node:path';
import type { Command } from 'commander';
import { parse } from 'yaml';
import { readConfig, readRules } from '../core/parser.js';
Expand All @@ -14,7 +14,6 @@ import { getBridgeOutputPaths, isDirectoryBridge } from '../bridges/types.js';
import { fileExists } from '../utils/fs.js';
import { resolveContext } from '../core/resolve-context.js';
import { isValidScope } from '../core/schema.js';
import { buildCanonicalOutputs } from '../core/canonical.js';
import { detectLegacyFiles } from '../core/cleanup.js';
import * as ui from '../utils/ui.js';

Expand Down Expand Up @@ -268,12 +267,6 @@ export async function checkHashSync(cwd: string, rules: Rule[]): Promise<CheckRe
};
}

function normalizeComparableContent(content: string): string {
const frontmatterPattern = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
const withoutFrontmatter = content.replace(frontmatterPattern, '');
return withoutFrontmatter.replaceAll('\r\n', '\n').trimEnd();
}

function extractFrontmatter(content: string): string | null {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) {
Expand All @@ -282,112 +275,6 @@ function extractFrontmatter(content: string): string | null {
return match[1] ?? null;
}

export async function checkCanonicalExists(cwd: string): Promise<CheckResult> {
const canonicalDir = join(cwd, '.agents', 'rules', 'devw');

let entries: string[];
try {
entries = await readdir(canonicalDir);
} catch {
return {
passed: false,
message: '.agents/rules/devw not found — run "devw compile"',
};
}

const canonicalFiles = entries.filter((entry) => entry.startsWith('dwf-') && entry.endsWith('.md'));
if (canonicalFiles.length === 0) {
return {
passed: false,
message: '.agents/rules/devw has no canonical files — run "devw compile"',
};
}

return {
passed: true,
message: `Canonical files exist (${String(canonicalFiles.length)} file${canonicalFiles.length === 1 ? '' : 's'})`,
};
}

export async function checkCanonicalSync(cwd: string, rules: Rule[], config: ProjectConfig): Promise<CheckResult> {
const directoryBridges = getConfiguredDirectoryBridges(config);

if (directoryBridges.length === 0) {
return {
passed: true,
message: 'Canonical sync skipped (no directory tools configured)',
skipped: true,
};
}

const canonicalOutputs = buildCanonicalOutputs(rules);
if (canonicalOutputs.size === 0) {
return {
passed: true,
message: 'Canonical sync skipped (no active scope outputs)',
skipped: true,
};
}

const mismatches: string[] = [];
let compared = 0;

for (const bridge of directoryBridges) {
const expectedNativeFiles = new Set<string>();

for (const [canonicalPath, canonicalContent] of canonicalOutputs) {
const canonicalFilename = basename(canonicalPath);
const scopeName = canonicalFilename.slice('dwf-'.length, canonicalFilename.length - '.md'.length);
const nativeFilename = `${bridge.filePrefix}${scopeName}${bridge.fileExtension}`;
expectedNativeFiles.add(nativeFilename);

const nativePath = join(cwd, bridge.outputDir, nativeFilename);
if (!(await fileExists(nativePath))) {
mismatches.push(`${bridge.id}: missing ${nativeFilename}`);
continue;
}

const nativeRaw = await readFile(nativePath, 'utf-8');
const normalizedNative = normalizeComparableContent(nativeRaw);
const normalizedCanonical = normalizeComparableContent(canonicalContent);

compared += 1;
if (normalizedNative !== normalizedCanonical) {
mismatches.push(`${bridge.id}: modified ${nativeFilename}`);
}
}

const bridgeDir = join(cwd, bridge.outputDir);
let entries: string[] = [];
try {
entries = await readdir(bridgeDir);
} catch {
entries = [];
}

for (const entry of entries) {
if (!entry.startsWith(bridge.filePrefix) || !entry.endsWith(bridge.fileExtension)) {
continue;
}
if (!expectedNativeFiles.has(entry)) {
mismatches.push(`${bridge.id}: unexpected ${entry}`);
}
}
}

if (mismatches.length > 0) {
return {
passed: false,
message: `Canonical/native mismatch: ${mismatches.join(', ')}`,
};
}

return {
passed: true,
message: `Canonical and native files are in sync (${String(compared)} files compared)`,
};
}

export async function checkLegacyMigration(cwd: string): Promise<CheckResult> {
const legacyFiles = await detectLegacyFiles(cwd);
if (legacyFiles.length === 0) {
Expand Down Expand Up @@ -570,17 +457,7 @@ export async function runDoctor(): Promise<void> {
const hashResult = await checkHashSync(effectiveCwd, rules);
results.push(hashResult);

// Check 11: Canonical output exists (skip if no rules)
if (rules.length > 0) {
const canonicalExistsResult = await checkCanonicalExists(effectiveCwd);
results.push(canonicalExistsResult);

// Check 12: Canonical and native outputs are synchronized
const canonicalSyncResult = await checkCanonicalSync(effectiveCwd, rules, config);
results.push(canonicalSyncResult);
}

// Check 13: Legacy migration has no pending files
// Check 11: Legacy migration has no pending files
const legacyResult = await checkLegacyMigration(effectiveCwd);
results.push(legacyResult);

Expand Down
53 changes: 16 additions & 37 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@ import { join, basename } from 'node:path';
import { homedir } from 'node:os';
import type { Command } from 'commander';
import { stringify } from 'yaml';
import pc from 'picocolors';
import { detectTools, SUPPORTED_TOOLS } from '../utils/detect-tools.js';
import * as ui from '../utils/ui.js';
import type { ToolId } from '../utils/detect-tools.js';
import { fileExists } from '../utils/fs.js';
import {
selectPrompt,
multiselectPrompt,
confirmPrompt,
introPrompt,
notePrompt,
outroPrompt,
spinnerTask,
isInteractiveSession,
Expand Down Expand Up @@ -165,11 +164,17 @@ export async function runInit(options: InitOptions): Promise<void> {

if (await fileExists(dwfDir)) {
const locationHint = scope === 'global'
? '~/.dwf/ already exists in your home directory'
: '.dwf/ already exists in this directory';
ui.error(locationHint, 'Remove it first or run from a different directory');
process.exitCode = 1;
return;
? '~/.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 (!overwrite) {
outroPrompt('Init cancelled.');
return;
}
}

const projectName = scope === 'global' ? 'global' : basename(cwd);
Expand Down Expand Up @@ -210,41 +215,15 @@ export async function runInit(options: InitOptions): Promise<void> {
},
});

// Ensure canonical global output dir exists for global mode.
if (scope === 'global') {
await spinnerTask({
label: 'Preparing canonical global output',
task: async () => {
await mkdir(join(rootDir, '.agents', 'rules', 'devw'), { recursive: true });
},
});
} else {
if (scope !== 'global') {
await appendToGitignore(cwd);
}

// Success summary
const dwfPath = scope === 'global' ? '~/.dwf/' : '.dwf/';
ui.newline();
ui.header('dev-workflows');
ui.newline();
ui.success(`Initialized ${scope === 'global' ? '~/.dwf/' : '.dwf/'} successfully`);
ui.newline();
ui.keyValue('Project:', pc.bold(projectName));
ui.keyValue('Scope:', scope);
ui.keyValue('Tools:', pc.cyan(tools.join(', ')));
ui.keyValue('Mode:', mode);
ui.newline();
ui.header("What's next");
ui.newline();
console.log(` 1. Browse available rules ${pc.cyan('devw add --list')}`);
console.log(` 2. Add a rule ${pc.cyan('devw add <category>/<rule>')}`);
console.log(` 3. Or write your own rules in ${pc.cyan(scope === 'global' ? '~/.dwf/rules/' : '.dwf/rules/')}`);
console.log(` 4. When ready, compile ${pc.cyan('devw compile')}`);

notePrompt(
`Project: ${projectName}\nScope: ${scope}\nTools: ${tools.join(', ')}\nMode: ${mode}`,
'Initialized',
);
outroPrompt(`Ready: ${scope === 'global' ? '~/.dwf/' : '.dwf/'}`);
ui.success(`Initialized ${dwfPath} — ${tools.join(', ')} (${mode} mode)`);
outroPrompt('Run "devw add" to browse and install rules.');

if (options.preset) {
ui.newline();
Expand Down
Loading
Loading