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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ C:*
node_modules/
.pnpm-store/
__pycache__/
# cc-taskrunner worktree protection
C:*
node_modules/
.pnpm-store/
__pycache__/
35 changes: 27 additions & 8 deletions packages/cli/src/__tests__/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ CONTEXT:
expect(fs.existsSync(path.join('.ai', '.backup'))).toBe(false);
expect(fs.existsSync(path.join('.ai', 'manifest.adf'))).toBe(true);
expect(fs.existsSync(path.join('.ai', 'state.adf'))).toBe(true);
expect(logs).toContain(' Warning: .ai/core.adf has custom content; skipping scaffold overwrite');
const skipWarning = logs.find(l => l.includes('.ai/core.adf has custom content') && l.includes('skipping scaffold overwrite'));
expect(skipWarning).toBeDefined();
expect(skipWarning).toMatch(/\d+ bytes/);
});

it('backs up and overwrites custom ADF files when run with --force', async () => {
Expand All @@ -75,15 +77,26 @@ STATE:
);

expect(exitCode).toBe(0);
expect(fs.readFileSync(path.join('.ai', '.backup', 'core.adf'), 'utf-8')).toBe(customCore);
expect(fs.readFileSync(path.join('.ai', '.backup', 'state.adf'), 'utf-8')).toBe(customState);

// Backup files should exist with timestamp suffix
const backupDir = path.join('.ai', '.backup');
expect(fs.existsSync(backupDir)).toBe(true);
const backupFiles = fs.readdirSync(backupDir);
const coreBackup = backupFiles.find(f => f.startsWith('core.adf.'));
const stateBackup = backupFiles.find(f => f.startsWith('state.adf.'));
expect(coreBackup).toBeDefined();
expect(stateBackup).toBeDefined();
expect(fs.readFileSync(path.join(backupDir, coreBackup!), 'utf-8')).toBe(customCore);
expect(fs.readFileSync(path.join(backupDir, stateBackup!), 'utf-8')).toBe(customState);

// Originals should be overwritten with scaffold content
expect(fs.readFileSync(path.join('.ai', 'core.adf'), 'utf-8')).not.toBe(customCore);
expect(fs.readFileSync(path.join('.ai', 'state.adf'), 'utf-8')).not.toBe(customState);
expect(logs).toContain(' Backed up 2 files to .ai/.backup/');
});

