claude-code-autoconfig 1.0.200 → 1.0.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -1,1014 +1,1043 @@
1
- #!/usr/bin/env node
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const readline = require('readline');
6
- const { execSync, spawn } = require('child_process');
7
- const { formatUpdateSummary } = require('./update-summary.js');
8
-
9
- const cwd = process.cwd();
10
- const packageDir = path.dirname(__dirname);
11
-
12
- // Cleanup any stray 'nul' file immediately on startup (Windows /dev/null artifact)
13
- function cleanupNulFile() {
14
- const nulFile = path.join(cwd, 'nul');
15
- if (fs.existsSync(nulFile)) {
16
- try {
17
- fs.unlinkSync(nulFile);
18
- } catch (e) {
19
- // Ignore - file might be locked
20
- }
21
- }
22
- }
23
- cleanupNulFile();
24
-
25
- // Reserved Windows device names - never create files with these names
26
- const WINDOWS_RESERVED = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4',
27
- 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5',
28
- 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
29
-
30
- // Files/folders installed by autoconfig - don't backup these
31
- const AUTOCONFIG_FILES = ['commands', 'docs', 'agents', 'migration', 'hooks', 'scripts', 'sounds', 'rules', 'feedback', 'settings.json', 'settings.local.json', '.mcp.json', '.autoconfig-version', '.autoconfig-plugins.json'];
32
-
33
- function isReservedName(name) {
34
- const baseName = name.replace(/\.[^.]*$/, '').toUpperCase();
35
- return WINDOWS_RESERVED.includes(baseName);
36
- }
37
-
38
- function hasUserContent(claudeDir) {
39
- // Check if .claude/ has any files beyond what autoconfig installs
40
- if (!fs.existsSync(claudeDir)) return false;
41
-
42
- const entries = fs.readdirSync(claudeDir);
43
- for (const entry of entries) {
44
- if (!AUTOCONFIG_FILES.includes(entry)) {
45
- // Found something that's not from autoconfig
46
- return true;
47
- }
48
- }
49
- return false;
50
- }
51
-
52
- function formatTimestamp() {
53
- const now = new Date();
54
- const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
55
- 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
56
- const month = months[now.getMonth()];
57
- const day = now.getDate();
58
- const year = now.getFullYear();
59
- const hour = now.getHours();
60
- const min = String(now.getMinutes()).padStart(2, '0');
61
- const ampm = hour >= 12 ? 'pm' : 'am';
62
- const hour12 = hour % 12 || 12;
63
-
64
- return `${month}-${day}-${year}_${hour12}-${min}${ampm}`;
65
- }
66
-
67
- // --pull-updates: Copy new update files from package to user's project
68
- function parseAppliedUpdates(filePath) {
69
- if (!fs.existsSync(filePath)) return [];
70
- const content = fs.readFileSync(filePath, 'utf8');
71
- const match = content.match(/<!-- @applied\r?\n([\s\S]*?)-->/);
72
- if (!match) return [];
73
-
74
- return match[1].trim().split('\n')
75
- .filter(line => line.trim())
76
- .map(line => {
77
- const idMatch = line.match(/^(\d{3})/);
78
- return idMatch ? parseInt(idMatch[1], 10) : 0;
79
- })
80
- .filter(id => id > 0);
81
- }
82
-
83
- function getHighestAppliedId(appliedIds) {
84
- return appliedIds.length > 0 ? Math.max(...appliedIds) : 0;
85
- }
86
-
87
- function pullUpdates() {
88
- console.log('\x1b[36m%s\x1b[0m', '🔄 Checking for updates...');
89
- console.log();
90
-
91
- const userCmdPath = path.join(cwd, '.claude', 'commands', 'autoconfig-update.md');
92
- const packageCmdPath = path.join(packageDir, '.claude', 'commands', 'autoconfig-update.md');
93
- const packageUpdatesDir = path.join(packageDir, '.claude', 'updates');
94
- const userUpdatesDir = path.join(cwd, '.claude', 'updates');
95
-
96
- // Ensure .claude/commands/ exists
97
- fs.mkdirSync(path.join(cwd, '.claude', 'commands'), { recursive: true });
98
-
99
- // Refresh autoconfig-update.md (preserve user's @applied block)
100
- if (fs.existsSync(packageCmdPath)) {
101
- if (fs.existsSync(userCmdPath)) {
102
- const userContent = fs.readFileSync(userCmdPath, 'utf8');
103
- const packageContent = fs.readFileSync(packageCmdPath, 'utf8');
104
- const userApplied = userContent.match(/<!-- @applied[\s\S]*?-->/);
105
- if (userApplied) {
106
- const merged = packageContent.replace(/<!-- @applied[\s\S]*?-->/, userApplied[0]);
107
- fs.writeFileSync(userCmdPath, merged);
108
- } else {
109
- fs.copyFileSync(packageCmdPath, userCmdPath);
110
- }
111
- } else {
112
- fs.copyFileSync(packageCmdPath, userCmdPath);
113
- }
114
- }
115
-
116
- // Check for available updates in package
117
- if (!fs.existsSync(packageUpdatesDir)) {
118
- console.log('\x1b[32m%s\x1b[0m', '✅ Already up to date');
119
- return;
120
- }
121
-
122
- const appliedIds = parseAppliedUpdates(userCmdPath);
123
- const highestApplied = getHighestAppliedId(appliedIds);
124
-
125
- const updateFiles = fs.readdirSync(packageUpdatesDir).filter(f => f.endsWith('.md'));
126
- const newUpdates = updateFiles.filter(file => {
127
- const match = file.match(/^(\d{3})-/);
128
- if (!match) return false;
129
- return parseInt(match[1], 10) > highestApplied;
130
- });
131
-
132
- if (newUpdates.length === 0) {
133
- console.log('\x1b[32m%s\x1b[0m', '✅ Already up to date');
134
- return;
135
- }
136
-
137
- // Copy new update files
138
- fs.mkdirSync(userUpdatesDir, { recursive: true });
139
- for (const file of newUpdates) {
140
- fs.copyFileSync(
141
- path.join(packageUpdatesDir, file),
142
- path.join(userUpdatesDir, file)
143
- );
144
- }
145
-
146
- console.log('\x1b[32m%s\x1b[0m', `✅ Copied ${newUpdates.length} new update${newUpdates.length > 1 ? 's' : ''} to .claude/updates/`);
147
- console.log();
148
- console.log('Run \x1b[36mclaude /autoconfig-update\x1b[0m to review and install updates.');
149
- }
150
-
151
- if (process.argv.includes('--pull-updates')) {
152
- pullUpdates();
153
- process.exit(0);
154
- }
155
-
156
- // ============================================================================
157
- // Settings merge helpers (shared by the upgrade path and the plugin installer)
158
- // ============================================================================
159
-
160
- // Additively fold a settings fragment (hooks / env / permissions) into an existing
161
- // settings object, mutating and returning it.
162
- // - hooks: add hook commands that don't already exist (dedup by command string, per event)
163
- // - env: add keys the user hasn't set (never overwrite an existing value)
164
- // - permissions.allow/deny: add missing rules (and migrate deprecated :* syntax)
165
- function mergeSettingsInto(userSettings, fragment) {
166
- if (fragment.hooks) {
167
- if (!userSettings.hooks) userSettings.hooks = {};
168
- for (const [event, matchers] of Object.entries(fragment.hooks)) {
169
- if (!userSettings.hooks[event]) {
170
- userSettings.hooks[event] = matchers;
171
- } else {
172
- // Add any hook commands that don't already exist
173
- for (const matcher of matchers) {
174
- for (const hook of matcher.hooks || []) {
175
- const exists = userSettings.hooks[event].some(m =>
176
- (m.hooks || []).some(h => h.command === hook.command)
177
- );
178
- if (!exists) {
179
- const existingMatcher = userSettings.hooks[event].find(m => m.matcher === matcher.matcher);
180
- if (existingMatcher) {
181
- existingMatcher.hooks = existingMatcher.hooks || [];
182
- existingMatcher.hooks.push(hook);
183
- } else {
184
- userSettings.hooks[event].push(matcher);
185
- }
186
- }
187
- }
188
- }
189
- }
190
- }
191
- }
192
-
193
- if (fragment.env) {
194
- if (!userSettings.env) userSettings.env = {};
195
- for (const [key, value] of Object.entries(fragment.env)) {
196
- if (!(key in userSettings.env)) userSettings.env[key] = value;
197
- }
198
- }
199
-
200
- if (fragment.permissions) {
201
- if (!userSettings.permissions) userSettings.permissions = {};
202
- for (const key of ['allow', 'deny']) {
203
- if (!fragment.permissions[key]) continue;
204
- if (!userSettings.permissions[key]) {
205
- userSettings.permissions[key] = fragment.permissions[key];
206
- } else {
207
- // Migrate deprecated :* syntax to space-* in existing entries
208
- userSettings.permissions[key] = userSettings.permissions[key].map(rule =>
209
- rule.replace(/^(Bash\([^)]*):(\*\))$/, '$1 $2')
210
- );
211
- for (const rule of fragment.permissions[key]) {
212
- if (!userSettings.permissions[key].includes(rule)) {
213
- userSettings.permissions[key].push(rule);
214
- }
215
- }
216
- }
217
- }
218
- }
219
-
220
- return userSettings;
221
- }
222
-
223
- // Inverse of mergeSettingsInto: strip a fragment's contributions back out. Only removes
224
- // what the fragment added (dedup-safe), leaving the user's own entries untouched.
225
- function unmergeSettingsFrom(userSettings, fragment) {
226
- if (fragment.hooks && userSettings.hooks) {
227
- for (const [event, matchers] of Object.entries(fragment.hooks)) {
228
- if (!userSettings.hooks[event]) continue;
229
- const commands = new Set();
230
- for (const matcher of matchers) {
231
- for (const hook of matcher.hooks || []) {
232
- if (hook.command) commands.add(hook.command);
233
- }
234
- }
235
- userSettings.hooks[event] = userSettings.hooks[event]
236
- .map(m => {
237
- if (m.hooks) m.hooks = m.hooks.filter(h => !commands.has(h.command));
238
- return m;
239
- })
240
- .filter(m => (m.hooks || []).length > 0);
241
- if (userSettings.hooks[event].length === 0) delete userSettings.hooks[event];
242
- }
243
- if (Object.keys(userSettings.hooks).length === 0) delete userSettings.hooks;
244
- }
245
-
246
- if (fragment.env && userSettings.env) {
247
- for (const [key, value] of Object.entries(fragment.env)) {
248
- if (userSettings.env[key] === value) delete userSettings.env[key];
249
- }
250
- if (Object.keys(userSettings.env).length === 0) delete userSettings.env;
251
- }
252
-
253
- if (fragment.permissions && userSettings.permissions) {
254
- for (const key of ['allow', 'deny']) {
255
- if (!fragment.permissions[key] || !userSettings.permissions[key]) continue;
256
- const remove = new Set(fragment.permissions[key]);
257
- userSettings.permissions[key] = userSettings.permissions[key].filter(r => !remove.has(r));
258
- }
259
- }
260
-
261
- return userSettings;
262
- }
263
-
264
- // ============================================================================
265
- // Plugin system: install / remove / list drop-in add-on plugins.
266
- //
267
- // A plugin is a folder containing a plugin.json manifest:
268
- // {
269
- // "name": "terminal-title",
270
- // "version": "1.0.0",
271
- // "description": "...",
272
- // "files": [ { "from": "hooks/x.js", "to": "hooks/x.js" } ], // "to" is relative to <project>/.claude
273
- // "settings": { "env": {...}, "hooks": {...}, "permissions": {...} } // folded into .claude/settings.json
274
- // }
275
- //
276
- // Installed plugins are tracked in .claude/.autoconfig-plugins.json so `plugin remove`
277
- // cleanly undoes both the copied files and the settings contributions. The free core
278
- // ships only this generic loader — paid/closed plugins live and are delivered separately.
279
- // ============================================================================
280
-
281
- const PLUGINS_LEDGER = '.autoconfig-plugins.json';
282
-
283
- function readPluginsLedger(claudeDir) {
284
- const p = path.join(claudeDir, PLUGINS_LEDGER);
285
- if (!fs.existsSync(p)) return {};
286
- try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; }
287
- }
288
-
289
- function writePluginsLedger(claudeDir, ledger) {
290
- fs.mkdirSync(claudeDir, { recursive: true });
291
- fs.writeFileSync(path.join(claudeDir, PLUGINS_LEDGER), JSON.stringify(ledger, null, 2));
292
- }
293
-
294
- function loadManifest(pluginDir) {
295
- const manifestPath = path.join(pluginDir, 'plugin.json');
296
- if (!fs.existsSync(manifestPath)) throw new Error(`no plugin.json found in ${pluginDir}`);
297
- let manifest;
298
- try {
299
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
300
- } catch (e) {
301
- throw new Error(`plugin.json is not valid JSON: ${e.message}`);
302
- }
303
- if (!manifest.name || typeof manifest.name !== 'string') {
304
- throw new Error('plugin.json must declare a string "name"');
305
- }
306
- if (manifest.files && !Array.isArray(manifest.files)) {
307
- throw new Error('plugin.json "files" must be an array');
308
- }
309
- if (!manifest.files) manifest.files = [];
310
- return manifest;
311
- }
312
-
313
- function pluginAdd(pluginArg, claudeDir) {
314
- const pluginDir = path.resolve(cwd, pluginArg);
315
- const manifest = loadManifest(pluginDir);
316
- console.log('\x1b[36m%s\x1b[0m', `📦 Installing plugin: ${manifest.name}${manifest.version ? ' v' + manifest.version : ''}`);
317
-
318
- // 1. Copy declared files into <project>/.claude/<to>
319
- const installedFiles = [];
320
- for (const file of manifest.files) {
321
- if (!file || !file.from || !file.to) throw new Error('each "files" entry must have "from" and "to"');
322
- const src = path.resolve(pluginDir, file.from);
323
- if (!fs.existsSync(src)) throw new Error(`plugin file not found: ${file.from}`);
324
- const dest = path.join(claudeDir, file.to);
325
- if (isReservedName(path.basename(dest))) throw new Error(`refusing to write reserved filename: ${file.to}`);
326
- fs.mkdirSync(path.dirname(dest), { recursive: true });
327
- fs.copyFileSync(src, dest);
328
- installedFiles.push(file.to);
329
- console.log('\x1b[90m%s\x1b[0m', ` + .claude/${file.to}`);
330
- }
331
-
332
- // 2. Fold the settings fragment into .claude/settings.json (clone first to avoid aliasing the ledger copy)
333
- if (manifest.settings) {
334
- const settingsPath = path.join(claudeDir, 'settings.json');
335
- let userSettings = {};
336
- if (fs.existsSync(settingsPath)) {
337
- try { userSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { userSettings = {}; }
338
- }
339
- mergeSettingsInto(userSettings, JSON.parse(JSON.stringify(manifest.settings)));
340
- fs.mkdirSync(claudeDir, { recursive: true });
341
- fs.writeFileSync(settingsPath, JSON.stringify(userSettings, null, 2));
342
- console.log('\x1b[90m%s\x1b[0m', ' ✎ merged settings.json (hooks / env / permissions)');
343
- }
344
-
345
- // 3. Record in the ledger so removal can cleanly undo everything
346
- const ledger = readPluginsLedger(claudeDir);
347
- ledger[manifest.name] = {
348
- version: manifest.version || null,
349
- files: installedFiles,
350
- settings: manifest.settings || null,
351
- installedAt: new Date().toISOString()
352
- };
353
- writePluginsLedger(claudeDir, ledger);
354
- console.log('\x1b[32m%s\x1b[0m', `✅ Installed ${manifest.name}`);
355
- }
356
-
357
- function pluginRemove(name, claudeDir) {
358
- const ledger = readPluginsLedger(claudeDir);
359
- const entry = ledger[name];
360
- if (!entry) throw new Error(`plugin "${name}" is not installed`);
361
- console.log('\x1b[36m%s\x1b[0m', `🗑 Removing plugin: ${name}`);
362
-
363
- // 1. Delete the files the plugin installed
364
- for (const rel of entry.files || []) {
365
- const p = path.join(claudeDir, rel);
366
- if (fs.existsSync(p)) {
367
- fs.rmSync(p, { force: true });
368
- console.log('\x1b[90m%s\x1b[0m', ` - .claude/${rel}`);
369
- }
370
- }
371
-
372
- // 2. Revert the settings contributions
373
- if (entry.settings) {
374
- const settingsPath = path.join(claudeDir, 'settings.json');
375
- if (fs.existsSync(settingsPath)) {
376
- try {
377
- const userSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
378
- unmergeSettingsFrom(userSettings, entry.settings);
379
- fs.writeFileSync(settingsPath, JSON.stringify(userSettings, null, 2));
380
- console.log('\x1b[90m%s\x1b[0m', ' ✎ reverted settings.json contributions');
381
- } catch { /* leave settings intact if unparsable */ }
382
- }
383
- }
384
-
385
- // 3. Drop it from the ledger
386
- delete ledger[name];
387
- writePluginsLedger(claudeDir, ledger);
388
- console.log('\x1b[32m%s\x1b[0m', `✅ Removed ${name}`);
389
- }
390
-
391
- function pluginList(claudeDir) {
392
- const ledger = readPluginsLedger(claudeDir);
393
- const names = Object.keys(ledger);
394
- if (names.length === 0) {
395
- console.log('\x1b[90m%s\x1b[0m', 'No plugins installed.');
396
- return;
397
- }
398
- console.log('\x1b[36m%s\x1b[0m', 'Installed plugins:');
399
- for (const name of names) {
400
- const e = ledger[name];
401
- const n = (e.files || []).length;
402
- console.log(` • ${name}${e.version ? ' v' + e.version : ''} (${n} file${n === 1 ? '' : 's'})`);
403
- }
404
- }
405
-
406
- function runPluginCommand(argv) {
407
- const sub = argv[3];
408
- const arg = argv[4];
409
- const claudeDir = path.join(cwd, '.claude');
410
- try {
411
- if (sub === 'add' || sub === 'install') {
412
- if (!arg) throw new Error('usage: claude-code-autoconfig plugin add <path-to-plugin-dir>');
413
- pluginAdd(arg, claudeDir);
414
- } else if (sub === 'remove' || sub === 'rm' || sub === 'uninstall') {
415
- if (!arg) throw new Error('usage: claude-code-autoconfig plugin remove <name>');
416
- pluginRemove(arg, claudeDir);
417
- } else if (sub === 'list' || sub === 'ls') {
418
- pluginList(claudeDir);
419
- } else {
420
- console.log('Usage:');
421
- console.log(' claude-code-autoconfig plugin add <dir> Install a plugin from a folder');
422
- console.log(' claude-code-autoconfig plugin remove <name> Uninstall a plugin');
423
- console.log(' claude-code-autoconfig plugin list List installed plugins');
424
- process.exit(sub ? 1 : 0);
425
- }
426
- } catch (err) {
427
- console.log('\x1b[31m%s\x1b[0m', `❌ ${err.message}`);
428
- process.exit(1);
429
- }
430
- }
431
-
432
- if (process.argv[2] === 'plugin') {
433
- runPluginCommand(process.argv);
434
- process.exit(0);
435
- }
436
-
437
- const forceMode = process.argv.includes('--force');
438
-
439
- // Detect if running inside Claude Code session.
440
- // CLAUDECODE=1 alone is not reliable: env vars inherit into every descendant
441
- // process (e.g. VS Code launched from a Claude session, and any terminal it
442
- // spawns). A genuine in-agent run has piped stdio (isTTY falsy), while a human
443
- // terminal has a real TTY — so require both signals before blocking.
444
- const insideClaude = process.env.CLAUDECODE === '1' && !process.stdout.isTTY;
445
-
446
- console.log('\x1b[36m%s\x1b[0m', '🚀 Claude Code Autoconfig');
447
- console.log();
448
-
449
- // Block early if running inside Claude Code (unless --bootstrap)
450
- if (insideClaude && !process.argv.includes('--bootstrap')) {
451
- console.log('\x1b[31m%s\x1b[0m', '● The tool needs to be run from a regular terminal, not from within Claude Code.');
452
- console.log();
453
- console.log(' Open a separate terminal window and run:');
454
- console.log();
455
- console.log(' \x1b[36mnpx claude-code-autoconfig@latest\x1b[0m');
456
- console.log();
457
- process.exit(0);
458
- }
459
-
460
- // Step 1: Check if Claude Code is installed
461
- function isClaudeInstalled() {
462
- try {
463
- execSync('claude --version', { stdio: 'ignore' });
464
- return true;
465
- } catch {
466
- return false;
467
- }
468
- }
469
-
470
- function installClaude() {
471
- console.log('\x1b[33m%s\x1b[0m', '⚠️ Claude Code not found. Installing...');
472
- console.log();
473
- try {
474
- execSync('npm install -g @anthropic-ai/claude-code', { stdio: 'inherit' });
475
- console.log();
476
- console.log('\x1b[32m%s\x1b[0m', ' Claude Code installed');
477
- return true;
478
- } catch (err) {
479
- console.log('\x1b[31m%s\x1b[0m', '❌ Failed to install Claude Code');
480
- console.log(' Install manually: npm install -g @anthropic-ai/claude-code');
481
- return false;
482
- }
483
- }
484
-
485
- if (!isClaudeInstalled()) {
486
- if (!installClaude()) {
487
- process.exit(1);
488
- }
489
- }
490
-
491
- console.log('\x1b[32m%s\x1b[0m', '✅ Claude Code detected');
492
-
493
- // Step 2: Backup existing .claude/ if it has user content
494
- const claudeDest = path.join(cwd, '.claude');
495
- const SKIP_BACKUP = ['migration']; // Don't backup the migration folder itself
496
- let migrationPath = null;
497
-
498
- // Diagnostic: log pre-install state
499
- console.log();
500
- console.log('\x1b[90m%s\x1b[0m', '── Pre-install state ──');
501
- console.log('\x1b[90m%s\x1b[0m', ` Working dir: ${cwd}`);
502
- const claudeMdExists = fs.existsSync(path.join(cwd, 'CLAUDE.md'));
503
- console.log('\x1b[90m%s\x1b[0m', ` CLAUDE.md: ${claudeMdExists ? 'exists' : 'not found'}`);
504
- if (claudeMdExists) {
505
- const claudeMdContent = fs.readFileSync(path.join(cwd, 'CLAUDE.md'), 'utf8');
506
- const hasMarker = claudeMdContent.includes('AUTO-GENERATED BY /autoconfig');
507
- console.log('\x1b[90m%s\x1b[0m', ` CLAUDE.md autoconfig marker: ${hasMarker ? 'yes' : 'no'}`);
508
- }
509
- if (fs.existsSync(claudeDest)) {
510
- const entries = fs.readdirSync(claudeDest);
511
- console.log('\x1b[90m%s\x1b[0m', ` .claude/ exists: yes (${entries.length} entries)`);
512
- for (const e of entries) {
513
- const isAutoconfig = AUTOCONFIG_FILES.includes(e);
514
- console.log('\x1b[90m%s\x1b[0m', ` ${isAutoconfig ? '·' : '▸'} ${e}${isAutoconfig ? '' : ' (user content)'}`);
515
- }
516
- const docsHtml = path.join(claudeDest, 'docs', 'autoconfig.docs.html');
517
- console.log('\x1b[90m%s\x1b[0m', ` autoconfig.docs.html: ${fs.existsSync(docsHtml) ? 'exists' : 'not found'}`);
518
- } else {
519
- console.log('\x1b[90m%s\x1b[0m', ' .claude/ exists: no');
520
- }
521
- console.log('\x1b[90m%s\x1b[0m', '───────────────────────');
522
- console.log();
523
-
524
- function copyDirForBackup(src, dest) {
525
- fs.mkdirSync(dest, { recursive: true });
526
- const entries = fs.readdirSync(src, { withFileTypes: true });
527
-
528
- for (const entry of entries) {
529
- if (SKIP_BACKUP.includes(entry.name)) continue;
530
- if (AUTOCONFIG_FILES.includes(entry.name)) continue; // Skip autoconfig-installed files
531
- if (isReservedName(entry.name)) continue;
532
-
533
- const srcPath = path.join(src, entry.name);
534
- const destPath = path.join(dest, entry.name);
535
-
536
- if (entry.isDirectory()) {
537
- copyDirForBackup(srcPath, destPath);
538
- } else {
539
- fs.copyFileSync(srcPath, destPath);
540
- }
541
- }
542
- }
543
-
544
- function collectFiles(dir, prefix = '') {
545
- const files = [];
546
- const entries = fs.readdirSync(dir, { withFileTypes: true });
547
- for (const entry of entries) {
548
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
549
- if (entry.isDirectory()) {
550
- files.push(...collectFiles(path.join(dir, entry.name), relPath));
551
- } else {
552
- files.push(relPath);
553
- }
554
- }
555
- return files;
556
- }
557
-
558
- if (fs.existsSync(claudeDest) && hasUserContent(claudeDest)) {
559
- const userEntries = fs.readdirSync(claudeDest).filter(e =>
560
- e !== 'migration' && !AUTOCONFIG_FILES.includes(e)
561
- );
562
- console.log('\x1b[90m%s\x1b[0m', ` Backup triggered by user content: ${userEntries.join(', ')}`);
563
-
564
- const timestamp = formatTimestamp();
565
- const migrationDir = path.join(claudeDest, 'migration');
566
- migrationPath = path.join(migrationDir, timestamp);
567
-
568
- fs.mkdirSync(migrationPath, { recursive: true });
569
-
570
- // Copy user files to backup (excluding autoconfig-installed files)
571
- for (const entry of userEntries) {
572
- const srcPath = path.join(claudeDest, entry);
573
- const destPath = path.join(migrationPath, entry);
574
-
575
- if (fs.statSync(srcPath).isDirectory()) {
576
- copyDirForBackup(srcPath, destPath);
577
- } else {
578
- fs.copyFileSync(srcPath, destPath);
579
- }
580
- }
581
-
582
- // Collect backed up files for metadata
583
- const backedUpFiles = collectFiles(migrationPath);
584
-
585
- if (backedUpFiles.length > 0) {
586
- // Write latest.json for the guide
587
- fs.writeFileSync(path.join(migrationDir, 'latest.json'), JSON.stringify({
588
- timestamp: timestamp,
589
- backedUpFiles: backedUpFiles
590
- }, null, 2));
591
-
592
- // Create README inside the dated backup folder
593
- const backupReadme = `# Migration Backup: ${timestamp}
594
-
595
- This folder contains a backup of your previous .claude/ configuration.
596
-
597
- ## Why This Backup Exists
598
-
599
- You ran \`npx claude-code-autoconfig\` on a project that already had Claude Code configured.
600
- Your previous files were backed up here before the new configuration was applied.
601
-
602
- ## Backed Up Files
603
-
604
- ${backedUpFiles.map(f => `- ${f}`).join('\n')}
605
-
606
- ## Restoring Files
607
-
608
- To restore any file, copy it from this folder back to \`.claude/\`.
609
-
610
- For example:
611
- \`\`\`bash
612
- cp .claude/migration/${timestamp}/settings.json .claude/settings.json
613
- \`\`\`
614
- `;
615
- fs.writeFileSync(path.join(migrationPath, 'README.md'), backupReadme);
616
-
617
- console.log('\x1b[33m%s\x1b[0m', `⚠️ Backed up existing config to .claude/migration/${timestamp}/`);
618
- } else {
619
- // No user files to backup, remove the empty migration folder
620
- fs.rmdirSync(migrationPath, { recursive: true });
621
- }
622
- }
623
-
624
- // Read previous installed version (before copying overwrites it)
625
- const versionFile = path.join(claudeDest, '.autoconfig-version');
626
- const previousVersion = fs.existsSync(versionFile)
627
- ? fs.readFileSync(versionFile, 'utf8').trim()
628
- : null;
629
- const currentVersion = require(path.join(packageDir, 'package.json')).version;
630
-
631
- // Detect upgrade vs fresh install (must run BEFORE copying files)
632
- const isUpgrade = (() => {
633
- // Indicator 1: CLAUDE.md has autoconfig marker
634
- const claudeMdPath = path.join(cwd, 'CLAUDE.md');
635
- if (fs.existsSync(claudeMdPath)) {
636
- const content = fs.readFileSync(claudeMdPath, 'utf8');
637
- if (content.includes('AUTO-GENERATED BY /autoconfig')) {
638
- console.log('\x1b[90m%s\x1b[0m', ' Upgrade detected: CLAUDE.md has autoconfig marker');
639
- return true;
640
- }
641
- }
642
- // Indicator 2: docs HTML exists (unique autoconfig artifact)
643
- const docsPath = path.join(claudeDest, 'docs', 'autoconfig.docs.html');
644
- if (fs.existsSync(docsPath)) {
645
- console.log('\x1b[90m%s\x1b[0m', ' Upgrade detected: autoconfig.docs.html exists');
646
- return true;
647
- }
648
- console.log('\x1b[90m%s\x1b[0m', ' Install type: fresh (no previous autoconfig found)');
649
- return false;
650
- })();
651
-
652
- // Step 3: Copy minimal bootstrap (commands/, docs/, agents/, feedback/, hooks/)
653
- const commandsSrc = path.join(packageDir, '.claude', 'commands');
654
- const docsSrc = path.join(packageDir, '.claude', 'docs');
655
- const agentsSrc = path.join(packageDir, '.claude', 'agents');
656
- const feedbackSrc = path.join(packageDir, '.claude', 'feedback');
657
- const hooksSrc = path.join(packageDir, '.claude', 'hooks');
658
- const scriptsSrc = path.join(packageDir, '.claude', 'scripts');
659
-
660
- // Files that exist in the dev repo but should never be installed to user projects
661
- const DEV_ONLY_FILES = ['deploy-to-npmjs.md'];
662
-
663
- function copyDir(src, dest) {
664
- fs.mkdirSync(dest, { recursive: true });
665
- const entries = fs.readdirSync(src, { withFileTypes: true });
666
-
667
- for (const entry of entries) {
668
- if (isReservedName(entry.name)) continue;
669
- if (DEV_ONLY_FILES.includes(entry.name)) continue;
670
-
671
- const srcPath = path.join(src, entry.name);
672
- const destPath = path.join(dest, entry.name);
673
-
674
- if (entry.isDirectory()) {
675
- copyDir(srcPath, destPath);
676
- } else {
677
- fs.copyFileSync(srcPath, destPath);
678
- }
679
- }
680
- }
681
-
682
- function copyDirIfMissing(src, dest) {
683
- fs.mkdirSync(dest, { recursive: true });
684
- const entries = fs.readdirSync(src, { withFileTypes: true });
685
- for (const entry of entries) {
686
- if (isReservedName(entry.name)) continue;
687
- if (DEV_ONLY_FILES.includes(entry.name)) continue;
688
- const srcPath = path.join(src, entry.name);
689
- const destPath = path.join(dest, entry.name);
690
- if (entry.isDirectory()) {
691
- copyDirIfMissing(srcPath, destPath);
692
- } else if (!fs.existsSync(destPath)) {
693
- fs.copyFileSync(srcPath, destPath);
694
- }
695
- }
696
- }
697
-
698
- // Parse @version from command file content
699
- function parseCommandVersion(content) {
700
- const match = content.match(/<!-- @version (\d+) -->/);
701
- return match ? parseInt(match[1], 10) : 0;
702
- }
703
-
704
- // Track what commands are new/updated for summary
705
- const commandsDest = path.join(claudeDest, 'commands');
706
- const existingCommandContents = new Map();
707
- if (fs.existsSync(commandsDest)) {
708
- for (const f of fs.readdirSync(commandsDest).filter(f => f.endsWith('.md'))) {
709
- existingCommandContents.set(f, fs.readFileSync(path.join(commandsDest, f), 'utf8'));
710
- }
711
- }
712
-
713
- // Copy commands (required for /autoconfig to work)
714
- // Preserve user's saved @screenshotDir in gls.md across upgrades
715
- const glsDest = path.join(claudeDest, 'commands', 'gls.md');
716
- let savedScreenshotDir = null;
717
- if (fs.existsSync(glsDest)) {
718
- const firstLine = fs.readFileSync(glsDest, 'utf8').split(/\r?\n/)[0];
719
- const match = firstLine.match(/<!-- @screenshotDir (.+?) -->/);
720
- if (match) savedScreenshotDir = match[1].trim();
721
- }
722
-
723
- if (fs.existsSync(commandsSrc)) {
724
- copyDir(commandsSrc, path.join(claudeDest, 'commands'));
725
- } else {
726
- console.log('\x1b[31m%s\x1b[0m', '❌ Error: commands directory not found');
727
- process.exit(1);
728
- }
729
-
730
- // Detect new and updated commands (with version tracking)
731
- const newCommands = [];
732
- const updatedCommands = []; // { file, oldVersion, newVersion }
733
- for (const f of fs.readdirSync(commandsDest).filter(f => f.endsWith('.md') && !DEV_ONLY_FILES.includes(f))) {
734
- const newContent = fs.readFileSync(path.join(commandsDest, f), 'utf8');
735
- if (!existingCommandContents.has(f)) {
736
- newCommands.push({ file: f, version: parseCommandVersion(newContent) });
737
- } else if (newContent !== existingCommandContents.get(f)) {
738
- const oldVersion = parseCommandVersion(existingCommandContents.get(f));
739
- const newVersion = parseCommandVersion(newContent);
740
- updatedCommands.push({ file: f, oldVersion, newVersion });
741
- }
742
- }
743
-
744
- // Restore saved screenshot dir after commands overwrite
745
- if (savedScreenshotDir && fs.existsSync(glsDest)) {
746
- const content = fs.readFileSync(glsDest, 'utf8');
747
- fs.writeFileSync(glsDest, content.replace(
748
- /<!-- @screenshotDir\s*-->/,
749
- `<!-- @screenshotDir ${savedScreenshotDir} -->`
750
- ));
751
- }
752
-
753
- // Copy docs (only .html files — skip internal planning docs)
754
- if (fs.existsSync(docsSrc)) {
755
- const docsDestDir = path.join(claudeDest, 'docs');
756
- fs.mkdirSync(docsDestDir, { recursive: true });
757
- for (const file of fs.readdirSync(docsSrc)) {
758
- if (file.endsWith('.html')) {
759
- fs.copyFileSync(path.join(docsSrc, file), path.join(docsDestDir, file));
760
- }
761
- }
762
- }
763
-
764
- // Copy agents if exists
765
- if (fs.existsSync(agentsSrc)) {
766
- copyDir(agentsSrc, path.join(claudeDest, 'agents'));
767
- }
768
-
769
- // Copy feedback template (preserve user customizations unless --force)
770
- if (fs.existsSync(feedbackSrc)) {
771
- const copyFn = forceMode ? copyDir : copyDirIfMissing;
772
- copyFn(feedbackSrc, path.join(claudeDest, 'feedback'));
773
- }
774
-
775
- // Copy hooks directory. Genuinely user-authorable hooks are preserved on upgrade
776
- // (copyDirIfMissing), BUT the cca-managed title-hook files are ALWAYS refreshed so bug-fixes
777
- // reach existing installs — without this, copyDirIfMissing leaves stale hooks in place forever
778
- // (same always-overwrite rationale as scripts/ below). --force already overwrites everything.
779
- const MANAGED_HOOKS = ['terminal-title.js', 'terminal-title.directive.md', 'arcade-beeps.js'];
780
- if (fs.existsSync(hooksSrc)) {
781
- const copyFn = forceMode ? copyDir : copyDirIfMissing;
782
- copyFn(hooksSrc, path.join(claudeDest, 'hooks'));
783
- if (!forceMode) {
784
- const hooksDestDir = path.join(claudeDest, 'hooks');
785
- for (const name of MANAGED_HOOKS) {
786
- const src = path.join(hooksSrc, name);
787
- if (fs.existsSync(src)) fs.copyFileSync(src, path.join(hooksDestDir, name));
788
- }
789
- }
790
- }
791
-
792
- // Copy scripts directory (always overwrite — these are utility scripts, not user-customizable)
793
- if (fs.existsSync(scriptsSrc)) {
794
- copyDir(scriptsSrc, path.join(claudeDest, 'scripts'));
795
- }
796
-
797
- // Copy sounds directory (binary status-cue assets for arcade-beeps; always overwrite)
798
- const soundsSrc = path.join(packageDir, '.claude', 'sounds');
799
- if (fs.existsSync(soundsSrc)) {
800
- copyDir(soundsSrc, path.join(claudeDest, 'sounds'));
801
- }
802
-
803
- // Note: updates directory is no longer copied to user projects.
804
- // Update files are only used by --pull-updates (for /autoconfig-update).
805
- // On fresh install, all updates are pre-marked as applied and the content
806
- // is already baked into /autoconfig itself, so the files are unnecessary.
807
-
808
- // Copy settings.json — fresh install gets full copy, upgrades get hooks + permissions merged
809
- const settingsSrc = path.join(packageDir, '.claude', 'settings.json');
810
- const settingsDest = path.join(claudeDest, 'settings.json');
811
- if (fs.existsSync(settingsSrc)) {
812
- if (forceMode || !fs.existsSync(settingsDest)) {
813
- fs.copyFileSync(settingsSrc, settingsDest);
814
- } else {
815
- // Merge hooks and permissions from package into existing settings
816
- try {
817
- const pkgSettings = JSON.parse(fs.readFileSync(settingsSrc, 'utf8'));
818
- const userSettings = JSON.parse(fs.readFileSync(settingsDest, 'utf8'));
819
-
820
- // Additively fold package hooks/env/permissions into the user's settings
821
- // (shared with the plugin installer — see mergeSettingsInto).
822
- mergeSettingsInto(userSettings, pkgSettings);
823
-
824
- fs.writeFileSync(settingsDest, JSON.stringify(userSettings, null, 2));
825
- } catch (err) {
826
- // If merge fails, don't break the install
827
- }
828
- }
829
- }
830
-
831
- console.log('\x1b[32m%s\x1b[0m', '✅ Prepared /autoconfig command');
832
-
833
- // Show what was installed/updated
834
- if (isUpgrade && (newCommands.length > 0 || updatedCommands.length > 0)) {
835
- console.log();
836
- for (const { file, version } of newCommands) {
837
- const name = file.replace('.md', '');
838
- const ver = version > 0 ? ` v${version}` : '';
839
- console.log('\x1b[36m%s\x1b[0m', ` + /${name}${ver} (new)`);
840
- }
841
- for (const { file, oldVersion, newVersion } of updatedCommands) {
842
- if (oldVersion > 0 && newVersion > 0 && oldVersion === newVersion) continue;
843
- const name = file.replace('.md', '');
844
- const ver = (oldVersion > 0 && newVersion > 0) ? ` (v${oldVersion} → v${newVersion})` : ' (updated)';
845
- console.log('\x1b[33m%s\x1b[0m', ` ↑ /${name}${ver}`);
846
- }
847
- }
848
-
849
-
850
- // Pre-mark all bundled updates as applied when the @applied block is empty.
851
- // On fresh installs, /autoconfig handles their content (e.g., debug methodology in MEMORY.md).
852
- // On upgrades from pre-update-system versions, these updates are already baked in.
853
- // The regex only matches an empty @applied block, so this is safe to run unconditionally.
854
- {
855
- const userCmdPath = path.join(claudeDest, 'commands', 'autoconfig-update.md');
856
- const packageUpdatesDir = path.join(packageDir, '.claude', 'updates');
857
- if (fs.existsSync(userCmdPath) && fs.existsSync(packageUpdatesDir)) {
858
- const updateFiles = fs.readdirSync(packageUpdatesDir)
859
- .filter(f => f.endsWith('.md') && /^\d{3}-/.test(f))
860
- .sort();
861
- if (updateFiles.length > 0) {
862
- const appliedLines = updateFiles.map(file => {
863
- const id = file.match(/^(\d{3})-/)[1];
864
- const content = fs.readFileSync(path.join(packageUpdatesDir, file), 'utf8');
865
- const titleMatch = content.match(/<!-- @title (.+?) -->/);
866
- const title = titleMatch ? titleMatch[1] : file.replace(/^\d{3}-/, '').replace(/\.md$/, '');
867
- return `${id} - ${title}`;
868
- });
869
- const cmdContent = fs.readFileSync(userCmdPath, 'utf8');
870
- const updated = cmdContent.replace(
871
- /<!-- @applied\r?\n-->/,
872
- `<!-- @applied\n${appliedLines.join('\n')}\n-->`
873
- );
874
- fs.writeFileSync(userCmdPath, updated);
875
- }
876
- }
877
- }
878
-
879
- // Clean up updates directory — updates are tracked in the @applied block,
880
- // so the .md files don't need to stay in the user's project
881
- const userUpdatesDir = path.join(claudeDest, 'updates');
882
- if (fs.existsSync(userUpdatesDir)) {
883
- fs.rmSync(userUpdatesDir, { recursive: true });
884
- }
885
-
886
- // Migrate FEEDBACK.md content to CLAUDE.md Discoveries section (one-time, on upgrade)
887
- if (isUpgrade) {
888
- const claudeMdPath = path.join(cwd, 'CLAUDE.md');
889
- const feedbackPath = path.join(claudeDest, 'feedback', 'FEEDBACK.md');
890
-
891
- if (fs.existsSync(claudeMdPath) && fs.existsSync(feedbackPath)) {
892
- const feedbackContent = fs.readFileSync(feedbackPath, 'utf8');
893
-
894
- // Extract custom content (everything after the first --- separator following the header)
895
- const feedbackLines = feedbackContent.split(/\r?\n/);
896
- let firstSeparatorIdx = -1;
897
- for (let i = 0; i < feedbackLines.length; i++) {
898
- if (feedbackLines[i].trim() === '---') {
899
- firstSeparatorIdx = i;
900
- break;
901
- }
902
- }
903
-
904
- if (firstSeparatorIdx >= 0) {
905
- const customContent = feedbackLines.slice(firstSeparatorIdx + 1).join('\n').trim();
906
-
907
- // Only migrate if there's custom content and it hasn't already been migrated
908
- const claudeMdContent = fs.readFileSync(claudeMdPath, 'utf8');
909
- const hasDiscoveries = claudeMdContent.includes('## Discoveries');
910
-
911
- if (customContent.length > 0 && !hasDiscoveries) {
912
- // Add Discoveries section to CLAUDE.md
913
- const discoveriesSection = `\n\n## Discoveries\n<!-- Claude: append project-specific learnings, gotchas, and context below. This section persists across /autoconfig runs. -->\n\n${customContent}\n`;
914
- fs.writeFileSync(claudeMdPath, claudeMdContent + discoveriesSection);
915
-
916
- // Reset FEEDBACK.md to clean template
917
- const cleanTemplate = `<!-- @description Human-authored corrections and guidance for Claude. Reserved for team feedback only — Claude must not write here. This directory persists across /autoconfig runs. -->\n\n# Team Feedback\n\n**This file is for human-authored corrections and guidance only.**\nClaude reads this file but must never write to it. When Claude discovers project context, gotchas, or learnings, it should append to the \`## Discoveries\` section in CLAUDE.md instead.\n\n---\n\n`;
918
- fs.writeFileSync(feedbackPath, cleanTemplate);
919
-
920
- // Count migrated sections
921
- const sectionCount = (customContent.match(/^## /gm) || []).length || 1;
922
- console.log('\x1b[36m%s\x1b[0m', ` 📋 Migrated ${sectionCount} section${sectionCount > 1 ? 's' : ''} from FEEDBACK.md → CLAUDE.md Discoveries`);
923
- }
924
- }
925
- }
926
- }
927
-
928
- // Write current version marker
929
- fs.writeFileSync(versionFile, currentVersion);
930
-
931
- const launchCommand = isUpgrade ? '/autoconfig-update' : '/autoconfig';
932
-
933
- // --bootstrap: copy files only, exit silently (used by /autoconfig inside Claude)
934
- const bootstrapMode = process.argv.includes('--bootstrap');
935
- if (bootstrapMode) {
936
- process.exit(0);
937
- }
938
-
939
- // Step 4: Show "READY" message
940
- console.log();
941
- if (isUpgrade) {
942
- console.log('\x1b[33m╔════════════════════════════════════════════╗\x1b[0m');
943
- console.log('\x1b[33m║ ║\x1b[0m');
944
- console.log('\x1b[33m║\x1b[0m \x1b[33;1mREADY TO UPDATE\x1b[0m \x1b[33m║\x1b[0m');
945
- console.log('\x1b[33m║ ║\x1b[0m');
946
- console.log('\x1b[33m║\x1b[0m \x1b[36mPress ENTER to launch Claude and\x1b[0m \x1b[33m║\x1b[0m');
947
- console.log('\x1b[33m║\x1b[0m \x1b[36mauto-run /autoconfig-update\x1b[0m \x1b[33m║\x1b[0m');
948
- console.log('\x1b[33m║ ║\x1b[0m');
949
- console.log('\x1b[33m╚════════════════════════════════════════════╝\x1b[0m');
950
- } else {
951
- console.log('\x1b[33m╔════════════════════════════════════════════╗\x1b[0m');
952
- console.log('\x1b[33m║ ║\x1b[0m');
953
- console.log('\x1b[33m║\x1b[0m \x1b[33;1mREADY TO CONFIGURE\x1b[0m \x1b[33m║\x1b[0m');
954
- console.log('\x1b[33m║ ║\x1b[0m');
955
- console.log('\x1b[33m║\x1b[0m \x1b[36mPress ENTER to launch Claude and\x1b[0m \x1b[33m║\x1b[0m');
956
- console.log('\x1b[33m║\x1b[0m \x1b[36mauto-run /autoconfig\x1b[0m \x1b[33m║\x1b[0m');
957
- console.log('\x1b[33m║ ║\x1b[0m');
958
- console.log('\x1b[33m╚════════════════════════════════════════════╝\x1b[0m');
959
- }
960
- // Show what changed on the upgrade path so a re-run never looks like "nothing came down":
961
- // grouped features/fixes since the installed version, or a single confirmation line when
962
- // already on the latest. Rendered here so it lands right before the ENTER prompt.
963
- // Logic lives in update-summary.js (pure + unit-tested).
964
- if (isUpgrade) {
965
- const changelogPath = path.join(packageDir, 'CHANGELOG.md');
966
- const changelogText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
967
- console.log();
968
- for (const seg of formatUpdateSummary(previousVersion, currentVersion, changelogText)) {
969
- if (seg.kind === 'latest') console.log(`\x1b[32m ✓ ${seg.text}\x1b[0m`);
970
- else if (seg.kind === 'heading') console.log(`\x1b[36m ${seg.text}\x1b[0m`);
971
- else if (seg.kind === 'group') console.log(`\x1b[33m ${seg.text}:\x1b[0m`);
972
- else if (seg.kind === 'item') console.log(`\x1b[90m • ${seg.text}\x1b[0m`);
973
- else if (seg.kind === 'more') console.log(`\x1b[90m ${seg.text}\x1b[0m`);
974
- }
975
- console.log();
976
- }
977
- if (!isUpgrade) {
978
- console.log('\x1b[90m%s\x1b[0m', "You'll need to approve a few file prompts to complete the installation.");
979
- console.log();
980
- }
981
-
982
- // Step 5: Wait for Enter, then launch Claude Code
983
- const rl = readline.createInterface({
984
- input: process.stdin,
985
- output: process.stdout
986
- });
987
-
988
- rl.question('\x1b[90mPress ENTER to continue...\x1b[0m', () => {
989
- rl.close();
990
-
991
- console.log();
992
- console.log('\x1b[36m%s\x1b[0m', `🚀 Launching Claude Code with ${launchCommand}...`);
993
- console.log();
994
- console.log('\x1b[90m%s\x1b[0m', ' Heads up: Claude Code can take 30+ seconds to initialize.');
995
- console.log('\x1b[90m%s\x1b[0m', ' Please be patient while it loads.');
996
- console.log();
997
-
998
- // Spawn claude with the appropriate command
999
- const claude = spawn('claude', [launchCommand], {
1000
- cwd: cwd,
1001
- stdio: 'inherit',
1002
- shell: true
1003
- });
1004
-
1005
- claude.on('error', (err) => {
1006
- console.log('\x1b[31m%s\x1b[0m', '❌ Failed to launch Claude Code');
1007
- console.log(` Run "claude" manually, then run ${launchCommand}`);
1008
- });
1009
-
1010
- // Cleanup when Claude exits
1011
- claude.on('close', () => {
1012
- cleanupNulFile();
1013
- });
1014
- });
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const { execSync, spawn } = require('child_process');
7
+ const { formatUpdateSummary } = require('./update-summary.js');
8
+
9
+ const cwd = process.cwd();
10
+ const packageDir = path.dirname(__dirname);
11
+
12
+ // Cleanup any stray 'nul' file immediately on startup (Windows /dev/null artifact)
13
+ function cleanupNulFile() {
14
+ const nulFile = path.join(cwd, 'nul');
15
+ if (fs.existsSync(nulFile)) {
16
+ try {
17
+ fs.unlinkSync(nulFile);
18
+ } catch (e) {
19
+ // Ignore - file might be locked
20
+ }
21
+ }
22
+ }
23
+ cleanupNulFile();
24
+
25
+ // Reserved Windows device names - never create files with these names
26
+ const WINDOWS_RESERVED = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4',
27
+ 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5',
28
+ 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
29
+
30
+ // Files/folders installed by autoconfig - don't backup these
31
+ const AUTOCONFIG_FILES = ['commands', 'docs', 'agents', 'migration', 'hooks', 'scripts', 'sounds', 'rules', 'feedback', 'settings.json', 'settings.local.json', '.mcp.json', '.autoconfig-version', '.autoconfig-plugins.json'];
32
+
33
+ function isReservedName(name) {
34
+ const baseName = name.replace(/\.[^.]*$/, '').toUpperCase();
35
+ return WINDOWS_RESERVED.includes(baseName);
36
+ }
37
+
38
+ function hasUserContent(claudeDir) {
39
+ // Check if .claude/ has any files beyond what autoconfig installs
40
+ if (!fs.existsSync(claudeDir)) return false;
41
+
42
+ const entries = fs.readdirSync(claudeDir);
43
+ for (const entry of entries) {
44
+ if (!AUTOCONFIG_FILES.includes(entry)) {
45
+ // Found something that's not from autoconfig
46
+ return true;
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function formatTimestamp() {
53
+ const now = new Date();
54
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
55
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
56
+ const month = months[now.getMonth()];
57
+ const day = now.getDate();
58
+ const year = now.getFullYear();
59
+ const hour = now.getHours();
60
+ const min = String(now.getMinutes()).padStart(2, '0');
61
+ const ampm = hour >= 12 ? 'pm' : 'am';
62
+ const hour12 = hour % 12 || 12;
63
+
64
+ return `${month}-${day}-${year}_${hour12}-${min}${ampm}`;
65
+ }
66
+
67
+ // --pull-updates: Copy new update files from package to user's project
68
+ function parseAppliedUpdates(filePath) {
69
+ if (!fs.existsSync(filePath)) return [];
70
+ const content = fs.readFileSync(filePath, 'utf8');
71
+ const match = content.match(/<!-- @applied\r?\n([\s\S]*?)-->/);
72
+ if (!match) return [];
73
+
74
+ return match[1].trim().split('\n')
75
+ .filter(line => line.trim())
76
+ .map(line => {
77
+ const idMatch = line.match(/^(\d{3})/);
78
+ return idMatch ? parseInt(idMatch[1], 10) : 0;
79
+ })
80
+ .filter(id => id > 0);
81
+ }
82
+
83
+ function getHighestAppliedId(appliedIds) {
84
+ return appliedIds.length > 0 ? Math.max(...appliedIds) : 0;
85
+ }
86
+
87
+ function pullUpdates() {
88
+ console.log('\x1b[36m%s\x1b[0m', '🔄 Checking for updates...');
89
+ console.log();
90
+
91
+ const userCmdPath = path.join(cwd, '.claude', 'commands', 'autoconfig-update.md');
92
+ const packageCmdPath = path.join(packageDir, '.claude', 'commands', 'autoconfig-update.md');
93
+ const packageUpdatesDir = path.join(packageDir, '.claude', 'updates');
94
+ const userUpdatesDir = path.join(cwd, '.claude', 'updates');
95
+
96
+ // Ensure .claude/commands/ exists
97
+ fs.mkdirSync(path.join(cwd, '.claude', 'commands'), { recursive: true });
98
+
99
+ // Refresh autoconfig-update.md (preserve user's @applied block)
100
+ if (fs.existsSync(packageCmdPath)) {
101
+ if (fs.existsSync(userCmdPath)) {
102
+ const userContent = fs.readFileSync(userCmdPath, 'utf8');
103
+ const packageContent = fs.readFileSync(packageCmdPath, 'utf8');
104
+ const userApplied = userContent.match(/<!-- @applied[\s\S]*?-->/);
105
+ if (userApplied) {
106
+ const merged = packageContent.replace(/<!-- @applied[\s\S]*?-->/, userApplied[0]);
107
+ fs.writeFileSync(userCmdPath, merged);
108
+ } else {
109
+ fs.copyFileSync(packageCmdPath, userCmdPath);
110
+ }
111
+ } else {
112
+ fs.copyFileSync(packageCmdPath, userCmdPath);
113
+ }
114
+ }
115
+
116
+ // Check for available updates in package
117
+ if (!fs.existsSync(packageUpdatesDir)) {
118
+ console.log('\x1b[32m%s\x1b[0m', '✅ Already up to date');
119
+ return;
120
+ }
121
+
122
+ const appliedIds = parseAppliedUpdates(userCmdPath);
123
+ const highestApplied = getHighestAppliedId(appliedIds);
124
+
125
+ const updateFiles = fs.readdirSync(packageUpdatesDir).filter(f => f.endsWith('.md'));
126
+ const newUpdates = updateFiles.filter(file => {
127
+ const match = file.match(/^(\d{3})-/);
128
+ if (!match) return false;
129
+ return parseInt(match[1], 10) > highestApplied;
130
+ });
131
+
132
+ if (newUpdates.length === 0) {
133
+ console.log('\x1b[32m%s\x1b[0m', '✅ Already up to date');
134
+ return;
135
+ }
136
+
137
+ // Copy new update files
138
+ fs.mkdirSync(userUpdatesDir, { recursive: true });
139
+ for (const file of newUpdates) {
140
+ fs.copyFileSync(
141
+ path.join(packageUpdatesDir, file),
142
+ path.join(userUpdatesDir, file)
143
+ );
144
+ }
145
+
146
+ console.log('\x1b[32m%s\x1b[0m', `✅ Copied ${newUpdates.length} new update${newUpdates.length > 1 ? 's' : ''} to .claude/updates/`);
147
+ console.log();
148
+ console.log('Run \x1b[36mclaude /autoconfig-update\x1b[0m to review and install updates.');
149
+ }
150
+
151
+ if (process.argv.includes('--pull-updates')) {
152
+ pullUpdates();
153
+ process.exit(0);
154
+ }
155
+
156
+ // ============================================================================
157
+ // Settings merge helpers (shared by the upgrade path and the plugin installer)
158
+ // ============================================================================
159
+
160
+ // Rewrite legacy cwd-relative hook commands ("node .claude/hooks/X.js") to the
161
+ // ${CLAUDE_PROJECT_DIR:-.}-anchored form IN PLACE, mutating the given settings object.
162
+ // Two reasons, both from the 2026-07-08 stuck-title postmortem:
163
+ // 1. cwd-relative commands go MODULE_NOT_FOUND the moment a session cd's away from the
164
+ // project root every matching tool call then spews a red hook error until cwd returns.
165
+ // 2. mergeSettingsInto dedups by EXACT command string, so without this rewrite an upgrade
166
+ // would ADD the anchored template entry alongside the user's old relative one -> the
167
+ // hook runs twice per event.
168
+ // Any .claude/hooks/*.js relative command is rewritten — anchored resolution is identical at
169
+ // the project root and cd-proof everywhere else — while commands outside .claude/hooks are
170
+ // never touched.
171
+ function migrateLegacyHookCommands(userSettings) {
172
+ if (!userSettings || !userSettings.hooks) return;
173
+ const LEGACY = /^node \.claude\/hooks\/([\w.-]+\.js)$/;
174
+ for (const matchers of Object.values(userSettings.hooks)) {
175
+ if (!Array.isArray(matchers)) continue;
176
+ for (const matcher of matchers) {
177
+ for (const hook of matcher.hooks || []) {
178
+ const m = typeof hook.command === 'string' && hook.command.match(LEGACY);
179
+ if (m) hook.command = 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/' + m[1] + '"';
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ // Additively fold a settings fragment (hooks / env / permissions) into an existing
186
+ // settings object, mutating and returning it.
187
+ // - hooks: add hook commands that don't already exist (dedup by command string, per event)
188
+ // - env: add keys the user hasn't set (never overwrite an existing value)
189
+ // - permissions.allow/deny: add missing rules (and migrate deprecated :* syntax)
190
+ function mergeSettingsInto(userSettings, fragment) {
191
+ if (fragment.hooks) {
192
+ if (!userSettings.hooks) userSettings.hooks = {};
193
+ for (const [event, matchers] of Object.entries(fragment.hooks)) {
194
+ if (!userSettings.hooks[event]) {
195
+ userSettings.hooks[event] = matchers;
196
+ } else {
197
+ // Add any hook commands that don't already exist
198
+ for (const matcher of matchers) {
199
+ for (const hook of matcher.hooks || []) {
200
+ const exists = userSettings.hooks[event].some(m =>
201
+ (m.hooks || []).some(h => h.command === hook.command)
202
+ );
203
+ if (!exists) {
204
+ const existingMatcher = userSettings.hooks[event].find(m => m.matcher === matcher.matcher);
205
+ if (existingMatcher) {
206
+ existingMatcher.hooks = existingMatcher.hooks || [];
207
+ existingMatcher.hooks.push(hook);
208
+ } else {
209
+ userSettings.hooks[event].push(matcher);
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ if (fragment.env) {
219
+ if (!userSettings.env) userSettings.env = {};
220
+ for (const [key, value] of Object.entries(fragment.env)) {
221
+ if (!(key in userSettings.env)) userSettings.env[key] = value;
222
+ }
223
+ }
224
+
225
+ if (fragment.permissions) {
226
+ if (!userSettings.permissions) userSettings.permissions = {};
227
+ for (const key of ['allow', 'deny']) {
228
+ if (!fragment.permissions[key]) continue;
229
+ if (!userSettings.permissions[key]) {
230
+ userSettings.permissions[key] = fragment.permissions[key];
231
+ } else {
232
+ // Migrate deprecated :* syntax to space-* in existing entries
233
+ userSettings.permissions[key] = userSettings.permissions[key].map(rule =>
234
+ rule.replace(/^(Bash\([^)]*):(\*\))$/, '$1 $2')
235
+ );
236
+ for (const rule of fragment.permissions[key]) {
237
+ if (!userSettings.permissions[key].includes(rule)) {
238
+ userSettings.permissions[key].push(rule);
239
+ }
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ return userSettings;
246
+ }
247
+
248
+ // Inverse of mergeSettingsInto: strip a fragment's contributions back out. Only removes
249
+ // what the fragment added (dedup-safe), leaving the user's own entries untouched.
250
+ function unmergeSettingsFrom(userSettings, fragment) {
251
+ if (fragment.hooks && userSettings.hooks) {
252
+ for (const [event, matchers] of Object.entries(fragment.hooks)) {
253
+ if (!userSettings.hooks[event]) continue;
254
+ const commands = new Set();
255
+ for (const matcher of matchers) {
256
+ for (const hook of matcher.hooks || []) {
257
+ if (hook.command) commands.add(hook.command);
258
+ }
259
+ }
260
+ userSettings.hooks[event] = userSettings.hooks[event]
261
+ .map(m => {
262
+ if (m.hooks) m.hooks = m.hooks.filter(h => !commands.has(h.command));
263
+ return m;
264
+ })
265
+ .filter(m => (m.hooks || []).length > 0);
266
+ if (userSettings.hooks[event].length === 0) delete userSettings.hooks[event];
267
+ }
268
+ if (Object.keys(userSettings.hooks).length === 0) delete userSettings.hooks;
269
+ }
270
+
271
+ if (fragment.env && userSettings.env) {
272
+ for (const [key, value] of Object.entries(fragment.env)) {
273
+ if (userSettings.env[key] === value) delete userSettings.env[key];
274
+ }
275
+ if (Object.keys(userSettings.env).length === 0) delete userSettings.env;
276
+ }
277
+
278
+ if (fragment.permissions && userSettings.permissions) {
279
+ for (const key of ['allow', 'deny']) {
280
+ if (!fragment.permissions[key] || !userSettings.permissions[key]) continue;
281
+ const remove = new Set(fragment.permissions[key]);
282
+ userSettings.permissions[key] = userSettings.permissions[key].filter(r => !remove.has(r));
283
+ }
284
+ }
285
+
286
+ return userSettings;
287
+ }
288
+
289
+ // ============================================================================
290
+ // Plugin system: install / remove / list drop-in add-on plugins.
291
+ //
292
+ // A plugin is a folder containing a plugin.json manifest:
293
+ // {
294
+ // "name": "terminal-title",
295
+ // "version": "1.0.0",
296
+ // "description": "...",
297
+ // "files": [ { "from": "hooks/x.js", "to": "hooks/x.js" } ], // "to" is relative to <project>/.claude
298
+ // "settings": { "env": {...}, "hooks": {...}, "permissions": {...} } // folded into .claude/settings.json
299
+ // }
300
+ //
301
+ // Installed plugins are tracked in .claude/.autoconfig-plugins.json so `plugin remove`
302
+ // cleanly undoes both the copied files and the settings contributions. The free core
303
+ // ships only this generic loader paid/closed plugins live and are delivered separately.
304
+ // ============================================================================
305
+
306
+ const PLUGINS_LEDGER = '.autoconfig-plugins.json';
307
+
308
+ function readPluginsLedger(claudeDir) {
309
+ const p = path.join(claudeDir, PLUGINS_LEDGER);
310
+ if (!fs.existsSync(p)) return {};
311
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; }
312
+ }
313
+
314
+ function writePluginsLedger(claudeDir, ledger) {
315
+ fs.mkdirSync(claudeDir, { recursive: true });
316
+ fs.writeFileSync(path.join(claudeDir, PLUGINS_LEDGER), JSON.stringify(ledger, null, 2));
317
+ }
318
+
319
+ function loadManifest(pluginDir) {
320
+ const manifestPath = path.join(pluginDir, 'plugin.json');
321
+ if (!fs.existsSync(manifestPath)) throw new Error(`no plugin.json found in ${pluginDir}`);
322
+ let manifest;
323
+ try {
324
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
325
+ } catch (e) {
326
+ throw new Error(`plugin.json is not valid JSON: ${e.message}`);
327
+ }
328
+ if (!manifest.name || typeof manifest.name !== 'string') {
329
+ throw new Error('plugin.json must declare a string "name"');
330
+ }
331
+ if (manifest.files && !Array.isArray(manifest.files)) {
332
+ throw new Error('plugin.json "files" must be an array');
333
+ }
334
+ if (!manifest.files) manifest.files = [];
335
+ return manifest;
336
+ }
337
+
338
+ function pluginAdd(pluginArg, claudeDir) {
339
+ const pluginDir = path.resolve(cwd, pluginArg);
340
+ const manifest = loadManifest(pluginDir);
341
+ console.log('\x1b[36m%s\x1b[0m', `📦 Installing plugin: ${manifest.name}${manifest.version ? ' v' + manifest.version : ''}`);
342
+
343
+ // 1. Copy declared files into <project>/.claude/<to>
344
+ const installedFiles = [];
345
+ for (const file of manifest.files) {
346
+ if (!file || !file.from || !file.to) throw new Error('each "files" entry must have "from" and "to"');
347
+ const src = path.resolve(pluginDir, file.from);
348
+ if (!fs.existsSync(src)) throw new Error(`plugin file not found: ${file.from}`);
349
+ const dest = path.join(claudeDir, file.to);
350
+ if (isReservedName(path.basename(dest))) throw new Error(`refusing to write reserved filename: ${file.to}`);
351
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
352
+ fs.copyFileSync(src, dest);
353
+ installedFiles.push(file.to);
354
+ console.log('\x1b[90m%s\x1b[0m', ` + .claude/${file.to}`);
355
+ }
356
+
357
+ // 2. Fold the settings fragment into .claude/settings.json (clone first to avoid aliasing the ledger copy)
358
+ if (manifest.settings) {
359
+ const settingsPath = path.join(claudeDir, 'settings.json');
360
+ let userSettings = {};
361
+ if (fs.existsSync(settingsPath)) {
362
+ try { userSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { userSettings = {}; }
363
+ }
364
+ mergeSettingsInto(userSettings, JSON.parse(JSON.stringify(manifest.settings)));
365
+ fs.mkdirSync(claudeDir, { recursive: true });
366
+ fs.writeFileSync(settingsPath, JSON.stringify(userSettings, null, 2));
367
+ console.log('\x1b[90m%s\x1b[0m', ' ✎ merged settings.json (hooks / env / permissions)');
368
+ }
369
+
370
+ // 3. Record in the ledger so removal can cleanly undo everything
371
+ const ledger = readPluginsLedger(claudeDir);
372
+ ledger[manifest.name] = {
373
+ version: manifest.version || null,
374
+ files: installedFiles,
375
+ settings: manifest.settings || null,
376
+ installedAt: new Date().toISOString()
377
+ };
378
+ writePluginsLedger(claudeDir, ledger);
379
+ console.log('\x1b[32m%s\x1b[0m', `✅ Installed ${manifest.name}`);
380
+ }
381
+
382
+ function pluginRemove(name, claudeDir) {
383
+ const ledger = readPluginsLedger(claudeDir);
384
+ const entry = ledger[name];
385
+ if (!entry) throw new Error(`plugin "${name}" is not installed`);
386
+ console.log('\x1b[36m%s\x1b[0m', `🗑 Removing plugin: ${name}`);
387
+
388
+ // 1. Delete the files the plugin installed
389
+ for (const rel of entry.files || []) {
390
+ const p = path.join(claudeDir, rel);
391
+ if (fs.existsSync(p)) {
392
+ fs.rmSync(p, { force: true });
393
+ console.log('\x1b[90m%s\x1b[0m', ` - .claude/${rel}`);
394
+ }
395
+ }
396
+
397
+ // 2. Revert the settings contributions
398
+ if (entry.settings) {
399
+ const settingsPath = path.join(claudeDir, 'settings.json');
400
+ if (fs.existsSync(settingsPath)) {
401
+ try {
402
+ const userSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
403
+ unmergeSettingsFrom(userSettings, entry.settings);
404
+ fs.writeFileSync(settingsPath, JSON.stringify(userSettings, null, 2));
405
+ console.log('\x1b[90m%s\x1b[0m', ' ✎ reverted settings.json contributions');
406
+ } catch { /* leave settings intact if unparsable */ }
407
+ }
408
+ }
409
+
410
+ // 3. Drop it from the ledger
411
+ delete ledger[name];
412
+ writePluginsLedger(claudeDir, ledger);
413
+ console.log('\x1b[32m%s\x1b[0m', `✅ Removed ${name}`);
414
+ }
415
+
416
+ function pluginList(claudeDir) {
417
+ const ledger = readPluginsLedger(claudeDir);
418
+ const names = Object.keys(ledger);
419
+ if (names.length === 0) {
420
+ console.log('\x1b[90m%s\x1b[0m', 'No plugins installed.');
421
+ return;
422
+ }
423
+ console.log('\x1b[36m%s\x1b[0m', 'Installed plugins:');
424
+ for (const name of names) {
425
+ const e = ledger[name];
426
+ const n = (e.files || []).length;
427
+ console.log(` • ${name}${e.version ? ' v' + e.version : ''} (${n} file${n === 1 ? '' : 's'})`);
428
+ }
429
+ }
430
+
431
+ function runPluginCommand(argv) {
432
+ const sub = argv[3];
433
+ const arg = argv[4];
434
+ const claudeDir = path.join(cwd, '.claude');
435
+ try {
436
+ if (sub === 'add' || sub === 'install') {
437
+ if (!arg) throw new Error('usage: claude-code-autoconfig plugin add <path-to-plugin-dir>');
438
+ pluginAdd(arg, claudeDir);
439
+ } else if (sub === 'remove' || sub === 'rm' || sub === 'uninstall') {
440
+ if (!arg) throw new Error('usage: claude-code-autoconfig plugin remove <name>');
441
+ pluginRemove(arg, claudeDir);
442
+ } else if (sub === 'list' || sub === 'ls') {
443
+ pluginList(claudeDir);
444
+ } else {
445
+ console.log('Usage:');
446
+ console.log(' claude-code-autoconfig plugin add <dir> Install a plugin from a folder');
447
+ console.log(' claude-code-autoconfig plugin remove <name> Uninstall a plugin');
448
+ console.log(' claude-code-autoconfig plugin list List installed plugins');
449
+ process.exit(sub ? 1 : 0);
450
+ }
451
+ } catch (err) {
452
+ console.log('\x1b[31m%s\x1b[0m', `❌ ${err.message}`);
453
+ process.exit(1);
454
+ }
455
+ }
456
+
457
+ if (process.argv[2] === 'plugin') {
458
+ runPluginCommand(process.argv);
459
+ process.exit(0);
460
+ }
461
+
462
+ const forceMode = process.argv.includes('--force');
463
+
464
+ // Detect if running inside Claude Code session.
465
+ // CLAUDECODE=1 alone is not reliable: env vars inherit into every descendant
466
+ // process (e.g. VS Code launched from a Claude session, and any terminal it
467
+ // spawns). A genuine in-agent run has piped stdio (isTTY falsy), while a human
468
+ // terminal has a real TTY — so require both signals before blocking.
469
+ const insideClaude = process.env.CLAUDECODE === '1' && !process.stdout.isTTY;
470
+
471
+ console.log('\x1b[36m%s\x1b[0m', '🚀 Claude Code Autoconfig');
472
+ console.log();
473
+
474
+ // Block early if running inside Claude Code (unless --bootstrap)
475
+ if (insideClaude && !process.argv.includes('--bootstrap')) {
476
+ console.log('\x1b[31m%s\x1b[0m', ' The tool needs to be run from a regular terminal, not from within Claude Code.');
477
+ console.log();
478
+ console.log(' Open a separate terminal window and run:');
479
+ console.log();
480
+ console.log(' \x1b[36mnpx claude-code-autoconfig@latest\x1b[0m');
481
+ console.log();
482
+ process.exit(0);
483
+ }
484
+
485
+ // Step 1: Check if Claude Code is installed
486
+ function isClaudeInstalled() {
487
+ try {
488
+ execSync('claude --version', { stdio: 'ignore' });
489
+ return true;
490
+ } catch {
491
+ return false;
492
+ }
493
+ }
494
+
495
+ function installClaude() {
496
+ console.log('\x1b[33m%s\x1b[0m', '⚠️ Claude Code not found. Installing...');
497
+ console.log();
498
+ try {
499
+ execSync('npm install -g @anthropic-ai/claude-code', { stdio: 'inherit' });
500
+ console.log();
501
+ console.log('\x1b[32m%s\x1b[0m', '✅ Claude Code installed');
502
+ return true;
503
+ } catch (err) {
504
+ console.log('\x1b[31m%s\x1b[0m', '❌ Failed to install Claude Code');
505
+ console.log(' Install manually: npm install -g @anthropic-ai/claude-code');
506
+ return false;
507
+ }
508
+ }
509
+
510
+ if (!isClaudeInstalled()) {
511
+ if (!installClaude()) {
512
+ process.exit(1);
513
+ }
514
+ }
515
+
516
+ console.log('\x1b[32m%s\x1b[0m', '✅ Claude Code detected');
517
+
518
+ // Step 2: Backup existing .claude/ if it has user content
519
+ const claudeDest = path.join(cwd, '.claude');
520
+ const SKIP_BACKUP = ['migration']; // Don't backup the migration folder itself
521
+ let migrationPath = null;
522
+
523
+ // Diagnostic: log pre-install state
524
+ console.log();
525
+ console.log('\x1b[90m%s\x1b[0m', '── Pre-install state ──');
526
+ console.log('\x1b[90m%s\x1b[0m', ` Working dir: ${cwd}`);
527
+ const claudeMdExists = fs.existsSync(path.join(cwd, 'CLAUDE.md'));
528
+ console.log('\x1b[90m%s\x1b[0m', ` CLAUDE.md: ${claudeMdExists ? 'exists' : 'not found'}`);
529
+ if (claudeMdExists) {
530
+ const claudeMdContent = fs.readFileSync(path.join(cwd, 'CLAUDE.md'), 'utf8');
531
+ const hasMarker = claudeMdContent.includes('AUTO-GENERATED BY /autoconfig');
532
+ console.log('\x1b[90m%s\x1b[0m', ` CLAUDE.md autoconfig marker: ${hasMarker ? 'yes' : 'no'}`);
533
+ }
534
+ if (fs.existsSync(claudeDest)) {
535
+ const entries = fs.readdirSync(claudeDest);
536
+ console.log('\x1b[90m%s\x1b[0m', ` .claude/ exists: yes (${entries.length} entries)`);
537
+ for (const e of entries) {
538
+ const isAutoconfig = AUTOCONFIG_FILES.includes(e);
539
+ console.log('\x1b[90m%s\x1b[0m', ` ${isAutoconfig ? '·' : '▸'} ${e}${isAutoconfig ? '' : ' (user content)'}`);
540
+ }
541
+ const docsHtml = path.join(claudeDest, 'docs', 'autoconfig.docs.html');
542
+ console.log('\x1b[90m%s\x1b[0m', ` autoconfig.docs.html: ${fs.existsSync(docsHtml) ? 'exists' : 'not found'}`);
543
+ } else {
544
+ console.log('\x1b[90m%s\x1b[0m', ' .claude/ exists: no');
545
+ }
546
+ console.log('\x1b[90m%s\x1b[0m', '───────────────────────');
547
+ console.log();
548
+
549
+ function copyDirForBackup(src, dest) {
550
+ fs.mkdirSync(dest, { recursive: true });
551
+ const entries = fs.readdirSync(src, { withFileTypes: true });
552
+
553
+ for (const entry of entries) {
554
+ if (SKIP_BACKUP.includes(entry.name)) continue;
555
+ if (AUTOCONFIG_FILES.includes(entry.name)) continue; // Skip autoconfig-installed files
556
+ if (isReservedName(entry.name)) continue;
557
+
558
+ const srcPath = path.join(src, entry.name);
559
+ const destPath = path.join(dest, entry.name);
560
+
561
+ if (entry.isDirectory()) {
562
+ copyDirForBackup(srcPath, destPath);
563
+ } else {
564
+ fs.copyFileSync(srcPath, destPath);
565
+ }
566
+ }
567
+ }
568
+
569
+ function collectFiles(dir, prefix = '') {
570
+ const files = [];
571
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
572
+ for (const entry of entries) {
573
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
574
+ if (entry.isDirectory()) {
575
+ files.push(...collectFiles(path.join(dir, entry.name), relPath));
576
+ } else {
577
+ files.push(relPath);
578
+ }
579
+ }
580
+ return files;
581
+ }
582
+
583
+ if (fs.existsSync(claudeDest) && hasUserContent(claudeDest)) {
584
+ const userEntries = fs.readdirSync(claudeDest).filter(e =>
585
+ e !== 'migration' && !AUTOCONFIG_FILES.includes(e)
586
+ );
587
+ console.log('\x1b[90m%s\x1b[0m', ` Backup triggered by user content: ${userEntries.join(', ')}`);
588
+
589
+ const timestamp = formatTimestamp();
590
+ const migrationDir = path.join(claudeDest, 'migration');
591
+ migrationPath = path.join(migrationDir, timestamp);
592
+
593
+ fs.mkdirSync(migrationPath, { recursive: true });
594
+
595
+ // Copy user files to backup (excluding autoconfig-installed files)
596
+ for (const entry of userEntries) {
597
+ const srcPath = path.join(claudeDest, entry);
598
+ const destPath = path.join(migrationPath, entry);
599
+
600
+ if (fs.statSync(srcPath).isDirectory()) {
601
+ copyDirForBackup(srcPath, destPath);
602
+ } else {
603
+ fs.copyFileSync(srcPath, destPath);
604
+ }
605
+ }
606
+
607
+ // Collect backed up files for metadata
608
+ const backedUpFiles = collectFiles(migrationPath);
609
+
610
+ if (backedUpFiles.length > 0) {
611
+ // Write latest.json for the guide
612
+ fs.writeFileSync(path.join(migrationDir, 'latest.json'), JSON.stringify({
613
+ timestamp: timestamp,
614
+ backedUpFiles: backedUpFiles
615
+ }, null, 2));
616
+
617
+ // Create README inside the dated backup folder
618
+ const backupReadme = `# Migration Backup: ${timestamp}
619
+
620
+ This folder contains a backup of your previous .claude/ configuration.
621
+
622
+ ## Why This Backup Exists
623
+
624
+ You ran \`npx claude-code-autoconfig\` on a project that already had Claude Code configured.
625
+ Your previous files were backed up here before the new configuration was applied.
626
+
627
+ ## Backed Up Files
628
+
629
+ ${backedUpFiles.map(f => `- ${f}`).join('\n')}
630
+
631
+ ## Restoring Files
632
+
633
+ To restore any file, copy it from this folder back to \`.claude/\`.
634
+
635
+ For example:
636
+ \`\`\`bash
637
+ cp .claude/migration/${timestamp}/settings.json .claude/settings.json
638
+ \`\`\`
639
+ `;
640
+ fs.writeFileSync(path.join(migrationPath, 'README.md'), backupReadme);
641
+
642
+ console.log('\x1b[33m%s\x1b[0m', `⚠️ Backed up existing config to .claude/migration/${timestamp}/`);
643
+ } else {
644
+ // No user files to backup, remove the empty migration folder
645
+ fs.rmdirSync(migrationPath, { recursive: true });
646
+ }
647
+ }
648
+
649
+ // Read previous installed version (before copying overwrites it)
650
+ const versionFile = path.join(claudeDest, '.autoconfig-version');
651
+ const previousVersion = fs.existsSync(versionFile)
652
+ ? fs.readFileSync(versionFile, 'utf8').trim()
653
+ : null;
654
+ const currentVersion = require(path.join(packageDir, 'package.json')).version;
655
+
656
+ // Detect upgrade vs fresh install (must run BEFORE copying files)
657
+ const isUpgrade = (() => {
658
+ // Indicator 1: CLAUDE.md has autoconfig marker
659
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
660
+ if (fs.existsSync(claudeMdPath)) {
661
+ const content = fs.readFileSync(claudeMdPath, 'utf8');
662
+ if (content.includes('AUTO-GENERATED BY /autoconfig')) {
663
+ console.log('\x1b[90m%s\x1b[0m', ' Upgrade detected: CLAUDE.md has autoconfig marker');
664
+ return true;
665
+ }
666
+ }
667
+ // Indicator 2: docs HTML exists (unique autoconfig artifact)
668
+ const docsPath = path.join(claudeDest, 'docs', 'autoconfig.docs.html');
669
+ if (fs.existsSync(docsPath)) {
670
+ console.log('\x1b[90m%s\x1b[0m', ' Upgrade detected: autoconfig.docs.html exists');
671
+ return true;
672
+ }
673
+ console.log('\x1b[90m%s\x1b[0m', ' Install type: fresh (no previous autoconfig found)');
674
+ return false;
675
+ })();
676
+
677
+ // Step 3: Copy minimal bootstrap (commands/, docs/, agents/, feedback/, hooks/)
678
+ const commandsSrc = path.join(packageDir, '.claude', 'commands');
679
+ const docsSrc = path.join(packageDir, '.claude', 'docs');
680
+ const agentsSrc = path.join(packageDir, '.claude', 'agents');
681
+ const feedbackSrc = path.join(packageDir, '.claude', 'feedback');
682
+ const hooksSrc = path.join(packageDir, '.claude', 'hooks');
683
+ const scriptsSrc = path.join(packageDir, '.claude', 'scripts');
684
+
685
+ // Files that exist in the dev repo but should never be installed to user projects
686
+ const DEV_ONLY_FILES = ['deploy-to-npmjs.md'];
687
+
688
+ function copyDir(src, dest) {
689
+ fs.mkdirSync(dest, { recursive: true });
690
+ const entries = fs.readdirSync(src, { withFileTypes: true });
691
+
692
+ for (const entry of entries) {
693
+ if (isReservedName(entry.name)) continue;
694
+ if (DEV_ONLY_FILES.includes(entry.name)) continue;
695
+
696
+ const srcPath = path.join(src, entry.name);
697
+ const destPath = path.join(dest, entry.name);
698
+
699
+ if (entry.isDirectory()) {
700
+ copyDir(srcPath, destPath);
701
+ } else {
702
+ fs.copyFileSync(srcPath, destPath);
703
+ }
704
+ }
705
+ }
706
+
707
+ function copyDirIfMissing(src, dest) {
708
+ fs.mkdirSync(dest, { recursive: true });
709
+ const entries = fs.readdirSync(src, { withFileTypes: true });
710
+ for (const entry of entries) {
711
+ if (isReservedName(entry.name)) continue;
712
+ if (DEV_ONLY_FILES.includes(entry.name)) continue;
713
+ const srcPath = path.join(src, entry.name);
714
+ const destPath = path.join(dest, entry.name);
715
+ if (entry.isDirectory()) {
716
+ copyDirIfMissing(srcPath, destPath);
717
+ } else if (!fs.existsSync(destPath)) {
718
+ fs.copyFileSync(srcPath, destPath);
719
+ }
720
+ }
721
+ }
722
+
723
+ // Parse @version from command file content
724
+ function parseCommandVersion(content) {
725
+ const match = content.match(/<!-- @version (\d+) -->/);
726
+ return match ? parseInt(match[1], 10) : 0;
727
+ }
728
+
729
+ // Track what commands are new/updated for summary
730
+ const commandsDest = path.join(claudeDest, 'commands');
731
+ const existingCommandContents = new Map();
732
+ if (fs.existsSync(commandsDest)) {
733
+ for (const f of fs.readdirSync(commandsDest).filter(f => f.endsWith('.md'))) {
734
+ existingCommandContents.set(f, fs.readFileSync(path.join(commandsDest, f), 'utf8'));
735
+ }
736
+ }
737
+
738
+ // Copy commands (required for /autoconfig to work)
739
+ // Preserve user's saved @screenshotDir in gls.md across upgrades
740
+ const glsDest = path.join(claudeDest, 'commands', 'gls.md');
741
+ let savedScreenshotDir = null;
742
+ if (fs.existsSync(glsDest)) {
743
+ const firstLine = fs.readFileSync(glsDest, 'utf8').split(/\r?\n/)[0];
744
+ const match = firstLine.match(/<!-- @screenshotDir (.+?) -->/);
745
+ if (match) savedScreenshotDir = match[1].trim();
746
+ }
747
+
748
+ if (fs.existsSync(commandsSrc)) {
749
+ copyDir(commandsSrc, path.join(claudeDest, 'commands'));
750
+ } else {
751
+ console.log('\x1b[31m%s\x1b[0m', '❌ Error: commands directory not found');
752
+ process.exit(1);
753
+ }
754
+
755
+ // Detect new and updated commands (with version tracking)
756
+ const newCommands = [];
757
+ const updatedCommands = []; // { file, oldVersion, newVersion }
758
+ for (const f of fs.readdirSync(commandsDest).filter(f => f.endsWith('.md') && !DEV_ONLY_FILES.includes(f))) {
759
+ const newContent = fs.readFileSync(path.join(commandsDest, f), 'utf8');
760
+ if (!existingCommandContents.has(f)) {
761
+ newCommands.push({ file: f, version: parseCommandVersion(newContent) });
762
+ } else if (newContent !== existingCommandContents.get(f)) {
763
+ const oldVersion = parseCommandVersion(existingCommandContents.get(f));
764
+ const newVersion = parseCommandVersion(newContent);
765
+ updatedCommands.push({ file: f, oldVersion, newVersion });
766
+ }
767
+ }
768
+
769
+ // Restore saved screenshot dir after commands overwrite
770
+ if (savedScreenshotDir && fs.existsSync(glsDest)) {
771
+ const content = fs.readFileSync(glsDest, 'utf8');
772
+ fs.writeFileSync(glsDest, content.replace(
773
+ /<!-- @screenshotDir\s*-->/,
774
+ `<!-- @screenshotDir ${savedScreenshotDir} -->`
775
+ ));
776
+ }
777
+
778
+ // Copy docs (only .html files skip internal planning docs)
779
+ if (fs.existsSync(docsSrc)) {
780
+ const docsDestDir = path.join(claudeDest, 'docs');
781
+ fs.mkdirSync(docsDestDir, { recursive: true });
782
+ for (const file of fs.readdirSync(docsSrc)) {
783
+ if (file.endsWith('.html')) {
784
+ fs.copyFileSync(path.join(docsSrc, file), path.join(docsDestDir, file));
785
+ }
786
+ }
787
+ }
788
+
789
+ // Copy agents if exists
790
+ if (fs.existsSync(agentsSrc)) {
791
+ copyDir(agentsSrc, path.join(claudeDest, 'agents'));
792
+ }
793
+
794
+ // Copy feedback template (preserve user customizations unless --force)
795
+ if (fs.existsSync(feedbackSrc)) {
796
+ const copyFn = forceMode ? copyDir : copyDirIfMissing;
797
+ copyFn(feedbackSrc, path.join(claudeDest, 'feedback'));
798
+ }
799
+
800
+ // Copy hooks directory. Genuinely user-authorable hooks are preserved on upgrade
801
+ // (copyDirIfMissing), BUT the cca-managed title-hook files are ALWAYS refreshed so bug-fixes
802
+ // reach existing installs — without this, copyDirIfMissing leaves stale hooks in place forever
803
+ // (same always-overwrite rationale as scripts/ below). --force already overwrites everything.
804
+ const MANAGED_HOOKS = ['terminal-title.js', 'terminal-title.directive.md', 'arcade-beeps.js'];
805
+ if (fs.existsSync(hooksSrc)) {
806
+ const copyFn = forceMode ? copyDir : copyDirIfMissing;
807
+ copyFn(hooksSrc, path.join(claudeDest, 'hooks'));
808
+ if (!forceMode) {
809
+ const hooksDestDir = path.join(claudeDest, 'hooks');
810
+ for (const name of MANAGED_HOOKS) {
811
+ const src = path.join(hooksSrc, name);
812
+ if (fs.existsSync(src)) fs.copyFileSync(src, path.join(hooksDestDir, name));
813
+ }
814
+ }
815
+ }
816
+
817
+ // Copy scripts directory (always overwrite — these are utility scripts, not user-customizable)
818
+ if (fs.existsSync(scriptsSrc)) {
819
+ copyDir(scriptsSrc, path.join(claudeDest, 'scripts'));
820
+ }
821
+
822
+ // Copy sounds directory (binary status-cue assets for arcade-beeps; always overwrite)
823
+ const soundsSrc = path.join(packageDir, '.claude', 'sounds');
824
+ if (fs.existsSync(soundsSrc)) {
825
+ copyDir(soundsSrc, path.join(claudeDest, 'sounds'));
826
+ }
827
+
828
+ // Note: updates directory is no longer copied to user projects.
829
+ // Update files are only used by --pull-updates (for /autoconfig-update).
830
+ // On fresh install, all updates are pre-marked as applied and the content
831
+ // is already baked into /autoconfig itself, so the files are unnecessary.
832
+
833
+ // Copy settings.json fresh install gets full copy, upgrades get hooks + permissions merged
834
+ const settingsSrc = path.join(packageDir, '.claude', 'settings.json');
835
+ const settingsDest = path.join(claudeDest, 'settings.json');
836
+ if (fs.existsSync(settingsSrc)) {
837
+ if (forceMode || !fs.existsSync(settingsDest)) {
838
+ fs.copyFileSync(settingsSrc, settingsDest);
839
+ } else {
840
+ // Merge hooks and permissions from package into existing settings
841
+ try {
842
+ const pkgSettings = JSON.parse(fs.readFileSync(settingsSrc, 'utf8'));
843
+ const userSettings = JSON.parse(fs.readFileSync(settingsDest, 'utf8'));
844
+
845
+ // Upgrade legacy relative hook commands FIRST so the anchored template entries below
846
+ // dedupe against them instead of doubling up (see migrateLegacyHookCommands).
847
+ migrateLegacyHookCommands(userSettings);
848
+
849
+ // Additively fold package hooks/env/permissions into the user's settings
850
+ // (shared with the plugin installer see mergeSettingsInto).
851
+ mergeSettingsInto(userSettings, pkgSettings);
852
+
853
+ fs.writeFileSync(settingsDest, JSON.stringify(userSettings, null, 2));
854
+ } catch (err) {
855
+ // If merge fails, don't break the install
856
+ }
857
+ }
858
+ }
859
+
860
+ console.log('\x1b[32m%s\x1b[0m', '✅ Prepared /autoconfig command');
861
+
862
+ // Show what was installed/updated
863
+ if (isUpgrade && (newCommands.length > 0 || updatedCommands.length > 0)) {
864
+ console.log();
865
+ for (const { file, version } of newCommands) {
866
+ const name = file.replace('.md', '');
867
+ const ver = version > 0 ? ` v${version}` : '';
868
+ console.log('\x1b[36m%s\x1b[0m', ` + /${name}${ver} (new)`);
869
+ }
870
+ for (const { file, oldVersion, newVersion } of updatedCommands) {
871
+ if (oldVersion > 0 && newVersion > 0 && oldVersion === newVersion) continue;
872
+ const name = file.replace('.md', '');
873
+ const ver = (oldVersion > 0 && newVersion > 0) ? ` (v${oldVersion} → v${newVersion})` : ' (updated)';
874
+ console.log('\x1b[33m%s\x1b[0m', ` ↑ /${name}${ver}`);
875
+ }
876
+ }
877
+
878
+
879
+ // Pre-mark all bundled updates as applied when the @applied block is empty.
880
+ // On fresh installs, /autoconfig handles their content (e.g., debug methodology in MEMORY.md).
881
+ // On upgrades from pre-update-system versions, these updates are already baked in.
882
+ // The regex only matches an empty @applied block, so this is safe to run unconditionally.
883
+ {
884
+ const userCmdPath = path.join(claudeDest, 'commands', 'autoconfig-update.md');
885
+ const packageUpdatesDir = path.join(packageDir, '.claude', 'updates');
886
+ if (fs.existsSync(userCmdPath) && fs.existsSync(packageUpdatesDir)) {
887
+ const updateFiles = fs.readdirSync(packageUpdatesDir)
888
+ .filter(f => f.endsWith('.md') && /^\d{3}-/.test(f))
889
+ .sort();
890
+ if (updateFiles.length > 0) {
891
+ const appliedLines = updateFiles.map(file => {
892
+ const id = file.match(/^(\d{3})-/)[1];
893
+ const content = fs.readFileSync(path.join(packageUpdatesDir, file), 'utf8');
894
+ const titleMatch = content.match(/<!-- @title (.+?) -->/);
895
+ const title = titleMatch ? titleMatch[1] : file.replace(/^\d{3}-/, '').replace(/\.md$/, '');
896
+ return `${id} - ${title}`;
897
+ });
898
+ const cmdContent = fs.readFileSync(userCmdPath, 'utf8');
899
+ const updated = cmdContent.replace(
900
+ /<!-- @applied\r?\n-->/,
901
+ `<!-- @applied\n${appliedLines.join('\n')}\n-->`
902
+ );
903
+ fs.writeFileSync(userCmdPath, updated);
904
+ }
905
+ }
906
+ }
907
+
908
+ // Clean up updates directory — updates are tracked in the @applied block,
909
+ // so the .md files don't need to stay in the user's project
910
+ const userUpdatesDir = path.join(claudeDest, 'updates');
911
+ if (fs.existsSync(userUpdatesDir)) {
912
+ fs.rmSync(userUpdatesDir, { recursive: true });
913
+ }
914
+
915
+ // Migrate FEEDBACK.md content to CLAUDE.md Discoveries section (one-time, on upgrade)
916
+ if (isUpgrade) {
917
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
918
+ const feedbackPath = path.join(claudeDest, 'feedback', 'FEEDBACK.md');
919
+
920
+ if (fs.existsSync(claudeMdPath) && fs.existsSync(feedbackPath)) {
921
+ const feedbackContent = fs.readFileSync(feedbackPath, 'utf8');
922
+
923
+ // Extract custom content (everything after the first --- separator following the header)
924
+ const feedbackLines = feedbackContent.split(/\r?\n/);
925
+ let firstSeparatorIdx = -1;
926
+ for (let i = 0; i < feedbackLines.length; i++) {
927
+ if (feedbackLines[i].trim() === '---') {
928
+ firstSeparatorIdx = i;
929
+ break;
930
+ }
931
+ }
932
+
933
+ if (firstSeparatorIdx >= 0) {
934
+ const customContent = feedbackLines.slice(firstSeparatorIdx + 1).join('\n').trim();
935
+
936
+ // Only migrate if there's custom content and it hasn't already been migrated
937
+ const claudeMdContent = fs.readFileSync(claudeMdPath, 'utf8');
938
+ const hasDiscoveries = claudeMdContent.includes('## Discoveries');
939
+
940
+ if (customContent.length > 0 && !hasDiscoveries) {
941
+ // Add Discoveries section to CLAUDE.md
942
+ const discoveriesSection = `\n\n## Discoveries\n<!-- Claude: append project-specific learnings, gotchas, and context below. This section persists across /autoconfig runs. -->\n\n${customContent}\n`;
943
+ fs.writeFileSync(claudeMdPath, claudeMdContent + discoveriesSection);
944
+
945
+ // Reset FEEDBACK.md to clean template
946
+ const cleanTemplate = `<!-- @description Human-authored corrections and guidance for Claude. Reserved for team feedback only — Claude must not write here. This directory persists across /autoconfig runs. -->\n\n# Team Feedback\n\n**This file is for human-authored corrections and guidance only.**\nClaude reads this file but must never write to it. When Claude discovers project context, gotchas, or learnings, it should append to the \`## Discoveries\` section in CLAUDE.md instead.\n\n---\n\n`;
947
+ fs.writeFileSync(feedbackPath, cleanTemplate);
948
+
949
+ // Count migrated sections
950
+ const sectionCount = (customContent.match(/^## /gm) || []).length || 1;
951
+ console.log('\x1b[36m%s\x1b[0m', ` 📋 Migrated ${sectionCount} section${sectionCount > 1 ? 's' : ''} from FEEDBACK.md → CLAUDE.md Discoveries`);
952
+ }
953
+ }
954
+ }
955
+ }
956
+
957
+ // Write current version marker
958
+ fs.writeFileSync(versionFile, currentVersion);
959
+
960
+ const launchCommand = isUpgrade ? '/autoconfig-update' : '/autoconfig';
961
+
962
+ // --bootstrap: copy files only, exit silently (used by /autoconfig inside Claude)
963
+ const bootstrapMode = process.argv.includes('--bootstrap');
964
+ if (bootstrapMode) {
965
+ process.exit(0);
966
+ }
967
+
968
+ // Step 4: Show "READY" message
969
+ console.log();
970
+ if (isUpgrade) {
971
+ console.log('\x1b[33m╔════════════════════════════════════════════╗\x1b[0m');
972
+ console.log('\x1b[33m║ ║\x1b[0m');
973
+ console.log('\x1b[33m║\x1b[0m \x1b[33;1mREADY TO UPDATE\x1b[0m \x1b[33m║\x1b[0m');
974
+ console.log('\x1b[33m║ ║\x1b[0m');
975
+ console.log('\x1b[33m║\x1b[0m \x1b[36mPress ENTER to launch Claude and\x1b[0m \x1b[33m║\x1b[0m');
976
+ console.log('\x1b[33m║\x1b[0m \x1b[36mauto-run /autoconfig-update\x1b[0m \x1b[33m║\x1b[0m');
977
+ console.log('\x1b[33m║ ║\x1b[0m');
978
+ console.log('\x1b[33m╚════════════════════════════════════════════╝\x1b[0m');
979
+ } else {
980
+ console.log('\x1b[33m╔════════════════════════════════════════════╗\x1b[0m');
981
+ console.log('\x1b[33m║ ║\x1b[0m');
982
+ console.log('\x1b[33m║\x1b[0m \x1b[33;1mREADY TO CONFIGURE\x1b[0m \x1b[33m║\x1b[0m');
983
+ console.log('\x1b[33m║ ║\x1b[0m');
984
+ console.log('\x1b[33m║\x1b[0m \x1b[36mPress ENTER to launch Claude and\x1b[0m \x1b[33m║\x1b[0m');
985
+ console.log('\x1b[33m║\x1b[0m \x1b[36mauto-run /autoconfig\x1b[0m \x1b[33m║\x1b[0m');
986
+ console.log('\x1b[33m║ ║\x1b[0m');
987
+ console.log('\x1b[33m╚════════════════════════════════════════════╝\x1b[0m');
988
+ }
989
+ // Show what changed on the upgrade path so a re-run never looks like "nothing came down":
990
+ // grouped features/fixes since the installed version, or a single confirmation line when
991
+ // already on the latest. Rendered here so it lands right before the ENTER prompt.
992
+ // Logic lives in update-summary.js (pure + unit-tested).
993
+ if (isUpgrade) {
994
+ const changelogPath = path.join(packageDir, 'CHANGELOG.md');
995
+ const changelogText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
996
+ console.log();
997
+ for (const seg of formatUpdateSummary(previousVersion, currentVersion, changelogText)) {
998
+ if (seg.kind === 'latest') console.log(`\x1b[32m ✓ ${seg.text}\x1b[0m`);
999
+ else if (seg.kind === 'heading') console.log(`\x1b[36m ${seg.text}\x1b[0m`);
1000
+ else if (seg.kind === 'group') console.log(`\x1b[33m ${seg.text}:\x1b[0m`);
1001
+ else if (seg.kind === 'item') console.log(`\x1b[90m • ${seg.text}\x1b[0m`);
1002
+ else if (seg.kind === 'more') console.log(`\x1b[90m ${seg.text}\x1b[0m`);
1003
+ }
1004
+ console.log();
1005
+ }
1006
+ if (!isUpgrade) {
1007
+ console.log('\x1b[90m%s\x1b[0m', "You'll need to approve a few file prompts to complete the installation.");
1008
+ console.log();
1009
+ }
1010
+
1011
+ // Step 5: Wait for Enter, then launch Claude Code
1012
+ const rl = readline.createInterface({
1013
+ input: process.stdin,
1014
+ output: process.stdout
1015
+ });
1016
+
1017
+ rl.question('\x1b[90mPress ENTER to continue...\x1b[0m', () => {
1018
+ rl.close();
1019
+
1020
+ console.log();
1021
+ console.log('\x1b[36m%s\x1b[0m', `🚀 Launching Claude Code with ${launchCommand}...`);
1022
+ console.log();
1023
+ console.log('\x1b[90m%s\x1b[0m', ' Heads up: Claude Code can take 30+ seconds to initialize.');
1024
+ console.log('\x1b[90m%s\x1b[0m', ' Please be patient while it loads.');
1025
+ console.log();
1026
+
1027
+ // Spawn claude with the appropriate command
1028
+ const claude = spawn('claude', [launchCommand], {
1029
+ cwd: cwd,
1030
+ stdio: 'inherit',
1031
+ shell: true
1032
+ });
1033
+
1034
+ claude.on('error', (err) => {
1035
+ console.log('\x1b[31m%s\x1b[0m', '❌ Failed to launch Claude Code');
1036
+ console.log(` Run "claude" manually, then run ${launchCommand}`);
1037
+ });
1038
+
1039
+ // Cleanup when Claude exits
1040
+ claude.on('close', () => {
1041
+ cleanupNulFile();
1042
+ });
1043
+ });