it('detects orphaned .adf modules not in manifest (--yes mode prints warning)', async () => {
// Pre-create .ai/ with an extra module that won't be in the scaffold manifest
it('detects orphaned .adf modules and auto-registers them in --yes mode', async () => {
// Pre-create .ai/ with extra modules that won't be in the scaffold manifest
fs.mkdirSync('.ai', { recursive: true });
fs.writeFileSync(path.join('.ai', 'agent.adf'), 'ADF: 0.1\n\nCONTEXT:\n - Agent rules\n');
fs.writeFileSync(path.join('.ai', 'persona.adf'), 'ADF: 0.1\n\nCONTEXT:\n - Persona rules\n');
Expand All @@ -99,8 +112,14 @@ STATE:
expect(orphanWarning).toBeDefined();
expect(orphanWarning).toContain('agent.adf');
expect(orphanWarning).toContain('persona.adf');
// Should suggest the register command
const registerHint = logs.find(l => l.includes('charter adf register'));
expect(registerHint).toBeDefined();

// In --yes mode, orphans should be auto-registered in manifest
const registerLog = logs.find(l => l.includes('Registered 2 module(s) as ON_DEMAND'));
expect(registerLog).toBeDefined();

// Verify manifest contains the orphan entries
const manifest = fs.readFileSync(path.join('.ai', 'manifest.adf'), 'utf-8');
expect(manifest).toContain('agent.adf');
expect(manifest).toContain('persona.adf');
});
});
6 changes: 3 additions & 3 deletions packages/cli/src/commands/adf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ See .ai/manifest.adf for the module routing manifest.
`;

function adfInit(options: CLIOptions, args: string[]): number {
const force = options.yes || args.includes('--force');
const force = args.includes('--force');
const aiDir = getFlag(args, '--ai-dir') || '.ai';
const moduleFlag = getFlag(args, '--module');
const presetFlag = getFlag(args, '--preset');
Expand All @@ -323,7 +323,7 @@ function adfInit(options: CLIOptions, args: string[]): number {
console.log('');
console.log(' .ai/ directory already exists. Run \'charter doctor\' to check for issues.');
console.log('');
console.log(' Use --force (or --yes) to overwrite.');
console.log(' Use --force to overwrite existing files.');
console.log(' To add a single module: charter adf init --module <name>');
}
return EXIT_CODE.SUCCESS;
Expand Down Expand Up @@ -566,7 +566,7 @@ function adfCreate(options: CLIOptions, args: string[]): number {
}

const aiDir = getFlag(args, '--ai-dir') || '.ai';
const force = options.yes || args.includes('--force');
const force = args.includes('--force');
const load = (getFlag(args, '--load') || 'on-demand').toLowerCase();
if (load !== 'default' && load !== 'on-demand') {
throw new CLIError(`Invalid --load value: ${load}. Use default or on-demand.`);
Expand Down
26 changes: 19 additions & 7 deletions packages/cli/src/commands/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,22 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
console.log('');
}

// Orphan registration prompt (interactive only)
// Orphan registration: auto-register in --yes mode, prompt interactively otherwise
const orphans = adfResult.step.details.orphans as string[] || [];
if (orphans.length > 0 && !nonInteractive && options.format === 'text') {
const shouldRegister = await promptYesNo(' Register these modules now? (y/N) ');
if (orphans.length > 0) {
let shouldRegister = false;
if (nonInteractive) {
shouldRegister = true;
} else if (options.format === 'text') {
shouldRegister = await promptYesNo(' Register these modules now? (y/N) ');
}
if (shouldRegister) {
registerOrphansInManifest(path.join('.ai', 'manifest.adf'), orphans);
updateModuleIndex('CLAUDE.md', '.ai');
console.log(` Registered ${orphans.length} module(s) as ON_DEMAND in manifest.adf`);
console.log('');
if (options.format === 'text') {
console.log(` Registered ${orphans.length} module(s) as ON_DEMAND in manifest.adf`);
console.log('');
}
}
}

Expand Down Expand Up @@ -514,6 +521,7 @@ function writeAdfScaffolds(
const warnings: string[] = [];
let backedUp = 0;
let backupDir: string | undefined;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

for (const scaffold of getAdfScaffolds(preset)) {
const targetPath = path.join(aiDir, scaffold.name);
Expand All @@ -530,14 +538,18 @@ function writeAdfScaffolds(
continue;
}

const byteCount = Buffer.byteLength(existing, 'utf-8');

if (!force) {
warnings.push(`${label} has custom content; skipping scaffold overwrite`);
warnings.push(`${label} has custom content (${byteCount} bytes); skipping scaffold overwrite`);
continue;
}

backupDir ||= path.join(aiDir, '.backup');
fs.mkdirSync(backupDir, { recursive: true });
fs.copyFileSync(targetPath, path.join(backupDir, scaffold.name));
const backupName = `${scaffold.name}.${timestamp}`;
fs.copyFileSync(targetPath, path.join(backupDir, backupName));
warnings.push(`Backed up ${label} (${byteCount} bytes) → .ai/.backup/${backupName}`);
backedUp++;

fs.writeFileSync(targetPath, scaffold.content);
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export async function setupCommand(options: CLIOptions, args: string[]): Promise
const presetFlag = getFlag(args, '--preset');
const detectOnly = args.includes('--detect-only');
const explicitForce = args.includes('--force');
const force = options.yes || explicitForce;
const force = explicitForce;
const noDependencySync = args.includes('--no-dependency-sync');

if (ciMode && ciMode !== 'github') {
Expand Down Expand Up @@ -645,7 +645,7 @@ export function detectStack(contexts: PackageContext[]): DetectionResult {
export function loadPackageContexts(): PackageContext[] {
const candidates = new Set<string>(['package.json']);

for (const dir of ['client', 'frontend', 'web']) {
for (const dir of ['client', 'frontend', 'web', 'worker', 'src', 'app']) {
candidates.add(path.join(dir, 'package.json'));
}
candidates.add(path.join('apps', 'web', 'package.json'));
Expand Down
Loading